文件

§相依性注入

相依性注入是一種廣泛使用的設計模式,有助於將元件的行為與相依性解析分開。Play 支援基於 JSR 330 的執行時期相依性注入(在此頁面中說明)以及 Scala 中的 編譯時期相依性注入

執行時期相依性注入之所以如此稱呼,是因為相依性圖表會在執行時期建立、連接和驗證。如果找不到特定元件的相依性,您在執行應用程式之前不會收到錯誤訊息。

Play 支援 Guice,但也可以插入其他 JSR 330 實作。一般來說,Guice wiki 是進一步了解 Guice 功能和 DI 設計模式的絕佳資源。

預設情況下,Play 的 sbt 外掛程式不會提供任何特定的相依性注入架構。如果您想使用 Play 的 Guice 模組,請將其明確新增到您的函式庫相依性中,如下所示

libraryDependencies += guice	

注意:Guice 是 Java 函式庫,本文件中的範例使用 Guice 內建的 Java API。如果您偏好 Scala DSL,您可能想使用 scala-guicesse-guice 函式庫。

§動機

依賴注入實現了幾個目標
1. 它允許您輕鬆地為相同組件繫結不同的實作。這對於測試特別有用,在測試中,您可以使用模擬依賴手動實例化組件,或注入替代實作。
2. 它允許您避免全域靜態狀態。雖然靜態工廠可以達成第一個目標,但您必須小心確保您的狀態正確設定。特別是 Play 的(現已棄用)靜態 API 需要執行中的應用程式,這使得測試較不靈活。而且,一次有多個可用實例,使得可以並行執行測試。

Guice wiki 有些範例詳細說明這一點。

§它的運作方式

Play 提供許多內建組件,並在模組中宣告它們,例如其 BuiltinModule。這些繫結描述建立 Application 實例所需的一切,包括預設值,由路由編譯器產生的路由器,其中將控制器注入建構函式。然後,這些繫結可以轉換為在 Guice 和其他執行時期 DI 架構中運作。

Play 團隊維護 Guice 模組,它提供 GuiceApplicationLoader。這會對 Guice 執行繫結轉換,使用這些繫結建立 Guice 注入器,並從注入器要求 Application 實例。

也有第三方載入器為其他架構執行此操作,包括 ScaldiSpring

或者,Play 提供 BuiltInComponents 特質,允許您建立純粹的 Scala 實作,將您的應用程式 在編譯時期 串接在一起。

我們在下方更詳細地說明如何自訂預設繫結和應用程式載入器。

§宣告執行時期 DI 依賴

如果您有一個元件,例如控制器,而它需要其他元件作為相依性,那麼可以使用 @Inject 注解來宣告。@Inject 注解可以用在欄位或建構函式上。我們建議您將它用在建構函式上,例如

import javax.inject._

import play.api.libs.ws._

class MyComponent @Inject() (ws: WSClient) {
  // ...
}

請注意,@Inject 注解必須在類別名稱之後,但建構函式參數之前,且必須加上括號。

此外,Guice 確實附帶了其他幾種 注入類型,但建構函式注入通常是 Scala 中最清楚、最簡潔、最可測試的,因此我們建議使用它。

Guice 能夠自動實例化建構函式上帶有 @Inject 的任何類別,而無需明確繫結它。此功能稱為 即時繫結,在 Guice 文件中更詳細地描述。如果您需要執行更複雜的操作,您可以宣告自訂繫結,如下所述。

§相依性注入控制器

Play 的路由編譯器會產生一個路由器類別,將您的控制器宣告為建構函式中的相依性。這讓您的控制器可以注入到路由器中。

在控制器名稱之前加上 @ 符號具有特殊意義:控制器並非直接注入,而是會注入控制器的 Provider。例如,這允許原型控制器,以及中斷循環相依性的選項。

§元件生命週期

依賴注入系統管理已注入元件的生命週期,依需要建立元件並將它們注入其他元件。以下是元件生命週期運作方式

§單例

有時您可能會有一個包含某些狀態的元件,例如快取、與外部資源的連線,或一個建立成本很高的元件。在這些情況下,重要的是只有一個該元件的執行個體。這可以使用 @Singleton 註解來達成

import javax.inject._

@Singleton
class CurrentSharePrice {
  @volatile private var price = 0

  def set(p: Int) = price = p
  def get         = price
}

§停止/清除

當 Play 關閉時,可能需要清除某些元件,例如,停止執行緒池。Play 提供一個 ApplicationLifecycle 元件,可以使用它來註冊掛勾,以便在 Play 關閉時停止您的元件

import javax.inject._

import scala.concurrent.Future

import play.api.inject.ApplicationLifecycle

@Singleton
class MessageQueueConnection @Inject() (lifecycle: ApplicationLifecycle) {
  val connection = connectToMessageQueue()
  lifecycle.addStopHook { () => Future.successful(connection.stop()) }

  // ...
}

ApplicationLifecycle 會以與建立時相反的順序停止所有元件。這表示您依賴的任何元件仍可以在元件的停止掛勾中安全地使用。因為您依賴它們,所以它們必須在您的元件之前建立,因此它們不會在您的元件停止之前停止。

注意:確保所有註冊停止掛勾的元件都是單例非常重要。任何註冊停止掛勾的非單例元件都可能成為記憶體外洩的來源,因為每次建立元件時都會註冊新的停止掛勾。

您也可以使用 協調式關閉 來實作清理邏輯。Play 內部使用 Pekko 的協調式關閉,但使用者程式碼也可以使用。ApplicationLifecycle#stop 實作為協調式關閉任務。主要的差異是 ApplicationLifecycle#stop 會依序以可預測的順序執行所有停止掛勾,而協調式關閉會並行執行同一個階段中的所有任務,這可能會比較快,但不可預測。

§提供自訂繫結

為元件定義特質並讓其他類別依賴於該特質,而非元件的實作,這被視為良好的做法。這樣一來,您可以注入不同的實作,例如在測試應用程式時注入模擬實作。

在這種情況下,DI 系統需要知道哪個實作應該繫結到該特質。我們建議您宣告此內容的方式取決於您是作為 Play 的最終使用者撰寫 Play 應用程式,還是撰寫其他 Play 應用程式會使用的函式庫。

§Play 應用程式

我們建議 Play 應用程式使用應用程式所使用的 DI 架構所提供的任何機制。儘管 Play 提供繫結 API,但此 API 有點受限,而且無法讓您充分利用所使用架構的效能。

由於 Play 提供開箱即用的 Guice 支援,因此以下範例顯示如何提供 Guice 的繫結。

§繫結註解

將實作繫結到介面的最簡單方法是使用 Guice @ImplementedBy 註解。例如

import com.google.inject.ImplementedBy

@ImplementedBy(classOf[EnglishHello])
trait Hello {
  def sayHello(name: String): String
}

class EnglishHello extends Hello {
  def sayHello(name: String) = "Hello " + name
}

§程式化繫結

在一些更複雜的情況下,您可能想要提供更複雜的繫結,例如當您有多個特質實作時,這些實作是由 @Named 註解限定的。在這些情況下,您可以實作自訂 Guice 模組

import com.google.inject.name.Names
import com.google.inject.AbstractModule

class Module extends AbstractModule {
  override def configure() = {
    bind(classOf[Hello])
      .annotatedWith(Names.named("en"))
      .to(classOf[EnglishHello])

    bind(classOf[Hello])
      .annotatedWith(Names.named("de"))
      .to(classOf[GermanHello])
  }
}

如果您呼叫此模組為 Module 並將其放在根目錄中,它將自動向 Play 註冊。或者,如果您想要給它不同的名稱或將它放在不同的套件中,您可以透過將其完全限定類別名稱附加到 application.conf 中的 play.modules.enabled 清單來向 Play 註冊它

play.modules.enabled += "modules.HelloModule"

您也可以透過將其新增至已停用的模組來停用根目錄中名為 Module 的模組的自動註冊

play.modules.disabled += "Module"

§可設定繫結

有時您可能想要在設定 Guice 繫結時讀取 Play Configuration 或使用 ClassLoader。您可以透過將它們新增至模組的建構函式來存取這些物件。

在以下範例中,每個語言的 Hello 繫結會從設定檔讀取。這允許透過在 application.conf 檔中新增新的設定來新增新的 Hello 繫結。

import com.google.inject.name.Names
import com.google.inject.AbstractModule
import play.api.Configuration
import play.api.Environment

class Module(environment: Environment, configuration: Configuration) extends AbstractModule {
  override def configure() = {
    // Expect configuration like:
    // hello.en = "myapp.EnglishHello"
    // hello.de = "myapp.GermanHello"
    val helloConfiguration: Configuration =
      configuration.getOptional[Configuration]("hello").getOrElse(Configuration.empty)
    val languages: Set[String] = helloConfiguration.subKeys
    // Iterate through all the languages and bind the
    // class associated with that language. Use Play's
    // ClassLoader to load the classes.
    for (l <- languages) {
      val bindingClassName: String = helloConfiguration.get[String](l)
      val bindingClass: Class[_ <: Hello] =
        environment.classLoader
          .loadClass(bindingClassName)
          .asSubclass(classOf[Hello])
      bind(classOf[Hello])
        .annotatedWith(Names.named(l))
        .to(bindingClass)
    }
  }
}

注意:在多數情況下,如果您需要在建立元件時存取 Configuration,您應該將 Configuration 物件注入元件本身或元件的 Provider。然後您可以在建立元件時讀取 Configuration。您通常不需要在建立元件的繫結時讀取 Configuration

§急切繫結

在以上的程式碼中,每次使用 EnglishHelloGermanHello 物件時,都會建立新的物件。如果您只想要建立這些物件一次,可能是因為建立它們的成本很高,那麼您應該使用 @Singleton 註解。如果您想要建立它們一次,而且在應用程式啟動時也急切地建立它們,而不是在需要時才建立,那麼您可以使用 Guice 的急切單例繫結

  import com.google.inject.name.Names
  import com.google.inject.AbstractModule

// A Module is needed to register bindings
  class Module extends AbstractModule {
    override def configure() = {
      // Bind the `Hello` interface to the `EnglishHello` implementation as eager singleton.
      bind(classOf[Hello])
        .annotatedWith(Names.named("en"))
        .to(classOf[EnglishHello])
        .asEagerSingleton()

      bind(classOf[Hello])
        .annotatedWith(Names.named("de"))
        .to(classOf[GermanHello])
        .asEagerSingleton()
    }
  }

當應用程式啟動時,可使用熱切單例來啟動服務。它們通常與關閉掛鉤結合使用,以便在應用程式停止時,服務可以清除其資源。

  import javax.inject._

  import scala.concurrent.Future

  import play.api.inject.ApplicationLifecycle

// This creates an `ApplicationStart` object once at start-up and registers hook for shut-down.
  @Singleton
  class ApplicationStart @Inject() (lifecycle: ApplicationLifecycle) {
    // Shut-down hook
    lifecycle.addStopHook { () => Future.successful(()) }
    // ...
  }
import com.google.inject.AbstractModule

class StartModule extends AbstractModule {
  override def configure() = {
    bind(classOf[ApplicationStart]).asEagerSingleton()
  }
}

§Play 函式庫

如果您正在為 Play 實作函式庫,則您可能希望它與 DI 架構無關,以便您的函式庫可以在應用程式中使用的任何 DI 架構中開箱即用。因此,Play 提供一個輕量級繫結 API,以 DI 架構無關的方式提供繫結。

若要提供繫結,請實作Module以傳回您要提供的繫結順序。Module特質也提供了一個 DSL,用於建立繫結

import play.api.inject._
import play.api.Configuration
import play.api.Environment

class HelloModule extends Module {
  def bindings(environment: Environment, configuration: Configuration): Seq[play.api.inject.Binding[_]] = Seq(
    bind[Hello].qualifiedWith("en").to[EnglishHello],
    bind[Hello].qualifiedWith("de").to[GermanHello]
  )
}

此模組可以透過將其附加到reference.conf中的play.modules.enabled清單中,自動向 Play 註冊

play.modules.enabled += "com.example.HelloModule"

為了最大化跨架構相容性,請記住下列事項

§排除模組

如果您不希望載入某個模組,則可以透過將其附加到application.conf中的play.modules.disabled屬性來排除它

play.modules.disabled += "play.api.db.evolutions.EvolutionsModule"

§管理循環相依性

循環依賴會在其中一個元件依賴另一個依賴原始元件(直接或間接)的元件時發生。例如

import javax.inject.Inject

class Foo @Inject() (bar: Bar)
class Bar @Inject() (baz: Baz)
class Baz @Inject() (foo: Foo)

在此情況下,Foo 依賴 Bar,而 Bar 依賴 Baz,而 Baz 依賴 Foo。因此,您將無法實體化任何這些類別。您可以使用 Provider 解決這個問題

import javax.inject.Inject
import javax.inject.Provider

class Foo @Inject() (bar: Bar)
class Bar @Inject() (baz: Baz)
class Baz @Inject() (foo: Provider[Foo])

一般來說,可以透過以更原子的方式分解元件,或尋找更具體的元件來依賴,來解決循環依賴。常見的問題是依賴 Application。當您的元件依賴 Application 時,表示它需要一個完整的應用程式才能執行其工作;通常情況並非如此。您的依賴關係應建立在具有您所需特定功能的更具體元件(例如 Environment)上。最後,您可以透過注入 Provider[Application] 來解決問題。

§進階:擴充 GuiceApplicationLoader

Play 的執行時期依賴注入是由 GuiceApplicationLoader 類別引導的。此類別載入所有模組,將模組傳送至 Guice,然後使用 Guice 建立應用程式。如果您想要控制 Guice 如何初始化應用程式,則可以擴充 GuiceApplicationLoader 類別。

您可以覆寫多種方法,但通常會想要覆寫 builder 方法。此方法會讀取 ApplicationLoader.Context 並建立 GuiceApplicationBuilder。您可以在下方看到 builder 的標準實作,您可以隨意變更。您可以在關於 使用 Guice 進行測試 的區段中找出如何使用 GuiceApplicationBuilder

import play.api.inject._
import play.api.inject.guice._
import play.api.ApplicationLoader
import play.api.Configuration

class CustomApplicationLoader extends GuiceApplicationLoader() {
  override def builder(context: ApplicationLoader.Context): GuiceApplicationBuilder = {
    val extra = Configuration("a" -> 1)
    initialBuilder
      .in(context.environment)
      .loadConfig(context.initialConfiguration.withFallback(extra))
      .overrides(overrides(context): _*)
  }
}

當您覆寫 ApplicationLoader 時,您需要告知 Play。將下列設定新增至您的 application.conf

play.application.loader = "modules.CustomApplicationLoader"

您不限於使用 Guice 進行依賴注入。透過覆寫 ApplicationLoader,您可以控制應用程式的初始化方式。在 下一節 中深入了解。

§新增依賴項至類別,而不觸及子類別

有時您可能想要新增新的依賴項至某些基礎類別,而該類別可能有很多子類別。
為避免直接提供依賴項給每個子類別,您可以將其新增為可注入欄位。
此方法可能會降低類別的可測試性,因此請小心使用。

import com.google.inject.ImplementedBy
import com.google.inject.Inject
import com.google.inject.Singleton
import play.api.mvc._

@ImplementedBy(classOf[LiveCounter])
trait Counter {
  def inc(label: String): Unit
}

object NoopCounter extends Counter {
  override def inc(label: String): Unit = ()
}

@Singleton
class LiveCounter extends Counter {
  override def inc(label: String): Unit = println(s"inc $label")
}

class BaseController extends ControllerHelpers {
  // LiveCounter will be injected
  @Inject
  @volatile protected var counter: Counter = NoopCounter

  def someBaseAction(source: String): Result = {
    counter.inc(source)
    Ok(source)
  }
}

@Singleton
class SubclassController @Inject() (action: DefaultActionBuilder) extends BaseController {
  def index = action {
    someBaseAction("index")
  }
}

下一頁:編譯時間依賴注入


在此文件檔中發現錯誤?此頁面的原始程式碼可以在 這裡 找到。在閱讀 文件檔指南 後,請隨時提交拉取請求。有問題或建議要分享嗎?請前往 我們的社群論壇,與社群展開對話。