トレイトの応用編:依存性の注入によるリファクタリング

ここではトレイトの応用編として、大きなクラスをリファクタリングする過程を通してトレイトを実際どのように使っていくかを学んでいきましょう。さらに大規模システム開発で使われる依存性の注入という技法についても紹介します。

サンプルプログラム

今回使われるサンプルプログラムは以下の場所にあります。実際に動かしたい場合は個々のディレクトリに入り、sbtを起動してください。

リファクタリング前のプログラム

リファクタリング後のプログラム

リファクタリング前のプログラムの紹介

今回は今までより実践的なプログラムを考えてみましょう。ユーザーの登録と認証を管理するUserServiceというクラスです。

scalaVersion := "2.13.15"

crossScalaVersions += "3.6.2"

libraryDependencies ++= Seq(
  "org.scalikejdbc" %% "scalikejdbc" % "4.3.2",
  "org.mindrot"     %  "jbcrypt"     % "0.4"
)
package domain

case class User(id: Long, name: String, hashedPassword: String)

object User {
  def apply(name: String, hashedPassword: String): User =
    User(0L, name, hashedPassword)
}
package domain

import org.mindrot.jbcrypt.BCrypt
import scalikejdbc._

class UserService {
  val maxNameLength = 32

  // ストレージ機能
  def insert(user: User): User = DB localTx { implicit s =>
    val id = sql"""insert into users (name, password) values (${user.name}, ${user.hashedPassword})"""
      .updateAndReturnGeneratedKey.apply()
    user.copy(id = id)
  }

  def createUser(rs: WrappedResultSet): User =
    User(rs.long("id"), rs.string("name"), rs.string("password"))

  def find(name: String): Option[User] = DB readOnly { implicit s =>
    sql"""select * from users where name = $name """
      .map(createUser).single.apply()
  }

  def find(id: Long): Option[User] = DB readOnly { implicit s =>
    sql"""select * from users where id = $id """
      .map(createUser).single.apply()
  }

  // パスワード機能
  def hashPassword(rawPassword: String): String =
    BCrypt.hashpw(rawPassword, BCrypt.gensalt())

  def checkPassword(rawPassword: String, hashedPassword: String): Boolean =
    BCrypt.checkpw(rawPassword, hashedPassword)

  // ユーザー登録
  def register(name: String, rawPassword: String): User = {
    if (name.length > maxNameLength) {
      throw new Exception("Too long name!")
    }
    if (find(name).isDefined) {
      throw new Exception("Already registered!")
    }
    insert(User(name, hashPassword(rawPassword)))
  }

  // ユーザー認証
  def login(name: String, rawPassword: String): User = {
    find(name) match {
      case None       => throw new Exception("User not found!")
      case Some(user) =>
        if (!checkPassword(rawPassword, user.hashedPassword)) {
          throw new Exception("Invalid password!")
        }
        user
    }
  }
}

UserServiceは以下のような機能があります。

  • registerメソッドはユーザーの登録をおこなうメソッドで、ユーザーの名前とパスワードを引数として受け取り、名前の最大長のチェックと既に名前が登録されているかどうかを調べて、ストレージに保存する
  • loginメソッドはユーザーの認証をおこなうメソッドで、ユーザーの名前とパスワードを受け取り、ストレージに保存されているユーザーの中から同名のユーザーを見つけ出し、パスワードをチェックする
  • この他のストレージ機能とパスワード機能のメソッドは内部的に使われるのみである

上記のプログラムでは実際に動かすことができるようにScalikeJDBCというデータベース用のライブラリとjBCryptというパスワースのハッシュ値を計算するライブラリが使われていますが、実装の詳細を理解する必要はありません。既にメソッドの実装を説明したことがある場合は実装を省略し???で書くことがあります。

リファクタリング:公開する機能を制限する

さて、モジュール化という観点でUserServiceを見ると、どういった問題が考えられるでしょうか?

1つはUserServiceが必要以上に情報を公開しているということです。 UserServiceの役割はregisterメソッドを使ったユーザー登録とloginメソッドを使ったユーザーの認証です。しかしストレージ機能であるinsertメソッドやfindメソッドも公開してしまっています。

registerメソッドはユーザーの名前の最大長や既にユーザーが登録されているかどうかチェックしていますが、insertメソッドはそういったチェックをせずにデータベースに保存しています。もしinsertメソッドを直接使われた場合、想定外に名前が長いユーザーや、名前が重複したユーザーが保存されてしまいます。

findメソッドも同様の問題があります。 loginメソッドではなく直接findメソッドが使われた場合、パスワードのチェックなしにユーザーの情報を取得することができます。

では、どのような修正が考えられるでしょうか。まず考えられるのは外部から使ってほしくないメソッドをprivateにすることです。

class UserService {
  // メソッドの実装は同じなので???で代用しています
  val maxNameLength = 32

  // ストレージ機能
  private def insert(user: User): User = ???

  private def createUser(rs: WrappedResultSet): User = ???

  private def find(name: String): Option[User] = ???

  private def find(id: Long): Option[User] = ???

  // パスワード機能
  private def hashPassword(rawPassword: String): String = ???

  private def checkPassword(rawPassword: String, hashedPassword: String): Boolean = ???

  // ユーザー登録
  def register(name: String, rawPassword: String): User = ???

  // ユーザー認証
  def login(name: String, rawPassword: String): User = ???
}

これでinsertメソッドやfindメソッドは外部から呼びだすことができなくなったので、先ほどの問題は起きなくなりました。

しかしトレイトを使って同じように公開したい機能を制限することもできます。まず、公開するメソッドだけを集めた新しいトレイトUserServiceを作ります。

trait UserService {
  val maxNameLength = 32

  def register(name: String, rawPassword: String): User

  def login(name: String, rawPassword: String): User
}

そして、このトレイトの実装クラスUserServiceImplを作ります。

class UserServiceImpl extends UserService {
  // メソッドの実装は同じなので???で代用しています

  // ストレージ機能
  def insert(user: User): User = ???

  def createUser(rs: WrappedResultSet): User = ???

  def find(name: String): Option[User] = ???

  def find(id: Long): Option[User] = ???

  // パスワード機能
  def hashPassword(rawPassword: String): String = ???

  def checkPassword(rawPassword: String, hashedPassword: String): Boolean = ???

  // ユーザー登録
  def register(name: String, rawPassword: String): User = ???

  // ユーザー認証
  def login(name: String, rawPassword: String): User = ???
}

UserServiceの利用者は実装クラスではなく公開トレイトのほうだけを参照するようにすれば、チェックされていないメソッドを使って不整合データができてしまう問題も起きません。

このようにモジュールのインタフェースを定義し、公開する機能を制限するのもトレイトの使われ方の1つです。 Javaのインタフェースと同じような使い方ですね。

リファクタリング:大きなモジュールを分割する

次にこのモジュールの問題点として挙げられるのは、モジュールが多くの機能を持ちすぎているということです。 UserServiceはユーザー登録とユーザー認証をおこなうサービスですが、付随してパスワードをハッシュ化したり、パスワードをチェックする機能も持っています。

このパスワード機能はUserService以外でも使いたいケースが出てくるかもしれません。たとえばユーザーが重要な操作をした場合に再度パスワードを入力させ、チェックしたい場合などです。

このような場合、1つのモジュールを複数のモジュールに分割することが考えられます。あたらしくパスワード機能だけを持つPasswordServicePasswordServiceImplを作ってみます。

package domain

import org.mindrot.jbcrypt.BCrypt

trait PasswordService {
  def hashPassword(rawPassword: String): String

  def checkPassword(rawPassword: String, hashedPassword: String): Boolean
}

trait PasswordServiceImpl extends PasswordService {
  def hashPassword(rawPassword: String): String =
    BCrypt.hashpw(rawPassword, BCrypt.gensalt())

  def checkPassword(rawPassword: String, hashedPassword: String): Boolean =
    BCrypt.checkpw(rawPassword, hashedPassword)
}

そして、先ほど作ったUserServiceImplPasswordServiceImplを継承して使うようにします。

class UserServiceImpl extends UserService with PasswordServiceImpl {
  // メソッドの実装は同じなので???で代用しています

  // ストレージ機能
  def insert(user: User): User = ???

  def createUser(rs: WrappedResultSet): User = ???

  def find(name: String): Option[User] = ???

  def find(id: Long): Option[User] = ???

  // ユーザー登録
  def register(name: String, rawPassword: String): User = ???

  // ユーザー認証
  def login(name: String, rawPassword: String): User = ???
}

これでパスワード機能を分離することができました。分離したパスワード機能は別の用途で使うこともできるでしょう。

同じようにストレージ機能もUserRepositoryとして分離してみます。

package domain

import scalikejdbc._

trait UserRepository {
  def insert(user: User): User

  def find(name: String): Option[User]

  def find(id: Long): Option[User]
}

trait UserRepositoryImpl extends UserRepository {
  def insert(user: User): User = DB localTx { implicit s =>
    val id = sql"""insert into users (name, password) values (${user.name}, ${user.hashedPassword})"""
      .updateAndReturnGeneratedKey.apply()
    user.copy(id = id)
  }

  def createUser(rs: WrappedResultSet): User =
    User(rs.long("id"), rs.string("name"), rs.string("password"))

  def find(name: String): Option[User] = DB readOnly { implicit s =>
    sql"""select * from users where name = $name """
      .map(createUser).single.apply()
  }

  def find(id: Long): Option[User] = DB readOnly { implicit s =>
    sql"""select * from users where id = $id """
      .map(createUser).single.apply()
  }
}

すると、UserServiceImplは以下のようになります。

class UserServiceImpl extends UserService with PasswordServiceImpl with UserRepositoryImpl {
  // メソッドの実装は同じなので???で代用しています

  // ユーザー登録
  def register(name: String, rawPassword: String): User = ???

  // ユーザー認証
  def login(name: String, rawPassword: String): User = ???
}

これで大きなUserServiceモジュールを複数の機能に分割することできました。

依存性の注入によるリファクタリング

さて、いよいよこの節の主題である依存性の注入によるリファクタリングの話に入りましょう。

ここまでトレイトを使って公開する機能を定義し、モジュールを分割し、リファクタリングを進めてきましたが、UserServiceにはもう1つ大きな問題があります。モジュール間の依存関係が分離できていないことです。

たとえばUserServiceImplのユニットテストをすることを考えてみましょう。ユニットテストは外部のシステムを使わないので、ローカル環境でテストしやすく、並行して複数のテストをすることもできます。また、単体の機能のみをテストするため失敗した場合、問題の箇所がわかりやすいという特徴もあります。

UserServiceImplUserRepositoryImplに依存しています。 UserRepositoryImplはScalikeJDBCを使った外部システムであるデータベースのアクセスコードがあります。このままのUserServiceImplでユニットテストを作成した場合、テストを実行するのにデータベースを用意しなければならず、データベースを共用する場合複数のテストを同時に実行するのが難しくなります。さらにテストに失敗した場合UserServiceImplに原因があるのか、もしくはデータベースの設定やテーブルに原因があるのか、調査しなければなりません。これではユニットテストとは言えません。

そこで具体的なUserRepositoryImplへの依存を分離することが考えられます。このために使われるのが依存性の注入と呼ばれる手法です。

依存性の注入とは?

まずは一般的な「依存性の注入(Dependency Injection、DI)」の定義を確認しましょう。

依存性の注入についてWikipediaのDependency injectionの項目を見てみますと、

  • Dependencyとは実際にサービスなどで使われるオブジェクトである
  • InjectionとはDependencyを使うオブジェクトに渡すことである

とあります。さらにDIには以下の4つの役割が登場するとあります。

  • 使われる対象の「サービス」
  • サービスを使う(依存する)「クライアント」
  • クライアントがどうサービスを使うかを定めた「インタフェース」
  • サービスを構築し、クライアントに渡す「インジェクタ」

これらの役割について、今回の例のUserRepositoryUserRepositoryImplUserServiceImplで考えてみます。 Wikipedia中の「サービス」という用語と、これまでの例の中で登場するサービスという言葉は別の意味なので注意してください。

まずはDIを使っていない状態のクラス図を見てみましょう。

DIを使っていないクラス図

このクラス図の役割を表にしてみます。

DIの役割 コード上の名前 説明
インタフェース UserRepository 抽象的なインタフェース
サービス UserRepositoryImpl 具体的な実装
クライアント UserServiceImpl UserRepositoryの利用者

DIを使わない状態ではUserRepositoryというインタフェースが定義されているのにもかかわらず、UserServiceImplUserRepositoryImplを継承することで実装も参照していました。これではせっかくインタフェースを分離した意味がありません。 UserServiceImplUserRepositoryインタフェースだけを参照(依存)するようにすれば、具体的な実装であるUserRepositoryImplの変更に影響されることはありません。この問題を解決するのがDIの目的です。

それではDIのインジェクタを加えて、上記のクラス図を修正しましょう。

DIを使うことにより修正されたクラス図

謎のインジェクタの登場によりUserServiceImplからUserRepositoryImplへの参照がなくなりました。おそらくインジェクタは何らかの手段でサービスであるUserRepositoryImpl(Dependency)をクライアントであるUserServiceImplに渡しています(Injection)。このインジェクタの動作を指して「Dependency Injection」と呼ぶわけです。そして、このインジェクタをどうやって実現するか、それがDI技術の核心に当たります。

依存性の注入の利点

では、この依存性の注入を使うとどのような利点があるのでしょうか。

1つはクライアントがインタフェースだけを参照することにより、具体的な実装への参照が少なくなり コンポーネント同士が疎結合になる という点が挙げられます。たとえばUserRepositoryImplのクラス名やパッケージ名が変更されてもUserServiceImplには何の影響もなくなります。

次に挙げられる点は具体的な実装を差し替えることにより クライアントの動作がカスタマイズ可能 になるという点です。たとえば今回の例ではUserRepositoryImplはScalikeJDBCの実装でしたが、MongoDBに保存するMongoUserRepositoryImplを新しく作ってUserServiceImplに渡せばクライアントをMongoDBに保存するように変更することができます。

またDIは設計レベルでも意味があります。 DIを使うと 依存関係逆転の原則を実現できます 。通常の手続き型プログラミングでは、上位のモジュールから下位の詳細な実装のモジュールを呼ぶということがしばしばあります。しかし、この場合、上位のモジュールが下位のモジュールに依存することになります。つまり、下位の実装の変更が上位のモジュールにまで影響することになってしまうわけです。

依存関係逆転の原則というのは、上位のモジュールの側に下位のモジュールが実装すべき抽象を定義し、下位のモジュールはその抽象に対して実装を提供すべきという考え方です。たとえばUserServiceが上位のモジュールだとすると、UserRepositoryImplは下位のモジュールになります。依存関係逆転をしない場合、UserServiceから直接UserRepositoryImplを呼ばざるをえません。このままだと先ほど述べたような問題が生じてしまうので、依存関係逆転の原則に従って、上位のモジュールに抽象的なUserRepositoryを用意し、UserServiceUserRepositoryを使うようにします。そして、依存性の注入によって、下位のモジュールのUserRepositoryImplUserServiceに渡すことにより、このような問題を解決できるわけです。

また見落されがちな点ですが、DIでは クライアントに特別な実装を要求しない という点も重要です。これはJava界隈でDIが登場した背景に関連するのですが、Spring FrameworkなどのDIを実現するDIコンテナは複雑なEnterprise JavaBeans(EJB)に対するアンチテーゼとして誕生しました。複雑なEJBに対し、何も特別でないただのJavaオブジェクトであるPlain Old Java Object(POJO)という概念が提唱され、わかりやすさや、言語そのものの機能による自由な記述が重視されました。 DIの登場にはそのような背景があり、クライアントに対して純粋な言語機能以外求められないことが一般的です。

最後に、先ほども触れましたが 依存オブジェクトのモック化によるユニットテストが可能になる という点です。たとえばWebアプリケーションについて考えると、Webアプリケーションは様々な外部システムを使います。 WebアプリケーションはMySQLやRedisなどのストレージを使い、TwitterやFacebookなどの外部サービスにアクセスすることもあるでしょう。また刻一刻と変化する時間や天候などの情報を使うかもしれません。このような外部システムが関係するモジュールはユニットテストすることが困難です。 DIを使えば外部システムの実装を分離できるので、モックに置き換えて、楽にテストできるようになります。

以上、DIの利点を見てきました。実装オブジェクト(Dependency)を取得し、サービスに渡す(Injection)という役割をするだけのインジェクタの登場により様々なメリットが生まれることが理解できたと思います。 DIは特に大規模システムの構築に欠かせない技術であると言っても過言ではないと思います。

results matching ""

    No results matching ""