Guión
Una aplicación web define una interfaz de usuario IUserBackend
con los métodos
- getUser (uid)
- createUser (uid)
- deleteUser (uid)
- setPassword (uid, contraseña)
- ...
Diferentes backends de usuario (por ejemplo, LDAP, SQL, ...) implementan esta interfaz, pero no todos los backend pueden hacer todo. Por ejemplo, un servidor LDAP concreto no permite que esta aplicación web elimine usuarios. Entonces la LdapUserBackend
clase que implementa IUserBackend
no implementará deleteUser(uid)
.
La clase concreta necesita comunicar a la aplicación web lo que la aplicación web puede hacer con los usuarios del back-end.
Solución conocida
He visto una solución donde IUserInterface
tiene un implementedActions
método que devuelve un número entero que es el resultado de OR bit a bit de las acciones AND bit a bit con las acciones solicitadas:
function implementedActions(requestedActions) {
return (bool)(
ACTION_GET_USER
| ACTION_CREATE_USER
| ACTION_DELTE_USER
| ACTION_SET_PASSWORD
) & requestedActions)
}
Dónde
- ACTION_GET_USER = 1
- ACTION_CREATE_USER = 2
- ACTION_DELETE_USER = 4
- ACTION_SET_PASSWORD = 8
- .... = 16
- .... = 32
etc.
Entonces, la aplicación web establece una máscara de bits con lo que necesita y implementedActions()
responde con un booleano si los admite.
Opinión
Para mí, estas operaciones de bits parecen reliquias de la era C, no necesariamente fáciles de entender en términos de código limpio.
Pregunta
¿Cuál es un patrón moderno (¿mejor?) Para que la clase comunique el subconjunto de los métodos de interfaz que implementa? ¿O el "método de operación de bits" de arriba sigue siendo la mejor práctica?
( En caso de que sea importante: PHP, aunque estoy buscando una solución general para los lenguajes OO )
fuente
IUserBackend
no debe contener eldeleteUser
método en absoluto. Eso debería ser parte deIUserDeleteBackend
(o como quieras llamarlo). El código que necesita eliminar usuarios tendrá argumentosIUserDeleteBackend
, el código que no necesita esa funcionalidad lo usaráIUserBackend
y no tendrá problemas con los métodos no implementados.Respuestas:
En términos generales, hay dos enfoques que puede tomar aquí: prueba y lanzamiento o composición a través del polimorfismo.
Prueba y lanzamiento
Este es el enfoque que ya describe. Por algún medio, usted le indica al usuario de la clase si se implementan otros métodos o no. Esto se puede hacer con un método único y una enumeración a nivel de bits (como usted describe) o mediante una serie de
supportsDelete()
métodos, etc.Luego, si se
supportsDelete()
devuelvefalse
, la llamadadeleteUser()
puede resultar en unNotImplementedExeption
lanzamiento, o el método simplemente no hace nada.Esta es una solución popular entre algunos, ya que es simple. Sin embargo, muchos, incluido yo mismo, argumentan que es una violación del principio de sustitución de Liskov (la L en SÓLIDO) y, por lo tanto, no es una buena solución.
Composición a través del polimorfismo
El enfoque aquí es ver
IUserBackend
un instrumento demasiado contundente. Si las clases no siempre pueden implementar todos los métodos en esa interfaz, divida la interfaz en partes más enfocadas. Por lo tanto, es posible que tenga:IGeneralUser IDeletableUser IRenamableUser ...
En otras palabras, todos los métodos que todos sus backends pueden implementar entranIGeneralUser
y usted crea una interfaz separada para cada una de las acciones que solo algunos pueden realizar.De esa manera,
LdapUserBackend
no se implementaIDeletableUser
y lo prueba usando una prueba como (usando la sintaxis de C #):(No estoy seguro del mecanismo en PHP para determinar si una instancia implementa una interfaz y cómo se envía a esa interfaz, pero estoy seguro de que hay un equivalente en ese lenguaje)
La ventaja de este método es que hace un buen uso del polimorfismo para permitir que su código cumpla con los principios SÓLIDOS y, en mi opinión, es mucho más elegante.
La desventaja es que puede volverse difícil de manejar con demasiada facilidad. Si, por ejemplo, termina teniendo que implementar docenas de interfaces porque cada backend concreto tiene capacidades ligeramente diferentes, entonces esta no es una buena solución. Por lo tanto, simplemente le aconsejo que use su juicio sobre si este enfoque es práctico para usted en esta ocasión y lo use, si es así.
fuente
if (backend instanceof IDelatableUser) {...}
Divide(float,float)
método. El valor de entrada es variable, y la excepción cubre un pequeño subconjunto de ejecuciones posibles. Pero si lanza en función de su tipo de implementación, entonces su incapacidad para ejecutar es un hecho dado. La excepción cubre todas las entradas posibles , no solo un subconjunto de ellas. Es como poner un letrero de "piso mojado" en cada piso mojado en un mundo donde cada piso siempre está mojado.NotImplementedException
. Esta excepción está destinada a interrupciones temporales , es decir, código que aún no se ha desarrollado pero se desarrollará. Eso no es lo mismo que decidir definitivamente que una clase determinada nunca hará nada con un método determinado, incluso después de que se complete el desarrollo.La situación actual
La configuración actual viola el Principio de segregación de interfaz (la I en SÓLIDO).
Referencia
En otras palabras, si esta es su interfaz:
Entonces, cada clase que implemente esta interfaz debe utilizar todos los métodos enumerados de la interfaz. Sin excepción.
Imagínese si hay un método generalizado:
Si realmente lograra que solo algunas de las clases implementadoras puedan eliminar a un usuario, este método ocasionalmente explotará en su cara (o no hará nada). Eso no es buen diseño.
Tu solución propuesta
Lo que esencialmente quieres hacer es:
Estoy ignorando cómo exactamente determinamos si una clase determinada puede eliminar un usuario. Si es un booleano, un poco de bandera, ... no importa. Todo se reduce a una respuesta binaria: ¿puede eliminar un usuario, sí o no?
Eso resolvería el problema, ¿verdad? Bueno, técnicamente, lo hace. Pero ahora, estás violando el Principio de sustitución de Liskov (la L en SÓLIDO).
Renunciando a la explicación bastante compleja de Wikipedia, encontré un ejemplo decente en StackOverflow . Tome nota del ejemplo "malo":
Supongo que ves la similitud aquí. Es un método que se supone que maneja un objeto abstraído (
IDuck
,IUserBackend
), pero debido a un diseño de clase comprometido, se ve obligado a manejar primero implementaciones específicas (ElectricDuck
, asegúrese de que no sea unaIUserBackend
clase que no pueda eliminar usuarios).Esto anula el propósito de desarrollar un enfoque abstracto.
Nota: El ejemplo aquí es más fácil de solucionar que su caso. Por ejemplo, es suficiente tener el
ElectricDuck
encendido dentro delSwim()
método. Ambos patos todavía pueden nadar, por lo que el resultado funcional es el mismo.Es posible que desee hacer algo similar. No hacerlo . No puede simplemente pretender eliminar un usuario, sino que en realidad tiene un cuerpo de método vacío. Si bien esto funciona desde una perspectiva técnica, hace que sea imposible saber si su clase implementadora realmente hará algo cuando se le pida que haga algo. Ese es un caldo de cultivo para el código imposible de mantener.
Mi propuesta de solución
Pero usted dijo que es posible (y correcto) que una clase implementadora solo maneje algunos de estos métodos.
Por el bien de ejemplo, digamos que para cada combinación posible de estos métodos, hay una clase que lo implementará. Cubre todas nuestras bases.
La solución aquí es dividir la interfaz .
Tenga en cuenta que podría haberlo visto venir al comienzo de mi respuesta. El nombre del Principio de segregación de interfaz ya revela que este principio está diseñado para hacer que segregue las interfaces en un grado suficiente.
Esto le permite mezclar y combinar interfaces como desee:
Cada clase puede decidir lo que quiere hacer, sin romper el contrato de su interfaz.
Esto también significa que no necesitamos verificar si cierta clase puede eliminar un usuario. Cada clase que implemente la
IDeleteUserService
interfaz podrá eliminar un usuario = Sin violación del Principio de sustitución de Liskov .Si alguien intenta pasar un objeto que no se implementa
IDeleteUserService
, el programa se negará a compilar. Por eso nos gusta tener la seguridad de los tipos.Nota
Llevé el ejemplo al extremo, segregando la interfaz en los trozos más pequeños posibles. Sin embargo, si su situación es diferente, puede salirse con la suya con trozos más grandes.
Por ejemplo, si cada servicio que puede crear un usuario siempre es capaz de eliminar un usuario (y viceversa), puede mantener estos métodos como parte de una única interfaz:
No hay ningún beneficio técnico al hacer esto en lugar de separarse a los trozos más pequeños; pero hará que el desarrollo sea un poco más fácil porque requiere menos repeticiones.
fuente
TryDeleteUser
para reflejar eso); o tiene el método para lanzar deliberadamente una Excepción si es una situación posible pero problemática. El uso de un métodoCanDoThing()
yDoThing()
método funciona, pero requeriría que las personas que llaman externas usen dos llamadas (y se les castigue por no hacerlo), lo cual es menos intuitivo y no tan elegante.Si desea utilizar tipos de nivel superior, puede elegir el tipo de conjunto en el idioma que elija. Esperemos que proporcione algo de azúcar de sintaxis para hacer intersecciones de conjuntos y determinación de subconjuntos.
Esto es básicamente lo que Java hace con EnumSet (menos el azúcar de sintaxis, pero bueno, es Java)
fuente
En el mundo .NET puedes decorar métodos y clases con atributos personalizados. Esto puede no ser relevante para su caso.
Sin embargo, me parece que el problema que tiene puede tener más que ver con un mayor nivel de diseño.
Si se trata de una función de IU, como una página o componente de edición de usuarios, ¿cómo se enmascaran las diferentes capacidades? En este caso, 'probar y lanzar' será un enfoque bastante ineficiente para ese propósito. Se supone que antes de cargar cada página se ejecuta una llamada simulada a cada función para determinar si el widget o elemento debe ocultarse o presentarse de manera diferente. Alternativamente, tiene una página web que básicamente obliga al usuario a descubrir lo que está disponible mediante 'prueba y lanzamiento' manual, cualquiera que sea la ruta de codificación que tome, porque el usuario no descubre que algo no está disponible hasta que aparezca una advertencia emergente.
Por lo tanto, para una interfaz de usuario, es posible que desee analizar cómo gestiona las funciones y vincular la elección de las implementaciones disponibles a eso, en lugar de que las implementaciones seleccionadas controlen qué funciones se pueden administrar. Es posible que desee ver los marcos para componer dependencias de características y definir explícitamente las capacidades como entidades en su modelo de dominio. Esto incluso podría estar vinculado a la autorización. Esencialmente, decidir si una capacidad está disponible o no en función del nivel de autorización puede extenderse a decidir si una capacidad se implementa realmente, y luego las 'características' de la IU de alto nivel pueden tener asignaciones explícitas a conjuntos de capacidades.
Si se trata de una API web, la elección general del diseño puede ser complicada al tener que admitir múltiples versiones públicas de la API 'Administrar usuario' o el recurso REST 'Usuario' a medida que las capacidades se expanden con el tiempo.
Para resumir, en el mundo .NET puede explotar varias formas de Reflexión / Atributo para determinar de antemano qué clases implementan qué, pero en cualquier caso parece que los problemas reales estarán en lo que haga con esa información.
fuente