La mayoría de los desarrolladores han oído hablar del principio abierto-cerrado, uno de los principios SOLID del tío Bob. Suena razonable, pero puede ser un poco confuso hasta el primer uso en código "vivo". El estado completo del principio es: las entidades de software (clases, módulos, funciones, etc.) deben estar abiertas a la extensión, pero cerradas a la modificación.
¿Qué significa realmente?
Nos hemos encontrado con un problema de desarrollo que nos ha mostrado en qué consiste realmente el principio de abierto-cerrado. En una de nuestras aplicaciones web teníamos un formulario con dos secciones (entre otras):
- canales de demanda
- filtros dinámicos
Los usuarios pueden añadir tantos filtros como deseen, pero hay algunas reglas: la disponibilidad de los filtros depende de los canales elegidos.
Canales de demanda: ADINTERCAMBIO, CABECERAPUJA, RESERVA, OTROS Filtros dinámicos: sitio web, anunciounidad, geo, creativotamaño, dispositivo
Este artículo es sobre todo acerca de refactorización de código, por lo que habrá una gran cantidad de fragmentos de código a continuación. Traté de reducirlo, pero una cierta cantidad de código es necesario para mostrar refactorización del código. No es necesario entender cada pequeña parte del código para captar la idea principal.
La primera aplicación del problema fue sencilla:
clase ResearchFormStateUpdater {
update () {
(...)
this._updateDynamicFilters();
}
_actualizarFiltrosDinámicos () {
$('.dynamic-filter').each((_, filter) => {
$(filter).trigger('dynamicFilter:disableWebsites', this._shouldDisableWebsitesFields());
});
}
_shouldDisableWebsitesFields () {
return this._shouldDisableFields(ResearchFormStateUpdater.WEBSITE_DISABLING_DEMAND_CHANNELS);
}
_shouldDisableFields (disablingDemandChannels) {
// ¿se ha marcado disablingDemandChannels ?
}
}
ResearchFormStateUpdater.WEBSITE_DISABLING_DEMAND_CHANNELS = ['header_bidding', 'reservation', 'other'];
clase ResearchDynamicFilter {
_setDynamicFilterDisableWebsitesEvent () {
$(this._getBody()).on('dynamicFilter:disableWebsites', (event, shouldDisableWebsites) => {
// desactivar filtros de sitios web
});
}
}
Como puede ver, se supone que el filtro del sitio web no está disponible para HEADERPUJA, RESERVA y OTROS canales, por lo que sólo está disponible para ADCanal de INTERCAMBIO.
Lo último que se puede decir del código es que es permanente o estático. Así que tenemos más peticiones de nuestro cliente haciendo estas clases más grandes y más complejas.
Desarrollo de funciones
Spoiler alert -> cuando un componente está abierto a cambios, habrá muchos cambios de nombre en el futuro. No prestaremos atención a esto en los próximos pasos.
Añadir otro filtro para "Producto (Producto el esquema de disponibilidad del filtro es el mismo que el del sitio web)
- FiltroDinámicoDeInvestigación la clase tiene que comprobar una dimensión más al desactivar/activar campos
Vamos a ir más grande y añadir un poco de conmutador por encima de los canales -> 'Fuente'. Todos los canales de demanda que teníamos hasta ahora están en la fuente Ad Manager. La nueva fuente - SSP - no tiene canales de demanda y el único filtro disponible es sitio web.
Reglas:
- Hay dos estados de fuente: Ad Manager, SSP.
- Todos nuestros canales de demanda están disponibles sólo para la fuente Ad Manager.
- No hay canales de demanda para la fuente SSP
- Sitio web" es el único filtro disponible para la fuente SSP.
Implantación:
Añadir otro filtro para "Plataforma
Reglas:
- La plataforma sólo está disponible cuando la fuente es SSP
Dificultad:
- Ahora tenemos 'Website', que está disponible para el canal AD_EXCHANGE del Ad Manager y para Ssp y tenemos 'Platform' que está disponible para Ssp pero no para Ad Manager
- Conmutación el estado del formulario puede ser realmente complicado y confuso
Implementación con nuevas funcionalidades:
Le presento el siguiente fragmento principalmente para mostrar la complejidad del código. Siéntase libre de dejarlo oculto.
clase ResearchFormStateUpdater {
update () {
(...)
this._triggerCallbacks();
}
_triggerCallbacks () {
// elegir callbacks dependiendo de la fuente
}
_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) {
// se comprueba cualquiera de disablingDemandChannels
}
}
ResearchFormStateUpdater.AD_MANAGER_DISABLING_DEMAND_CHANNELS = ['header_bidding', 'reservation', 'other', 'ebda'];
clase ResearchDynamicFilter {
// No he simplificado el cuerpo de estos dos métodos para mostrar la complejidad de la implementación actual
_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 === 'plataforma') {
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) => {
// cambiar el estado del filtro en función de 'shouldDisable
});
}
}
ResearchDynamicFilter.NON_SSP_FILTERS_OPTIONS = ['ad_unit', 'creative_size', 'geo', 'device', 'product'];
Todavía utilizamos algunos alternar mecanismo. Es realmente difícil cambiar 4 palancas y llegar al estado esperado y ahora DynamicFilter tiene que saber, qué dimensiones no son para la fuente ssp.
Tenemos ResearchFormStateUpdater, ¿por qué no debería estar a cargo?
Petición final
Añadir otro filtro para "Socio de rendimiento
Ese es el momento exacto en el que decidimos refactorizar esas clases. Los canales y filtros analizados son sólo una pequeña parte del problema. Hay múltiples secciones de formularios aquí y todas ellas tienen el mismo problema. Nuestra refactorización debería neutralizar la necesidad de cambiar métodos internos de esas clases *para* añadir nuevos canales o dimensiones.
En el siguiente fragmento, he dejado las clases principales casi como están en nuestro código de producción para mostrarte lo fáciles de entender que son ahora.
clase ResearchFormStateUpdater {
update () {
(...)
this._updateDynamicFilters();
}
actualizarFiltrosDinámicos () {
this._toggleAllDynamicFiltersState(this._dynamicFiltersDimensionsToBeDisabled());
}
Si se desactiva la función _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(filtro, disabledFilters);
});
}
_toggleDynamicFilterState (dynamicFilter, disabledFilters) {
$(dynamicFilter).trigger('dynamicFilter:toggleDynamicFilters', disabledFilters);
}
}
ResearchFormStateUpdater.NO_SSP_FILTERS = ['ad_unit', 'creative_size', 'geo', 'device', 'product'];
ResearchFormStateUpdater.ONLY_SSP_FILTERS = ['plataforma'];
ResearchFormStateUpdater.ONLY_ADX_FILTERS = ['sitio web', 'producto'];
clase ResearchDynamicFilter {
_setDynamicFiltersToggleEvent () {
$(this._getBody()).on('dynamicFilter:toggleDynamicFilters', (event, disabledFilters) => {
this._disableFilters(disabledFilters.split(','));
this._enableFilters(disabledFilters.split(',')));
});
}
_disableFilters (filtersToDisable) {
//deshabilitarfiltrosParaDeshabilitar
}
_enableFilters (filtersToDisable) {
const filtersToEnable = $(ResearchDynamicFilter.ALL_FILTERS).not(filtersToDisable).get();
// habilitar filtrosHabilitar
}
}
ResearchDynamicFilter.ALL_FILTERS = ['website', 'ad_unit', 'creative_size', 'geo', 'device', 'product', 'platform'];
Lo hemos conseguido. ¿Lo logramos?
Ahora lo único que 'ResearchDynamicFilter' tiene que saber es una lista de todos los filtros - parece justo. El resto de la lógica y el control viene de arriba - algunos métodos superiores y constantes.
Probemos nuestra nueva estructura añadiendo un filtro para "Yield_partner":
clase 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'];
Como puedes ver, se trata de añadir algunos valores a las constantes y algunas condiciones adicionales.
Gracias al "principio abierto-cerrado" podemos cambiar la lógica de negocio del formulario con sólo añadir algunos valores y condiciones en un nivel superior de abstracción. No necesitamos entrar en el componente y cambiar nada. Esta refactorización afectó a todo el formulario y había más secciones y ahora todas obedecen al principio abierto-cerrado.
No hemos reducido la cantidad de código; de hecho, incluso la hemos aumentado (antes/después):
- ActualizadorInvestigaciónFormStateUpdater - 211/282 líneas
- FiltroDinámicoDeInvestigación - 267/256 líneas
Se trata de la colección en constantes -> es nuestra interfaz pública ahora, nuestra consola para controlar el proceso sin decenas de conmutadores.
Lea también: