¿Cómo debe comunicar una clase a sus usuarios qué subconjunto de métodos implementa?

12

Guión

Una aplicación web define una interfaz de usuario IUserBackendcon 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 LdapUserBackendclase que implementa IUserBackendno 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 IUserInterfacetiene un implementedActionsmé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 )

problema oficial
fuente
55
La solución general es dividir la interfaz. El IUserBackendno debe contener el deleteUsermétodo en absoluto. Eso debería ser parte de IUserDeleteBackend(o como quieras llamarlo). El código que necesita eliminar usuarios tendrá argumentos IUserDeleteBackend, el código que no necesita esa funcionalidad lo usará IUserBackendy no tendrá problemas con los métodos no implementados.
Bakuriu
3
Una consideración importante del diseño es si la disponibilidad de una acción depende de las circunstancias de tiempo de ejecución. ¿Son todos los servidores LDAP que no admiten la eliminación? ¿O es una propiedad de la configuración de un servidor y puede cambiar con un reinicio del sistema? ¿Debería su conector LDAP descubrir automáticamente esta situación, o debería requerir que se cambie la configuración para enchufar un conector LDAP diferente con diferentes capacidades? Estas cosas tienen un fuerte impacto sobre qué soluciones son viables.
Sebastian Redl
@SebastianRedl Sí, eso es algo que no tuve en cuenta. De hecho, necesito una solución para el tiempo de ejecución. Como no quería invalidar las muy buenas respuestas, abrí una nueva pregunta que se centra en el tiempo de ejecución
problemofficer el

Respuestas:

24

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()devuelve false, la llamada deleteUser()puede resultar en un NotImplementedExeptionlanzamiento, 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 IUserBackendun 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 entran IGeneralUsery usted crea una interfaz separada para cada una de las acciones que solo algunos pueden realizar.

De esa manera, LdapUserBackendno se implementa IDeletableUsery lo prueba usando una prueba como (usando la sintaxis de C #):

if (backend is IDeletableUser deletableUser)
{
    deletableUser.deleteUser(id);
}

(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í.

David Arno
fuente
44
+1 para consideraciones de diseño SÓLIDO. ¡Siempre es bueno mostrar respuestas con diferentes enfoques que mantendrán el código más limpio en el futuro!
Caleb
2
en PHP seríaif (backend instanceof IDelatableUser) {...}
Rad80
Ya mencionaste la violación de LSP. Estoy de acuerdo, pero quería agregarle algo: Test & Throw es válido si el valor de entrada hace que sea imposible realizar la acción, por ejemplo, pasar un 0 como divisor en un 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.
Flater
Hay una excepción (juego de palabras no previsto) para el principio de no lanzar un tipo. Para C #, es decir 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.
Flater
Gracias por la respuesta. En realidad necesitaba una solución de tiempo de ejecución pero no pude enfatizarla en mi pregunta. Como no quería invadir su respuesta, decidí crear una nueva pregunta .
problemofficer
5

La situación actual

La configuración actual viola el Principio de segregación de interfaz (la I en SÓLIDO).

Referencia

Según Wikipedia, el principio de segregación de interfaz (ISP) establece que ningún cliente debería verse obligado a depender de métodos que no utiliza . El principio de segregación de interfaz fue formulado por Robert Martin a mediados de la década de 1990.

En otras palabras, si esta es su interfaz:

public interface IUserBackend
{
    User getUser(int uid);
    User createUser(int uid);
    void deleteUser(int uid);
    void setPassword(int uid, string password);
}

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:

public void HaveUserDeleted(IUserBackend backendService, User user)
{
     backendService.deleteUser(user.Uid);
}

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

He visto una solución en la que IUserInterface tiene un método implementActions que devuelve un entero que es el resultado de OR bit a bit de las acciones AND bit a bit con las acciones solicitadas.

Lo que esencialmente quieres hacer es:

public void HaveUserDeleted(IUserBackend backendService, User user)
{
     if(backendService.canDeleteUser())
         backendService.deleteUser(user.Uid);
}

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":

void MakeDuckSwim(IDuck duck)
{
    if (duck is ElectricDuck)
        ((ElectricDuck)duck).TurnOn();

    duck.Swim();
}

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 una IUserBackendclase 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 ElectricDuckencendido dentro del Swim()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 .

public interface IGetUserService
{
    User getUser(int uid);
}

public interface ICreateUserService
{
    User createUser(int uid);
}

public interface IDeleteUserService
{
    void deleteUser(int uid);
}

public interface ISetPasswordService
{
    void setPassword(int uid, string password);
}

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:

public class UserRetrievalService 
              : IGetUserService, ICreateUserService
{
    //getUser and createUser methods implemented here
}

public class UserDeleteService
              : IDeleteUserService
{
    //deleteUser method implemented here
}

public class DoesEverythingService 
              : IGetUserService, ICreateUserService, IDeleteUserService, ISetPasswordService
{
    //All methods implemented here
}

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 IDeleteUserServiceinterfaz podrá eliminar un usuario = Sin violación del Principio de sustitución de Liskov .

public void HaveUserDeleted(IDeleteUserService backendService, User user)
{
     backendService.deleteUser(user.Uid); //guaranteed to work
}

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.

HaveUserDeleted(new DoesEverythingService());    // No problem.
HaveUserDeleted(new UserDeleteService());        // No problem.
HaveUserDeleted(new UserRetrievalService());     // COMPILE ERROR

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:

public interface IManageUserService
{
    User createUser(int uid);
    void deleteUser(int uid);
}

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.

Flater
fuente
+1 para dividir las interfaces por el comportamiento que admiten, que es el objetivo de una interfaz.
Greg Burghardt
Gracias por la respuesta. En realidad necesitaba una solución de tiempo de ejecución pero no pude enfatizarla en mi pregunta. Como no quería invadir su respuesta, decidí crear una nueva pregunta .
problemofficer
@problemofficer: la evaluación de tiempo de ejecución para estos casos rara vez es la mejor opción, pero de hecho hay casos para hacerlo. En tal caso, puede crear un método que se puede llamar pero que puede terminar sin hacer nada (llámelo TryDeleteUserpara 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étodo CanDoThing()y DoThing()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.
Flater
0

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)

Bwmat
fuente
0

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.

Centinela
fuente