Este post é parte de uma série em que construo incrementalmente a ferramenta de injeção de dependência Reader, usando diferentes conceitos avançados de programação funcional em scala:
- Injeção de dependência para configurar conexão (ões) de banco de dados do Play Framework, parte 1
- Injeção de dependência para configurar conexão (ões) de banco de dados do Play Framework, parte 2
- Caril e Bolo sem indigestão de tipo – Covariância, Contravariância e a Mônada do Leitor
- Ferramentas do Reader Monad
- Generalizando o Reader Tooling, parte 1
- Generalizando o Reader Tooling, parte 2
Esta é uma continuação de um post anterior sobre injeção de dependência para configurar a conexão de banco de dados Play 2 , eu recomendo que você dê uma olhada primeiro para ter um pouco mais de contexto.
Coma um pouco de Bolo
No post anterior, eu discuti uma maneira de passar uma configuração para jogar ‘s DB.withConnection
, sem envenenar seus argumentos de função listas com detalhes de implementação. Isso foi conseguido usando implícitos, passando um String
com a chave de configuração a ser usada para o banco de dados SQL
No entanto, se você já trabalhou em um grande aplicativo da web, saberá que um String para configurar uma conexão não é realmente suficiente. O exemplo dado na documentação do Play é que você pode ter vários bancos de dados SQL como armazenamento de dados, mas cada vez mais os aplicativos da web dependem de outros tipos de armazenamento, apoiados por bancos de dados NoSQL ou acessados por meio de serviços da web.
Antes de entrar nos detalhes de outro método de injeção de dependência, vamos refatorar um pouco o código para poder trocar mais facilmente o tipo de armazenamento de dados usado em nosso aplicativo.
No post anterior, tivemos um exemplo de uma classe de modelo para um usuário, com um objeto complementar contendo as funções de acesso a dados:
case class User(name: String, email: String)
object User {
def findAll: Seq[User] = DB.withConnection { implicit conn =>
SQL(…))…
}
}
Agora, se você deseja trocar seu armazenamento SQL por NoSQL ou um serviço da web, seu User
objeto se torna obsoleto, você terá que alterar todo o seu código para usar um novo objeto Usuário. Se a sua alteração for definitiva, não é tão ruim, mas imagine que você deseja avaliar soluções diferentes ou, mais comumente, imagine que em seus testes, você deseja ter um armazenamento de dados “falso” que não dependa das consultas SQL. Você precisará de algo um pouco mais modular.
Uma solução é depender de uma interface de conexão separada, que define o acesso aos dados. Por conveniência, se desejar, você ainda pode ter um atalho em seu objeto companheiro Usuário. Portanto, primeiro você define uma característica que descreve sua conexão. Você pode criar esse objeto de conexão para os diferentes tipos de acesso que possa ter. Por exemplo, uma característica que descreve a fonte de dados para os usuários, outra para as conexões de seus amigos (que pode ser implementada como uma chamada para a API do Facebook, por exemplo), etc .:
trait UserConnection {
def findAllUsers: Seq[User]
def createUser(u: User): Either[Throwable, User]
}
trait SocialNetworkConnection {
def getFriends(u: User): Seq[User]
}
Agora, sua função de atalho no objeto complementar User vai depender desse traço de conexão, se seguirmos o padrão da última postagem, usando implícitos, você terá algo assim:
object User {
def findAll(implicit conn: UserConnection): Seq[User] = conn.findAll
def getFriends(u: User)(implicit conn: SocialNetworkConnection): Seq[User] = conn.getFriends(u)
}
Você pode então passar essa dependência, com implícita, nas funções de chamada, imagine:
def avgNumberFriends(implicit conn: UserConnection with SocialNetworkConnection): Double = {
val allUsers = User.findAll.view
allUsers.map(User.getFriends(_).length).reduce(_ + _).toDouble / allUsers.length
}
Isso nos dá uma boa abstração e podemos fazer diferentes características para diferentes tipos de objeto de modelo. Podemos então usar o padrão bolo para construir uma implementação válida, por exemplo:
class MySQLConnection(dbName: String) extends UserConnection with SocialNetworkConnection …
Que você pode instanciar da maneira certa, dependendo do seu contexto e requisitos de teste.implicit val
Comer um pouco de curry
Ok, então fizemos uma pequena refatoração para abstrair o tipo de conexão, mas ainda temos esses implícitos feios e confusos. Como mencionei no post anterior, eu os acho um pouco irritantes, pois escondem muito do que está acontecendo e se você não tiver cuidado, rapidamente terá confrontos no contexto implícito.
Em sua apresentação Dead-Simple Dependency Injection , Runar Oli apresentou uma abordagem interessante para fazer injeção de dependência sem usar implícitos. Se você prefere apresentações de vídeo a leitura, vá direto para a apresentação dele, é muito bom e cobre tudo o que vou discutir aqui. Nos parágrafos a seguir, vou apenas resumir a ideia e mostrar como ela pode ser usada no contexto de um aplicativo Play 2 . Embora eu use o Play 2 como exemplo, este é realmente um padrão que pode ser usado em qualquer código scala em que você dependa de recursos que podem ser trocados, portanto, continue lendo mesmo se não usar o Play.
A ideia é que, em vez de usar implícitos, você use currying para declarar a dependência e passá-la para cima em sua cadeia de chamadas. Simplificando, currying é um conceito de programação funcional em que sua função, em vez de retornar um resultado final, retorna uma nova função. Observe que a notação implícita em scala é um açúcar de sintaxe para currying, com pesquisa adicional no contexto para os implícitos.
Portanto, agora, em vez de usar um implicit
argumento, você retorna uma função:
object User {
def findAll: UserConnection => Seq[User] = conn => conn.findAll
def getFriends(u: User): SocialNetworkConnection => Seq[User] = conn => conn.getFriends(u)
}
Essa notação significa que você está retornando uma nova função tomando como argumento a UserConnection
e eventualmente retornando a . No momento, não é muito útil em comparação com os implícitos, imagine nossa chamada anterior combinada para as duas conexões:Seq[User]
def avgNumberFriends: UserConnection with SocialNetworkConnection => Double = conn => {
val allUsers = User.findAll(conn).view
allUsers.map(User.getFriends(_)(conn).length).reduce(_ + _).toDouble / allUsers.length
}
Você precisa adicionar um parâmetro extra para obter o resultado real e poder trabalhar nele. Vejo você já pensando FTW!(conn)
implicits
Mas espere! Existe um padrão de programação funcional que existe para nos ajudar … Mônadas!
The Reader Monad
Se você viu a palavra mônada e está com medo, não tenha, eu realmente não vou me aprofundar muito aqui, usaremos uma mônada, mas você não precisa saber todos os detalhes sangrentos para poder para usá-lo.
O Scalaz inclui a mônada do Reader, mas aqui está o código se você não quiser incluir tudo apenas para fazer este truque de DI:
object ReaderMonad {
implicit def reader[From, To](f: From => To) = Reader(f)
case class Reader[From, To](wrappedF: From => To) {
def apply(c: From) = wrappedF(c)
def map[Tob](transformF: To => Tob): Reader[From, Tob] =
Reader(c => transformF(wrappedF(c)))
def flatMap[Tob](transformF: To => Reader[From, Tob]): Reader[From, Tob] =
Reader(c => transformF(wrappedF(c))(c))
}
def pure[From, To](a: To) = Reader((c: From) => a)
}
Vamos dar uma olhada nisso, não tenha medo;)
Definimos uma classe Reader
que possui uma propriedade:, wrappedF
que é uma função. Esta função é o que nós queremos esconder em nosso exemplo anterior: . O Reader realmente envolve nosso tipo de retorno de função e nos permite ter uma assinatura mais limpa, mas também fazer algumas manipulações interessantes em nosso tipo de retorno de função.UserConnection => Seq[User]
A função na classe Reader é apenas um atalho para aplicar a função que ela envolve, como você pode ver, a única coisa que faz é chamar com o parâmetro fornecido.apply(c: From)
wrappedF
O Reader
leva dois parâmetros de tipo para descrever a função que envolve:
From
o tipo de argumento da funçãoTo
o tipo de retorno da função
O map
e flatMap
nos permitem manipular o tipo de retorno da função agrupada. Isso é muito semelhante às funções map
e flatMap
nas coleções em scala. Por exemplo, uma sequência de Int
:
val intSeq: Seq[Int] = Seq(1, 2, 3, 4)
pode ser transformado em uma sequência de String
:
val strSeq: Seq[String] = intSeq.map(_.toString)
Em nosso exemplo anterior, temos:, nosso problema, é que retorna um tipo de função, de para , mas queremos apenas um comprimento, então gostaríamos de “mapear” o para um . Se usarmos um Reader, podemos fazer exatamente isso:User.getFriends(_)(conn).length
User.getFriends
SocialNetworkConnection
Seq[User]
Seq[User]
Int
object User {
def findAll: Reader[UserConnection,Seq[User]] = conn => conn.findAll
def getFriends(u: User): Reader[SocialNetworkConnection,Seq[User]] = conn => conn.getFriends(u)
}
def numberOfFriends(u: User): Reader[SocialNetworkConnection,Int] = User.getFriends(u).map(_.length)
Usamos map
, aplicando uma função no valor de retorno da função empacotada para transformar a em a .Reader[SocialNetworkConnection, Seq[User]]
Reader[SocialNetworkConnection, Int]
flatMap
é semelhante a map
, mas é usado para “mesclar” resultados que são Reader
s. Novamente, é um operador comum em scala e programação funcional e as classes de coleção fornecem um bom exemplo: imagine que você tem uma sequência String
que deseja dividir em torno do espaço e deseja retornar uma lista de todos os tokens extraídos desta forma, se você usa map
para aplicar split
em cada elemento desta sequência, você terá uma sequência de sequências. O que você quer é uma sequência “achatada” de String
:
val strSeq = Seq("string 1", "another string", "yet another string")
strSeq.flatMap(_.split('_'))
flatMap
mescla as coleções que recebe de cada chamada split
para retornar apenas uma sequência. Da mesma forma, flatMap
em Reader
permite alterar a função agrupada mesclando um Leitor com o Leitor retornado por outra função.
O objeto ReaderMonad
fornece um pure
método que permite criar um leitor quando você tem um resultado que não depende de uma conexão, mas você precisa retornar um leitor de qualquer maneira.
Observe também que, por Reader
ter as funções map
e flatMap
, o scala é capaz de entender seu uso em para compreensões .
Configure a conexão
Então, todo esse bootstrapping de mônadas e tipos de retorno engraçados é interessante, mas como isso o ajuda a configurar uma conexão concreta no Play? Em algum ponto, você terá que substituir a classe Reader por um resultado real (provavelmente no topo de sua cadeia de chamadas, quando estiver retornando um resultado para uma solicitação http).
Então, normalmente, com o Play 2 , você terá criado uma implementação SQL / Anorm de sua conexão, praticamente o que declaramos anteriormente:
class MySQLConnection(dbName: String) extends UserConnection with SocialNetworkConnection {
def findAllUsers: Seq[User] = DB.withConnection(dbName) { … }
def createUser(u: User): Either[Throwable, User] = DB.withConnection(dbName) { … }
def getFriends(u: User): Seq[User] = DB.withConnection(dbName) { … }
}
Agora você pode declarar em seu controlador que está usando esta implementação:
object Application extends Controller {
val sqlConnection = new MySQLConnection("default");
def allEmails = Action {
// get a reader with the result we want
val resultReader = User.findAll.map{ allUsers =>
Ok(Json.toJson(allUsers.map(_.email)))
}
//apply the function wrapped in the reader to get the final result
resultReader(sqlConnection)
}
}
A configuração é basicamente a mesma que para os implícitos, exceto que você mantém tudo explícito. Acho isso muito melhor, pois está claro, pelas assinaturas de função e os tipos de retorno, o que está acontecendo em sua cadeia de chamadas. Qualquer pessoa (para a qual você explica o que o tipo de leitor representa) pode ver quais funções em seu código dependem de qual tipo de conexão, e não precisa adivinhar onde o implícito é introduzido no contexto, você só precisa olhar no topo da cadeia de chamadas para ver onde o Reader é finalmente aplicado.
Um ultimo truque
Você pode dizer que o último não é totalmente transparente para quem não está acostumado com Leitores. Em sua palestra, Runar Oli fornece uma boa solução para isso, para tornar o que está acontecendo ainda mais explícito e ter uma forma semelhante à construção da API Play, é apenas mais um truque de embrulho:resultReader(sqlConnection)
DB.withConnection
object MySQLConnection {
def withUserConnection[To](dbName: String)(f: Reader[UserConnection, To]) {
val sqlConnection = new MySQLConnection(dbName);
f(sqlConnection)
}
}
Você então faz:
def allEmails = Action {
MySQLConnection.withUserConnection("default") {
User.findAll.map{ allUsers =>
Ok(Json.toJson(allUsers.map(_.email)))
}
}
}
O código agora está muito mais limpo e, para qualquer revisor de código desavisado, não é muito importante entender o que está acontecendo com o Reader, é claro pelo que está escrito que um UserConnection
é usado dentro desse bloco e que sua implementação vem de MySQLConnection.
No Play, cuida de toda a configuração da conexão e de fechá-la quando terminar. Mas, no caso em que você está usando outro tipo de provedor e precisa fazer tudo isso sozinho, pode mover esse processo para a função, certificando-se de que todo o bootstrap e a limpeza sejam cuidados ao usar uma conexão.DB.withConnection
withUserConnection
Obrigado Runar Oli e scala
Como mencionado anteriormente, a maior parte do que é explicado aqui é fortemente inspirado pela apresentação Dead-Simple Dependency Injection de Runar Oli no 2012 neScala. Você pode encontrar a maioria dessas ferramentas na biblioteca scalaz e tudo isso é possível graças ao bom poder do scala.
Espero que esses dois posts tenham sido úteis para você e que tenham ajudado a levá-lo a um “Doh!” momento semelhante ao que tive quando estava assistindo a palestra de Runar Oli. Se você tiver alguma dúvida, não hesite.