La plupart des développeurs ont entendu parler du principe ouvert - fermé, l'un des principes SOLID de l'oncle Bob. Il semble raisonnable, mais il peut encore être un peu flou jusqu'à la première utilisation sur un code "vivant". L'énoncé complet du principe est le suivant : les entités logicielles (classes, modules, fonctions, etc.) doivent être ouvertes à l'extension, mais fermées à la modification.
Qu'est-ce que cela signifie vraiment ?
Nous avons rencontré un problème de développement qui nous a montré ce qu'est réellement le principe "ouvert-fermé". Dans l'une de nos applications web, nous avions un formulaire comportant deux sections (entre autres) :
- canaux de la demande
- filtres dynamiques
Les utilisateurs peuvent ajouter autant de filtres qu'ils le souhaitent, mais il existe certaines règles - la disponibilité des filtres dépend des canaux choisis.
Canaux de demande : ADÉCHANGE, EN-TÊTEENCHÈRES, RÉSERVATION, AUTRES Filtres dynamiques (dimensions) : site web, annonceunité, géo, créatiftaille, dispositif
Cet article traite principalement du refactoring de code, il y aura donc beaucoup d'extraits de code ci-dessous. J'ai essayé de les réduire, mais une certaine quantité de code est nécessaire pour montrer ce qui suit refonte du code. Il n'est pas nécessaire de comprendre chaque petite partie du code pour en saisir l'idée principale.
La première mise en œuvre du problème était simple :
class ResearchFormStateUpdater {
update () {
(...)
this._updateDynamicFilters() ;
}
_updateDynamicFilters () {
$('.dynamic-filter').each((_, filter) => {
$(filter).trigger('dynamicFilter:disableWebsites', this._shouldDisableWebsitesFields()) ;
}) ;
}
_shouldDisableWebsitesFields () {
return this._shouldDisableFields(ResearchFormStateUpdater.WEBSITE_DISABLING_DEMAND_CHANNELS) ;
}
_shouldDisableFields (disablingDemandChannels) {
// l'un des champs de disablingDemandChannels est-il coché ?
}
}
ResearchFormStateUpdater.WEBSITE_DISABLING_DEMAND_CHANNELS = ['header_bidding', 'reservation', 'other'] ;
class ResearchDynamicFilter {
_setDynamicFilterDisableWebsitesEvent () {
$(this._getBody()).on('dynamicFilter:disableWebsites', (event, shouldDisableWebsites) => {
// désactive les filtres de sites web
}) ;
}
}
Comme vous pouvez le voir, le filtre de site web est censé être indisponible pour HEADERLes canaux BIDDING, RESERVATION et AUTRES sont donc disponibles uniquement pour ADCanal d'échange.
La dernière chose que l'on puisse dire à propos d'un code est qu'il est permanent ou statique. Nous avons donc plus de demandes de la part de nos clients, ce qui rend ces classes plus grandes et plus complexes.
Développement des fonctionnalités
Alerte au spoiler -> lorsqu'un composant est ouvert aux modifications, il y aura beaucoup de changements de noms à l'avenir. Nous n'y prêterons pas attention dans les prochaines étapes.
Ajouter un autre filtre pour "Produit (Produit Le schéma de disponibilité des filtres est le même que celui du site web)
- RechercheDynamiqueFiltre la classe doit vérifier la présence d'une dimension supplémentaire lors de la désactivation ou de l'activation des champs
Allons plus loin et ajoutons un sélecteur au-dessus des canaux -> "Source". Tous les canaux à la demande dont nous disposions jusqu'à présent se trouvent dans la source Ad Manager. La nouvelle source - SSP - n'a pas de canaux à la demande et le seul filtre disponible est le site web.
Règles :
- Il existe deux états de la source : Gestionnaire de publicité, SSP.
- Tous nos canaux de demande sont disponibles uniquement pour la source Ad Manager.
- Il n'y a pas de canaux de demande pour la source SSP
- Site web" est le seul filtre disponible pour la source SSP.
Mise en œuvre :
Ajouter un autre filtre pour "Plateforme
Règles :
- La plate-forme n'est disponible que lorsque la source est SSP.
Difficulté :
- Nous avons maintenant "Site web", qui est disponible pour le canal AD_EXCHANGE du gestionnaire de publicités et pour le SSP, et nous avons "Plateforme" qui est disponible pour le SSP mais pas pour le gestionnaire de publicités.
- Basculement l'état du formulaire peut s'avérer très délicat et confus
Mise en œuvre d'une nouvelle fonctionnalité :
Je vous présente l'extrait suivant principalement pour montrer la complexité du code. N'hésitez pas à le laisser caché.
class ResearchFormStateUpdater {
update () {
(...)
this._triggerCallbacks() ;
}
_triggerCallbacks () {
// choisir les rappels en fonction de la source
}
_adManagerSourceCallbacks () {
(...)
this._enableDemandChannels(ResearchFormStateUpdater.AD_MANAGER_DEMAND_CHANNELS) ;
this._updateDefaultStateOfDynamicFilters() ;
this._updateAdManagerDynamicFilters() ;
}
_sspSourceCallbacks () {
(...)
this._removeDemandChannelsActiveClassAndDisable(ResearchFormStateUpdater.AD_MANAGER_DEMAND_CHANNELS) ;
this._updateDefaultStateOfDynamicFilters() ;
}
_updateDefaultStateOfDynamicFilters () {
$('.dynamic-filter').each((_, filter) => {
$(filter).trigger('dynamicFilter:enableSspFilters', this.isSourceSsp) ;
}) ;
}
_updateAdManagerDynamicFilters () {
$('.dynamic-filter').each((_, filter) => {
$(filter).trigger('dynamicFilter:disableWebsitesAndProducts', this._areFormStateDimensionsDisabled() && !this.isSourceSsp) ;
}) ;
}
_shouldDisableFields (disablingDemandChannels) {
// si l'un des champs de disablingDemandChannels est vérifié
}
}
ResearchFormStateUpdater.AD_MANAGER_DISABLING_DEMAND_CHANNELS = ['header_bidding', 'reservation', 'other', 'ebda'] ;
class ResearchDynamicFilter {
// Je n'ai pas simplifié ces deux méthodes pour montrer la complexité de l'implémentation actuelle.
_setDefaultDynamicFiltersToggleEvent () {
$(this._getBody()).on('dynamicFilter:enableSspFilters', (event, shouldEnableSspOptions)) => {
this._setDefaultFiltersOptionDisabledState(shouldEnableSspOptions) ;
const selectedFilterDimension = this._getFiltersDimension().find('option:selected').val() ;
if (selectedFilterDimension === 'website') {
this._toggleChosenFilterDisabledState(false) ;
} else if (selectedFilterDimension === 'platform') {
this._toggleChosenFilterDisabledState(!shouldEnableSspOptions) ;
} else {
this._toggleChosenFilterDisabledState(shouldEnableSspOptions) ;
}
}) ;
}
_setDynamicFilterDisableWebsitesAndProductsEvent () {
$(this._getBody()).on('dynamicFilter:disableWebsitesAndProducts', (event, shouldDisableWebsitesAndProducts) => {
const selectedFilterDimension = this._getFiltersDimension().find('option:selected').val() ;
if ($.inArray(selectedFilterDimension, ['website', 'product']) >= 0) {
this._toggleChosenFilterDisabledState(shouldDisableWebsitesAndProducts) ;
}
this._setMethodSelectWebsiteAndProductOptionDisabledState(shouldDisableWebsitesAndProducts) ;
}) ;
}
_toggleNonSspFilters (dimensionSelect, shouldDisable) {
$.each(ResearchDynamicFilter.NON_SSP_FILTERS_OPTIONS, (_, option) => {
// basculer l'état du filtre en fonction de 'shouldDisable'
}) ;
}
}
ResearchDynamicFilter.NON_SSP_FILTERS_OPTIONS = ['ad_unit', 'creative_size', 'geo', 'device', 'product'] ;
Nous utilisons encore certains 'toggle' (bascule) mécanisme. Il est vraiment difficile d'actionner 4 leviers et d'obtenir l'état attendu, et maintenant DynamicFilter doit savoir quelles sont les dimensions qui ne sont pas pour la source ssp.
Nous avons ResearchFormStateUpdater, pourquoi ne serait-il pas responsable ?
Demande finale
Ajouter un filtre supplémentaire pour "Partenaire de rendement".
C'est à ce moment précis que nous avons décidé de remanier ces classes. Les canaux et les filtres analysés ne sont qu'une petite partie du problème. Il y a plusieurs sections de formulaire ici et toutes ont le même problème. Notre refactorisation devrait neutraliser le besoin de modifier les méthodes internes de ces classes *pour* ajouter de nouveaux canaux ou dimensions.
Dans l'extrait suivant, j'ai laissé les classes principales presque telles qu'elles sont dans notre code de production pour vous montrer à quel point elles sont faciles à comprendre maintenant.
class ResearchFormStateUpdater {
update () {
(...)
this._updateDynamicFilters() ;
}
_updateDynamicFilters () {
this._toggleAllDynamicFiltersState(this._dynamicFiltersDimensionsToBeDisabled()) ;
}
_dynamicFiltersDimensionsToBeDisabled () {
if (this.isSourceSsp) { return ResearchFormStateUpdater.NO_SSP_FILTERS ; }
var disabledFilters = ResearchFormStateUpdater.ONLY_SSP_FILTERS ;
if (this.areDemandChannelsExceptAdxSelected) {
disabledFilters = disabledFilters.concat(ResearchFormStateUpdater.ONLY_ADX_FILTERS) ;
}
return disabledFilters ;
}
_toggleAllDynamicFiltersState (disabledFilters) {
$('.dynamic-filter').each((_, filter) => {
this._toggleDynamicFilterState(filter, disabledFilters) ;
}) ;
}
_toggleDynamicFilterState (dynamicFilter, disabledFilters) {
$(dynamicFilter).trigger('dynamicFilter:toggleDynamicFilters', disabledFilters) ;
}
}
ResearchFormStateUpdater.NO_SSP_FILTERS = ['ad_unit', 'creative_size', 'geo', 'device', 'product'] ;
ResearchFormStateUpdater.ONLY_SSP_FILTERS = ['platform'] ;
ResearchFormStateUpdater.ONLY_ADX_FILTERS = ['website', 'product'] ;
class ResearchDynamicFilter {
_setDynamicFiltersToggleEvent () {
$(this._getBody()).on('dynamicFilter:toggleDynamicFilters', (event, disabledFilters) => {
this._disableFilters(disabledFilters.split(',')) ;
this._enableFilters(disabledFilters.split(','))) ;
}) ;
}
_disableFilters (filtersToDisable) {
// désactive les filtresToDisable
}
_enableFilters (filtersToDisable) {
const filtersToEnable = $(ResearchDynamicFilter.ALL_FILTERS).not(filtersToDisable).get() ;
// activer les filtresToEnable
}
}
ResearchDynamicFilter.ALL_FILTERS = ['website', 'ad_unit', 'creative_size', 'geo', 'device', 'product', 'platform'] ;
Nous avons réussi ! On l'a fait ?
Désormais, la seule chose que "ResearchDynamicFilter" doit connaître est la liste de tous les filtres, ce qui semble équitable. Le reste de la logique et du contrôle vient d'en haut - quelques méthodes et constantes supérieures.
Essayons donc notre nouvelle structure en ajoutant un filtre pour "Yield_partner" :
class ResearchFormStateUpdater {
_dynamicFiltersDimensionsToBeDisabled () {
(...)
if (this.areDemandChannelsExceptEbdaSelected) {
disabledFilters = disabledFilters.concat(ResearchFormStateUpdater.ONLY_EBDA_FILTERS) ;
}
return disabledFilters ;
}
}
ResearchFormStateUpdater.NO_SSP_FILTERS = [(...), 'yield_partner'] ;
ResearchFormStateUpdater.ONLY_EBDA_FILTERS = [(...), 'yield_partner'] ;
ResearchDynamicFilter.ALL_FILTERS = [(...), 'yield_partner'] ;
Comme vous pouvez le constater, il s'agit d'ajouter des valeurs à des constantes et des conditions supplémentaires.
Grâce au principe "ouvert-fermé", nous sommes en mesure de modifier la logique commerciale du formulaire en ajoutant simplement quelques valeurs et conditions à un niveau d'abstraction plus élevé. Nous n'avons pas besoin d'aller à l'intérieur du composant et de changer quoi que ce soit. Ce remaniement a affecté l'ensemble du formulaire et il y avait d'autres sections qui obéissent toutes au principe de l'ouverture-fermeture.
Nous n'avons pas réduit la quantité de code - en fait, nous l'avons même augmentée (avant/après) :
- ResearchFormStateUpdater - 211/282 lignes
- RechercheDynamiqueFiltre - 267/256 lignes
Il s'agit d'une collection de constantes -> c'est notre interface publique maintenant, notre console pour contrôler le processus sans des dizaines de commutateurs.
Lire aussi :