A long time ago in the land of Scala emerged new type-safe way of dependency injection. A longo prazo, traz mais problemas do que vale.
padrão de bolo
vamos lembrar o que é um padrão de bolo. Para compreendê-lo, precisamos de conhecer os auto-tipos. Eles foram destinados a ser usados como mixins e parecem mais ou menos assim:
trait UserRepository { def fetchUsers(userIds: Seq): Seq}class UserRepositoryDbImpl(database: Database) extends UserRepository { def fetchUsers(userIds: Seq): Seq = ...}trait UserRepositoryLogging { self: UserRepository => private lazy val logger: Logger = ... override def fetchUsers(userIds: Seq): Seq = { val users = super.fetchUsers(usersIds) logger.debug(s"""Fetched users by ids: ${users.mkString(", ")}""") users }}val userRepository: UserRepository = new UserRepositoryDbImpl(database) with UserRepositoryLogging
Aqui podemos adicionar traço UserRepositoryLogging
para tudo o que é UserRepository
implementação – ele não seria compilar o contrário. Adicionalmente, dentro de UserRepositoryLogging
estamos assumindo que é a implementação deUserRepository
. Assim, tudo o que é acessível dentro de UserRepository
também é acessível lá.
agora, uma vez que podemos acessar o que quer que tenha sido declarado Tipo (S) usado para auto-tipo, estamos autorizados a fazer isso:
trait UserRepository { def fetchUsers(userIds: Seq): Seq}trait UserRepositoryComponent { def userRepository: UserRepository}trait UserRepositoryComponentImpl extends UserRepositoryComponent { lazy val userRepository: UserRepository = new UserRepository { // ... }}trait SomeController { self: UserRepositoryComponent => def handleRequest(userIds: Seq): Response = { // ... userRepository.fetchUsers(userIds) // ... }}object SomeController extends SomeController with UserRepositoryComponentImpl
Dentro de SomeController
declaramos o auto-tipo, que garante, que o seu instanciáveis implementação terá userRepository
método. Assim, adicionando o mixin nós fornecemos a implementação e, assim, garantimos que a dependência seja injetada no tempo de compilação. Sem reflexão em tempo de execução, com segurança de tipo, sem configurações adicionais ou bibliotecas.
cada um desses componentes pode ser uma camada de nossa aplicação (camada lógica de negócios, a camada de infra-estrutura, etc), que alguém em comparação com as camadas do bolo. Então, ao criar a sua aplicação de tal forma que você está efetivamente fazendo um bolo. Assim, o padrão do bolo.porque é que isso é um problema?
adicionando dependências
em projetos maiores, o número de serviços, repositórios e utilitários será certamente maior que 1-2. Assim que o bolo vai começar a se parecer com:
object PaymentTransactionApiController extends TransactionApiController with ConfigComponentImpl with DatabaseComponentImpl with UserRepositoryComponentImpl with SessionRepositoryComponentImpl with SecurityServicesComponentImpl with ExternalPaymentApiServicesComponentImpl with PaymentServicesComponentImpl with TransactionServicesComponentImpl
Como uma questão de fato, eu vi uma vez um bolo onde ComponentImpl
passou por 2-3 telas. Não estavas a injetar Componentes apenas aqueles de que precisavas, estavas a combinar tudo de uma vez. : infra-estrutura, Serviços de domínio, persistência, lógica empresarial…
Agora, imagine o que acontece quando você precisa adicionar uma dependência. Você começa por adicionar outro with Dependency
ao seu auto-tipo. Em seguida, você verifica onde o traço atualizado é usado. Você possivelmente tem que adicionar self-type aqui também. Você sobe a árvore de mixins até chegar a uma classe ou um objeto. Uff.
exceto, desta vez você adicionou isso você mesmo, então você meio que sabe o que precisa ser adicionado. Mas quando você está fazendo o ajuste ou resolvendo um conflito, você pode acabar em uma situação quando alguma nova dependência apareceu que você não tem. Erro de compilador diz apenas que self-type X does not conform to Y
. Com mais de 50 componentes, mais vale adivinhar qual falha. (Ou começar a fazer uma pesquisa binária com a remoção de componentes até que o erro desapareça).
Quando o tamanho do bolo e a quantidade cresce ainda mais, pode – se querer implementar a regra seca e dividi-la em bolos menores, que serão colocados juntos mais tarde. Isto descobre novas profundezas do inferno.
removendo dependências
com DI normal quando você parar de usar algum objeto, seu IDE / compilador pode dizer-lhe que não é mais necessário, então você pode removê-lo. Com o padrão do bolo, você não será informado sobre tais coisas.
Como resultado, a limpeza é muito mais difícil e é bem possível que em algum momento você vai acabar com muito mais dependências que você realmente precisa.
tempo de Compilação
tudo isso se soma ao tempo de compilação. Pode não ser óbvio à primeira vista, mas pode acabar com:
trait ModuleAComponentImpl { self: ModuleBComponent => }trait ModuleBComponentImpl { self: ModuleAComponent => }
que é uma dependência cíclica. (Todos sabemos que eles são maus, mas você pode nem notar, que você acabou de criar um. Especialmente se você inicializar tudo preguiçosamente).
de modo que esses 2 traços serão muitas vezes compilados em conjunto, e o compilador incremental de zinco não vai ajudar com isso. Além disso, mudanças em qualquer um dos componentes de um bolo irão desencadear a recompilação de coisas acima dele no gráfico de dependência.
testar
dependências cíclicas criam outro problema. Você pode muito bem acabar com algo como:
trait AComponentImpl { self: BComponent => lazy val a: A = new A { b.someMethod() // use b }}trait BComponentImpl { self: AComponent => lazy val b: B = new B { a.someMethod() // use a }}object Cake extends AComponentImpl with BComponentImplCake.a // NPE!Cake.b // NPE!
cada componente estará no seu próprio ficheiro, por isso não irá notar esta dependência. Os testes com mock não o ajudarão com isso:
trait BComponentMock { val b: B = mock }val aComponent = new AComponentImpl with BComponentMock {}aComponent.a // works!
por isso, tudo ficará bem até que você realmente execute o código. Você sabe – o código é tipo-seguro, o que significa que o valor que você vai construir seguirá restrições de tipo. Mas essas restrições não significam nada como Nothing
(exceção lançada) é um subtipo perfeitamente válido do seu tipo requerido.
ordem de inicialização
o problema anterior está relacionado a este: Qual é a ordem da inicialização do componente?
com DI normal é óbvio – antes de colocar uma coisa na outra você precisa criá-la. Então você só olha para a ordem em que, você cria objetos.com um bolo você pode esquecer. Os valores dentro dos componentes serão frequentemente lazy val
s ou object
s. Assim, o primeiro atributo acedido irá tentar instanciar as suas dependências, que irão tentar instanciar as suas dependências, etc.
mesmo se formos com val
s é uma questão de ordem em que compusemos bolo – você sabe, linearização de traços. Desde que cada componente seja usado exatamente uma vez – nenhum problema. Mas se você secar o bolo, e alguns componentes repetirem, quando você os fundir…
em um mundo perfeito, coisas como a ordem de inicialização não deve importar, mas muitas vezes faz, e gostaríamos de saber, por exemplo, quando alguma conexão com o DB ou assinatura do tópico Kafka começou, e registrar as coisas, a fim de depurar falhas potenciais.
Boilerplate e lock-in
como você provavelmente notou este padrão gera um monte de boilerplate – basicamente cada repositório ou serviço teria que ter wrapper, apenas para anotar as dependências.
Uma vez que você vai por essa estrada você vai notar que é muito difícil se livrar do padrão do bolo. Cada implementação é colocada dentro de um traço, e suas dependências vêm do escopo, não através de parâmetros. Então, para cada classe, você teria que executar o refactor primeiro, a fim de ser capaz de instanciá-lo sem o invólucro.
mas então as dependências também teriam que ser refactored e assim por diante. O padrão do bolo é um compromisso feio, que dói e se opõe a você quando você tenta se retirar dele gradualmente.
literalmente, qualquer outra forma de DI em Scala, que eu sei (reflexão em tempo de execução A’la Guice, anotações macro a’la MacWire, implicitos, combinações desses 3) permite que você diga stop! a qualquer momento, e gradualmente mudar para outra solução.
resumo
padrão de bolo é uma solução para um problema de DI, que a longo prazo faz mais mal do que bem. Pelo que ouvi, foi usado no compilador Scala, e eles arrependem-se. Foi o mecanismo padrão de DI para o Play Framework, mas por causa de todas essas questões os autores decidiram mudar para Guice – sim, programadores funcionais preferiram reflexão em tempo de execução para digitar bolo seguro, então isso diz muito.então, quais são as alternativas?
- simples manual antigo argumento de passagem – não precisa explicar o que
- implicits – tem suas vantagens, até que seu projeto é mantido por mais de si mesmo, e colegas de trabalho começar a reclamar sobre o não-saber, o que está acontecendo, compilação lenta, não entendendo implicits…
- tempo de execução de reflexão – Guice é um maduro quadro fazendo DI exatamente da maneira que eu odeio. Foi usado em muitos projetos, programadores Java adoram – No-assim como eles amam a Primavera. Alguns deles deixaram a abstração vazar passando o injetor para a classe. Alguns deles querem que a sua configuração seja flexível-tão flexível que a fiação da classe pode falhar no tempo de execução, bleh!
- MacWire-a meio da passagem de argumentos manualmente e usando macros para tudo-verifica o escopo actual no tempo de compilação e injecta dependências para si. Se algumas estão faltando compilação falha. Enquanto isso, você escreve apenas
wire
- Framework – macro-baseado em Frame onde você constrói a receita para DI com um DSL. Ele tem suporte para o gerenciamento do ciclo de vida do objeto
- pulp – minha auto-promoção sem vergonha. Ele usou implicitos e anotações de macro para gerar provedores para classes
IMHO cada um deles deve funcionar melhor do que o padrão do bolo.