为了更好的理解编译时注入(compile time dependency injection), 我们从零开始创建一个 Play 网站 play-macwire-di。网站是基于 Play 官方的编译注入例子play-scala-macwire-di-example,本文做了一些原理探索并给出了必要的解释。

1. 初始化 sbt 配置

Play Framework 采用了 MVC 结构,其项目结构不同于通常的 Scala 项目结构。sbt 遵循了 convention over configuration 原则,所以首先需要引入 Play 的 sbt plugin。

在项目根目录创建 project\ 子目录,在此子目录下创建下面二个文件:

  • build.properties: 设置 sbt 版本: sbt.version=1.3.3
  • plugins.sbt: 引入 Play 的 sbt plguin: addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.7.3")。最后的版本号是当前的 Play Framework 版本号。

在根目录创建 build.sbt,加入下面内容:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
name := """play-macwire-di"""

version := "2.7.x"

lazy val root = (project in file(".")).enablePlugins(PlayScala)

scalaVersion := "2.13.1"

libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "4.0.3" % Test
libraryDependencies += "com.softwaremill.macwire" %% "macros" % "2.3.3" % "provided"

scalacOptions ++= Seq(
    "-feature",
    "-deprecation",
    "-Xfatal-warnings"
)

2. 建立 Controller 方法和路由

一个简单的 Web 应用最少包括一个生产 Action 的 controller 方法和一个相应的 router 定义。

创建 app/controllers/GreeterController.scala 并加入下面内容:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
package controllers

import play.api.mvc._
import play.twirl.api.Html

class GreeterController(cc: ControllerComponents) extends AbstractController(cc) {
  def index = Action {
    Ok(Html("<h1>Welcome</h1><p>Your new application is ready.</p>"))
  }
}

创建 conf/routes 加入如下内容:

1
GET / controllers.GreeterController.index

3. 设置程序加载

Play 的文档 Application entry point 解释了使用编译注入需要了解的加载过程。Play 用 ApplicationLoader trait 定义应用的加载。其 load 方法的类型为 Context => ApplicationContext 独立于具体应用,包含加载应用所需要的各种 Component。 这里,Component 是采用 Think Cake Pattern 创建的包含所需依赖的 trait。 这些 trait 的名字通常用 ComponentsModule 作为结尾。

Play 提供了 BuiltInComponentsFromContext 作为父类帮助实现 ApplicationLoader。具体实现的子类需要提供最少二个所需要的模块:处理 Http 请求的处理链 HttpFiltersComponents 和定义的所有路由。 创建包含下面内容的 app/GrettingApplicationLoader.scala 文件, 其中包含了配置 Logger 的内容:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import com.softwaremill.macwire._
import play.api.ApplicationLoader.Context
import play.api._
import play.api.routing.Router
import play.filters.HttpFiltersComponents
import router.Routes

class GreetingApplicationLoader extends ApplicationLoader {

  def load(context: Context): Application = new GreetingComponents(context).application
}

class GreetingComponents(context: Context) extend BuiltInComponentsFromContext(context)
  with GreetingModule
  with HttpFiltersComponents
{

  // set up logger
  LoggerConfigurator(context.environment.classLoader).foreach {
    _.configure(context.environment, context.initialConfiguration, Map.empty)
  }

  lazy val router: Router = {
    // add the prefix string in local scope for the Routes constructor
    val prefix: String = "/"
    wire[Routes]
  }
}

上面的 GreetingModule 包含了创建路由需要的所有 Controller instances。 一个 Play Web 应用需要创建所有的路由和所有的 Controller。创建包含下面内容的 app/GreetingModule.scala 文件生成所有 Controller:

1
2
3
4
5
6
7
8
import controllers.GreeterController
import play.api.mvc.ControllerComponents
import com.softwaremill.macwire._

trait GreetingModule {
  def controllerComponents: ControllerComponents
  lazy val greeterController = wire[GreeterController]
}

因为 GreeterController 的 Constructor 需要一个 ControllerComponents 类型的参数,这里需要给出抽象方法定义,否则会编译错误。具体的值,则在 mixin 这个 Component 的时候生成。

刚创立上面二个文件时,import router.Routeswire[Routes] 在 IDE 里面会报告错误。原因是 Play 在编译初期需要从路由的定义文件 conf/routes 产生相关 Scala 代码。运行 sbt compile 产生所需的路由代码。

还需要指定加载程序,在 conf/application.conf 加入下面内容:play.application.loader = GreetingApplicationLoader

现在可以运行 sbt run 检查生成的网站。用 sbt dist 可以生成可以部署的二进制代码(需要在命令行给出 Application secret 或 事先配置)。

4. 增加服务模块

app/services 里面创建服务以及服务模块文件。

app/services/GreetingService.scala 内容如下:

1
2
3
4
5
6
7
8
package services

class GreetingService {
  def greetingMessage(language: String) = language match {
    case "it" => "Messi"
    case _ => "Hello"
  }
}

app/services/ServiceModule.scala 内容如下:

1
2
3
4
5
6
7
package services

import com.softwaremill.macwire._

trait ServicesModule {
  lazy val greetingService = wire[GreetingService]
}

GreetingModule 需要 mixin ServiceModule 并定义一个 Langs 类型的方法,其具体实现由 Play 在运行时提供。app/GreetingModule.scala 的内容如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import controllers.GreeterController
import play.api.i18n.Langs
import play.api.mvc.ControllerComponents
import services.ServicesModule

trait GreetingModule extends ServicesModule {
  import com.softwaremill.macwire._

  lazy val greeterController = wire[GreeterController]

  def langs: Langs

  def controllerComponents: ControllerComponents
}

修改 app/controllers/GreeterController.scala,增加一个新方法。新方法需要一个新服务和语言设置。其内容如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
package controllers

import play.api.i18n.Langs
import play.api.mvc._
import play.twirl.api.Html
import services.GreetingService

class GreeterController(greetingService: GreetingService,
                        langs: Langs,
                        cc: ControllerComponents) extends AbstractController(cc) {
  def index = Action {
    Ok(Html("<h1>Welcome</h1><p>Your new application is ready.</p>"))
  }

  def greetInMyLanguage = Action {
    Ok(greetingService.greetingMessage(langs.preferred(langs.availables).language))
  }
}

最后,需要在 conf/routes 文件配置新增加的路由。其内容如下:

1
2
GET / controllers.GreeterController.index
GET /greet controllers.GreeterController.greetInMyLanguage

此时可以编译运行程序,测试新加的功能。Langs 的值可以在 conf/application.conf 里面配置,如 play.i18n.langs = ["it", "en"]

5. 增加 Model

创建 models/Greeting.scala, 内容如下:

1
2
3
4
5
6
7
8
9
package models

import play.api.libs.json.Json

case class Greeting(id: Int = -1, message: String, name: String)

object Greeting {
  implicit val GreetingFormat = Json.format[Greeting]
}

在 companion object 里定义了 implcit 的 Json 转换类型。使用 Greeting 数据的地方会自动使用这个隐含的 Json 转换功能。

最终的 app/controllers/GreeterController.scala 如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package controllers

import models.Greeting
import play.api.i18n.Langs
import play.api.libs.json.Json
import play.api.mvc.{AbstractController, ControllerComponents}
import play.twirl.api.Html
import services.GreetingService

class GreeterController(greetingService: GreetingService,
                        langs: Langs,
                        cc: ControllerComponents) extends AbstractController(cc) {

  val greetingsList = Seq(
    Greeting(1, greetingService.greetingMessage("en"), "sameer"),
    Greeting(2, greetingService.greetingMessage("it"), "sam")
  )

  def greetings = Action {
    Ok(Json.toJson(greetingsList))
  }

  def greetInMyLanguage = Action {
    Ok(greetingService.greetingMessage(langs.preferred(langs.availables).language))
  }

  def index = Action {
    Ok(Html("<h1>Welcome</h1><p>Your new application is ready.</p>"))
  }

}

最终路由配置 conf/routes 文件内容如下:

1
2
3
GET / controllers.GreeterController.index
GET /greet controllers.GreeterController.greetInMyLanguage
GET /greetings controllers.GreeterController.greetings

由于 Controller 里面的 Json.toJson(greetingsList) 使用 implicit 的 Json 转换,无需再修改 GeertingModule

6. 总结

编译时注入(compile time DI) 的最大好处是让人放心的类型检查。同时所有的依赖都明确写出,虽然多写一些代码,但是长期看可维护性比较好。测试的时候,所有的依赖还是需要明确给出。

从上面也可以看出用 Macwire 的好处。基本上不需要手工调用 Constructor 了,节省了很多代码,尤其是不用理会具体参数的顺序和个数。

使用编译时注入的步骤概括如下:

  • 通过继承 BuiltInComponentsFromContext 来实现 ApplicationLoader trait 的加载。在这里 mix in 应用的模块以及 HttpFiltersComponents。也可以添加其它用到的 Play 模块比如 AssetsComponents。 路由的加载代码基本固定。
  • Controller 需要的服务需要定义服务本身及提供服务 instance 的模块。
  • 在 Application 的模块汇总创建所有 Controller 的 instances,用于创建路由。
  • 在数据模型的 companion object 定义 implicit 参数也是比较好的注入方式。