Como uma classe deve comunicar aos seus usuários qual subconjunto de métodos ela implementa?

12

Cenário

Um aplicativo da web define uma interface de back-end do usuário IUserBackendcom os métodos

  • getUser (uid)
  • createUser (uid)
  • deleteUser (uid)
  • setPassword (uid, senha)
  • ...

Diferentes back-ends de usuários (por exemplo, LDAP, SQL, ...) implementam essa interface, mas nem todos os back-end podem fazer tudo. Por exemplo, um servidor LDAP concreto não permite que este aplicativo da Web exclua usuários. Portanto, a LdapUserBackendclasse que implementa IUserBackendnão implementará deleteUser(uid).

A classe concreta precisa comunicar ao aplicativo da Web o que ele pode fazer com os usuários do back-end.

Solução conhecida

Eu vi uma solução em que IUserInterfacetem um implementedActionsmétodo que retorna um número inteiro que é o resultado de ORs bit a bit das ações AND bit a bit com as ações solicitadas:

function implementedActions(requestedActions) {
    return (bool)(
        ACTION_GET_USER
        | ACTION_CREATE_USER
        | ACTION_DELTE_USER
        | ACTION_SET_PASSWORD
        ) & requestedActions)
}

Onde

  • ACTION_GET_USER = 1
  • ACTION_CREATE_USER = 2
  • ACTION_DELETE_USER = 4
  • ACTION_SET_PASSWORD = 8
  • .... = 16
  • .... = 32

etc.

Portanto, o aplicativo da Web define uma máscara de bits com o que precisa e implementedActions()responde com um booleano, se é compatível com eles.

Opinião

Essas operações de bits para mim parecem relíquias da era C, não necessariamente fáceis de entender em termos de código limpo.

Questão

O que é um padrão moderno (melhor?) Para a classe comunicar o subconjunto dos métodos de interface que implementa? Ou o "método de operação de bits" de cima ainda é a melhor prática?

( No caso de ser importante: PHP, embora eu esteja procurando uma solução geral para linguagens OO )

problemofficer
fonte
5
A solução geral é dividir a interface. O IUserBackendnão deve conter o deleteUsermétodo. Isso deve fazer parte IUserDeleteBackend(ou como você quiser chamar). O código que precisa excluir os usuários terá argumentos IUserDeleteBackend, o código que não precisa dessa funcionalidade será usado IUserBackende não terá problemas com métodos não implementados.
Bakuriu
3
Uma consideração importante do design é se a disponibilidade de uma ação depende das circunstâncias do tempo de execução. São todos os servidores LDAP que não oferecem suporte à exclusão? Ou isso é uma propriedade da configuração de um servidor e pode mudar com a reinicialização do sistema? Seu conector LDAP deve descobrir automaticamente essa situação ou exigir que a configuração seja alterada para conectar um conector LDAP diferente com recursos diferentes? Essas coisas têm um forte impacto sobre as soluções viáveis.
Sebastian Redl
@SebastianRedl Sim, isso é algo que eu não levei em consideração. Na verdade, eu preciso de uma solução para o tempo de execução. Desde que eu não queria invalidar as respostas muito boas, eu abri uma nova pergunta que se concentra em tempo de execução
problemofficer

Respostas:

24

De um modo geral, existem duas abordagens que você pode adotar aqui: teste e lançamento ou composição via polimorfismo.

Testar e lançar

Essa é a abordagem que você já descreve. Por alguns meios, você indica ao usuário da classe se certos outros métodos estão implementados ou não. Isso pode ser feito com um único método e uma enumeração bit a bit (como você descreve), ou através de uma série de supportsDelete()métodos etc.

Então, se supportsDelete()retornar false, a chamada deleteUser()poderá resultar em um NotImplementedExeptionlançamento, ou o método simplesmente não fará nada.

Esta é uma solução popular entre alguns, pois é simples. No entanto, muitos - inclusive eu - argumentam que é uma violação do princípio de substituição de Liskov (o L no SOLID) e, portanto, não é uma solução agradável.

Composição via Polimorfismo

A abordagem aqui é ver IUserBackendum instrumento muito franco. Se as classes nem sempre podem implementar todos os métodos nessa interface, divida a interface em partes mais focadas. Portanto, você pode ter: IGeneralUser IDeletableUser IRenamableUser ... Em outras palavras, todos os métodos que todos os seus back-end podem implementar entram IGeneralUsere você cria uma interface separada para cada uma das ações que somente alguns podem executar.

Dessa forma, você LdapUserBackendnão implementa IDeletableUsere você testa isso usando um teste como (usando a sintaxe C #):

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

(Não tenho certeza do mecanismo no PHP para determinar se uma instância implementa uma interface e como você faz a conversão para essa interface, mas tenho certeza de que há um equivalente nessa linguagem)

A vantagem desse método é que ele faz bom uso do polimorfismo para permitir que seu código esteja em conformidade com os princípios do SOLID e, na minha opinião, é muito mais elegante.

A desvantagem é que ela pode se tornar difícil de manejar com muita facilidade. Se, por exemplo, você tiver que implementar dezenas de interfaces porque cada back-end concreto possui recursos ligeiramente diferentes, essa não é uma boa solução. Por isso, aconselho que você use seu julgamento sobre se essa abordagem é prática para você nesta ocasião e a use, se for.

David Arno
fonte
4
+1 para considerações de projeto do SOLID. É sempre bom mostrar respostas com abordagens diferentes que manterão o código mais limpo!
Caleb
2
em PHP seriaif (backend instanceof IDelatableUser) {...}
Rad80
Você já mencionou a violação do LSP. Concordo, mas queria acrescentar um pouco: Test & Throw é válido se o valor de entrada impossibilitar a execução da ação, por exemplo, passar um 0 como divisor em um Divide(float,float)método. O valor de entrada é variável e a exceção cobre um pequeno subconjunto de possíveis execuções. Mas se você jogar com base no seu tipo de implementação, sua incapacidade de executar é um fato. A exceção abrange todas as entradas possíveis , não apenas um subconjunto delas. É como colocar uma placa de "piso molhado" em todos os pisos molhados de um mundo onde todos os pisos estão sempre molhados.
quer
Há uma exceção (trocadilho não pretendido) para o princípio de não jogar em um tipo. Para C #, isso é é NotImplementedException. Essa exceção é destinada a interrupções temporárias , ou seja, código que ainda não foi desenvolvido, mas será desenvolvido. Isso não é o mesmo que decidir definitivamente que uma determinada classe nunca fará nada com um determinado método, mesmo após a conclusão do desenvolvimento.
quer
Obrigado pela resposta. Na verdade, eu precisava de uma solução de tempo de execução, mas não o enfatizei na minha pergunta. Como não queria invalidar sua resposta, decidi criar uma nova pergunta .
problemofficer
5

A situação atual

A configuração atual viola o princípio de segregação de interface (o I no SOLID).

Referência

De acordo com a Wikipedia, o princípio de segregação de interface (ISP) declara que nenhum cliente deve ser forçado a depender dos métodos que não usa . O princípio de segregação de interface foi formulado por Robert Martin em meados dos anos 90.

Em outras palavras, se esta é sua interface:

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

Então, toda classe que implementa essa interface deve utilizar todos os métodos listados da interface. Sem exceção.

Imagine se houver um método generalizado:

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

Se você realmente deseja fazer com que apenas algumas das classes de implementação consigam excluir um usuário, esse método ocasionalmente aparece na sua cara (ou não faz nada). Isso não é um bom design.


Sua solução proposta

Eu vi uma solução em que o IUserInterface possui um método managedActions que retorna um número inteiro que é o resultado de ORs bit a bit das ações AND bit a bit com as ações solicitadas.

O que você essencialmente quer fazer é:

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

Estou ignorando como exatamente determinamos se uma determinada classe é capaz de excluir um usuário. Se é um sinal booleano, um pouco sinalizado, ... não importa. Tudo se resume a uma resposta binária: ele pode excluir um usuário, sim ou não?

Isso resolveria o problema, certo? Bem, tecnicamente, ele faz. Mas agora, você está violando o Princípio de Substituição de Liskov (o L no SOLID).

Abandonando a explicação bastante complexa da Wikipedia, encontrei um exemplo decente no StackOverflow . Tome nota do exemplo "ruim":

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

    duck.Swim();
}

Presumo que você veja a semelhança aqui. É um método que deve lidar com um objeto abstraído ( IDuck, IUserBackend), mas devido a um design de classe comprometido, ele é forçado a lidar primeiro com implementações específicas ( ElectricDuckverifique se não é uma IUserBackendclasse que não pode excluir usuários).

Isso anula o objetivo de desenvolver uma abordagem abstrata.

Nota: O exemplo aqui é mais fácil de corrigir do que o seu caso. Por exemplo, basta ter a ElectricDuckprópria ativação dentro do Swim()método. Ambos os patos ainda são capazes de nadar, então o resultado funcional é o mesmo.

Você pode fazer algo semelhante. Não . Você não pode simplesmente fingir excluir um usuário, mas, na realidade, possui um corpo de método vazio. Embora isso funcione de uma perspectiva técnica, torna-se impossível saber se sua classe de implementação fará ou não algo quando solicitado. Esse é um terreno fértil para código não sustentável.


Minha solução proposta

Mas você disse que é possível (e correto) para uma classe de implementação lidar apenas com alguns desses métodos.

Por uma questão de exemplo, digamos que para toda combinação possível desses métodos, há uma classe que o implementará. Abrange todas as nossas bases.

A solução aqui é dividir a interface .

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);
}

Note que você poderia ter visto isso chegando no começo da minha resposta. O nome do Princípio de Segregação de Interface já revela que esse princípio foi projetado para fazer com que você separe as interfaces em um grau suficiente.

Isso permite que você misture e combine interfaces como desejar:

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
}

Toda classe pode decidir o que quer fazer, sem quebrar o contrato de sua interface.

Isso também significa que não precisamos verificar se uma determinada classe é capaz de excluir um usuário. Toda classe que implementa a IDeleteUserServiceinterface poderá excluir um usuário = Nenhuma violação do Princípio de Substituição de Liskov .

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

Se alguém tentar passar um objeto que não é implementado IDeleteUserService, o programa se recusará a compilar. É por isso que gostamos de ter segurança de tipo.

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

Nota de rodapé

Levei o exemplo ao extremo, segregando a interface nos menores pedaços possíveis. No entanto, se sua situação for diferente, você poderá se livrar de pedaços maiores.

Por exemplo, se todo serviço que pode criar um usuário for sempre capaz de excluir um usuário (e vice-versa), você poderá manter esses métodos como parte de uma única interface:

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

Não há nenhum benefício técnico em fazer isso, em vez de separar os pedaços menores; mas tornará o desenvolvimento um pouco mais fácil, pois requer menos caldeiras.

Flater
fonte
+1 para dividir as interfaces pelo comportamento que elas suportam, que é o objetivo de uma interface.
Greg Burghardt
Obrigado pela resposta. Na verdade, eu precisava de uma solução de tempo de execução, mas não o enfatizei na minha pergunta. Como não queria invalidar sua resposta, decidi criar uma nova pergunta .
problemofficer
@problemofficer: A avaliação do tempo de execução para esses casos raramente é a melhor opção, mas há realmente casos para fazê-lo. Nesse caso, você cria um método que pode ser chamado, mas pode acabar não fazendo nada (chame-o TryDeleteUserpara refletir isso); ou você tem o método intencionalmente lançar uma exceção se for uma situação possível, mas problemática. Usar uma abordagem CanDoThing()e DoThing()método funciona, mas exigiria que os chamadores externos usassem duas chamadas (e sejam punidas por não fazer isso), o que é menos intuitivo e não tão elegante.
Flater
0

Se você deseja usar tipos de nível superior, pode optar pelo tipo definido no idioma de sua escolha. Espero que ele forneça um pouco de sintaxe para fazer interseções e determinações de subconjuntos.

Isso é basicamente o que o Java faz com o EnumSet (menos o açúcar da sintaxe, mas ei, é o Java)

Bwmat
fonte
0

No mundo .NET, você pode decorar métodos e classes com atributos personalizados. Isso pode não ser relevante para o seu caso.

Parece-me, no entanto, que o problema que você tem pode ter mais a ver com um nível mais alto do design.

Se esse é um recurso da interface do usuário, como uma página ou componente de edição de usuários, como estão sendo ocultados os diferentes recursos? Nesse caso, 'test and throw' será uma abordagem bastante ineficiente para esse propósito. Ele pressupõe que, antes de carregar todas as páginas, você execute uma chamada simulada para cada função para determinar se o widget ou elemento deve ser oculto ou apresentado de forma diferente. Como alternativa, você tem uma página da Web que basicamente obriga o usuário a descobrir o que está disponível pelo 'teste e lançamento manual', qualquer rota de codificação que você seguir, porque o usuário não descobre que algo não está disponível até que um aviso pop-up seja exibido.

Portanto, para uma interface do usuário, convém analisar como você gerencia o recurso e vincular a escolha das implementações disponíveis a isso, em vez de fazer com que as implementações selecionadas controlem quais recursos podem ser gerenciados. Você pode querer examinar estruturas para compor dependências de recursos e definir explicitamente os recursos como entidades em seu modelo de domínio. Isso pode até estar vinculado à autorização. Essencialmente, decidir se um recurso está disponível ou não com base no nível de autorização pode ser estendido para decidir se um recurso é realmente implementado e, em seguida, os 'recursos' da interface do usuário de alto nível podem ter mapeamentos explícitos para os conjuntos de recursos.

Se essa é uma API da Web, a escolha geral do design pode ser complicada ao oferecer suporte a várias versões públicas da API 'Gerenciar usuário' ou do recurso REST 'Usuário', à medida que os recursos se expandem ao longo do tempo.

Para resumir, no mundo .NET, você pode explorar várias formas de Reflexão / Atributo para determinar antecipadamente quais classes implementam o que, mas, de qualquer forma, parece que os problemas reais estarão no que você faz com essas informações.

Sentinela
fonte