依存性の注入をRubyのサンプルコードで初心者向けに説明する

会社のSlackや技術書などで「依存性の注入」という言葉をよく目にするんですが、内容がわかっていなかったので、備忘録としてまとめます。

サンプルコードはRubyとRSpecで書くので、Rubyの経験しかないエンジニアの参考になれば幸いです。

記事の信頼性
  • ぼくは独学で未経験から従業員300名以上の自社開発企業へ転職しました。
  • 実務ではVue.jsとRailsを毎日書いています。
  • 初心者や駆け出しエンジニアがつまづくポイントも身をもってよく理解しています。
目次

そもそも依存とは?

「依存性の注入」を理解するためにはまず、そもそも「依存とは何か?」から知らなくてはいけません。

依存とは、「ある処理を実行するために別の処理を必要とすること」を指します。

たとえばクラスAのなかでクラスBのメソッドを呼んでいる場合、「クラスAはクラスBに依存している」と言えます。

ざっくり「呼び出す側=依存する側」、「呼び出される側=依存される側」の理解でOKです。

依存は完全にゼロにはできませんが、「依存度が高い=密結合」なので、できるかぎり依存度が低い状態、つまり疎結合をめざす必要があります。

依存性の注入とは?

そこで登場するのが、「依存性の注入」です。

依存性の注入は、依存しているオブジェクトをクラス内で直接生成するのではなく、外部から渡すことで、結合度を下げる手法です。

これだけじゃ意味がわからないので、通知サービスを例にサンプルコードで説明します。

依存性の注入なしの例

まずは依存性の注入を使わない場合の例です。

EmailServiceクラスはメール通知ロジックを、NotificationServiceクラスはEmailServiceクラスを用いて通知の実行を担います。

require 'net/smtp'

# メール通知のロジックを実装
class EmailService
  def initialize(from, smtp_server, port = 587)
    @from = from
    @smtp_server = smtp_server
    @port = port
  end

  def send(to, subject, message)
    msg = <<~MESSAGE
      From: #{@from}
      To: #{to}
      Subject: #{subject}

      #{message}
    MESSAGE

    Net::SMTP.start(@smtp_server, @port) do |smtp|
      smtp.send_message msg, @from, to
    end
  rescue StandardError => e
    puts "Failed to send email: #{e.message}"
  end
end

# 通知の送信を実装
class NotificationService
  def initialize
    # EmailService クラスのインスタンスを NotificationService クラス内で直接生成している
    @emailer = EmailService.new('noreply@example.com', 'smtp.example.com')
  end

  def notify(user_email, subject, message)
    @emailer.send(user_email, subject, message)
  end
end

# 使用例
notification_service = NotificationService.new
notification_service.notify('user@example.com', 'Welcome!', 'Welcome to our service!')

上記の場合、NotificationServiceクラスはEmailServiceクラスのインスタンス生成時にfromsmtp_serverの2つが必要であることを知ってしまっています。

そのため相対的に依存度が高い(=密結合)状態です。

依存性の注入を用いた例

つづいて依存性の注入を用いた例です。

各クラスが担う役割はおなじです。

require 'net/smtp'
# EmailServiceクラスは変更なし
class EmailService
  def initialize(from, smtp_server, port = 587)
    @from = from
    @smtp_server = smtp_server
    @port = port
  end

  def send(to, subject, message)
    msg = <<~MESSAGE
      From: #{@from}
      To: #{to}
      Subject: #{subject}

      #{message}
    MESSAGE

    Net::SMTP.start(@smtp_server, @port) do |smtp|
      smtp.send_message msg, @from, to
    end
  rescue StandardError => e
    puts "Failed to send email: #{e.message}"
  end
end

class NotificationService
 # EmailService クラスのインスタンスを直接生成せず、引数として受け取る
  def initialize(sender)
    @sender = sender
  end

  def notify(user_contact, subject, message)
    @sender.send(user_contact, subject, message)
  end
end

# 使用例
email_service = EmailService.new('noreply@example.com', 'smtp.example.com')
# email_service を引数として渡す
notification_service = NotificationService.new(email_service)
notification_service.notify('user@example.com', 'Welcome!', 'Welcome to our service!')

↑のように書けば、EmailServiceクラスのインスタンス生成時にどんな引数が必要であるか、NotificationServiceクラスが知っている必要はなく、生成済みのインスタンスを受け取ればよいだけです。

つまり、より疎結合(=依存度が相対的に下がった)な状態と言えます。

これが「依存性の注入」です。

依存性の注入のメリット

この「依存性の注入」を使用するメリットとして以下の3つが挙げられます。

  1. コードを変更しやすい
  2. 機能を追加しやすい
  3. テストがカンタン

なるべくカンタンな言葉でより詳しく説明します。

1. コードを変更しやすい

たとえば先ほどの通知サービスの例の場合、依存性の注入を使わないと、EmailServiceクラスのinitializeメソッドの引数が変更された場合に、NotificationServiceクラスまで修正が必要になってしまいます。

# 依存性の注入を使っていない場合
class NotificationService
  def initialize
    # SMTPサーバのホスト名を変更する場合、第2引数の 'smtp.example.com' も修正が必要になってしまう
    @emailer = EmailService.new('noreply@example.com', 'smtp.example.com')
  end

  def notify(user_email, subject, message)
    @emailer.send(user_email, subject, message)
  end
end

# 使用例
notification_service = NotificationService.new
notification_service.notify('user@example.com', 'Welcome!', 'Welcome to our service!')

しかし、依存性の注入を使えば、たとえEmailServiceクラスに必要な情報が変化したとしても、NotificationServiceクラス側には影響しません。

# 依存性の注入を使っている場合
class NotificationService
 # EmailServiceクラスのインスタンスを引数で受け取る
  def initialize(sender)
    @sender = sender
  end

  def notify(user_contact, subject, message)
    @sender.send(user_contact, subject, message)
  end
end

# SMTPサーバのホスト名を 'smtp.example.com' から 'new_smtp.example.com' へ変更する
email_service = EmailService.new('noreply@example.com', 'new_smtp.example.com')

# ホスト名が変わったとしても email_service を引数として渡せばいいだけ
notification_service = NotificationService.new(email_service)
notification_service.notify('user@example.com', 'Welcome!', 'Welcome to our service!')

このように、後から変更が発生したとしても、他のコードに影響を及ぼさず、修正箇所を最小限におさえられるのが「依存性の注入」の1つ目のメリットです。

2. 機能を追加しやすい

依存性の注入を使わない場合のNotificationServiceクラスはEmailServiceクラスに依存してしまっている状況でした。

# 依存性の注入を使っていない場合
class NotificationService
  def initialize
    # NotificationService クラス内で EmailService クラスのインスタンスを生成(=依存している)
    @emailer = EmailService.new('noreply@example.com', 'smtp.example.com')
  end

  def notify(user_email, subject, message)
    @emailer.send(user_email, subject, message)
  end
end

# 使用例
notification_service = NotificationService.new
notification_service.notify('user@example.com', 'Welcome!', 'Welcome to our service!')

これだと、メール以外の方法で通知したくなったら、NotificationServiceクラスも修正する or NotificationServiceとは別の通知用の〇〇Serviceクラスを作成しないといけません。

しかし、依存性の注入を採用すれば、他の通知方法もカンタンに追加できます。

以下のコードはSMSでの通知機能のために追加したSmsServiceクラスの実装です。

require 'twilio-ruby'
# 新たにSMS通知の機能も実装
class SmsService
  def initialize(api_key, password)
    @api_key = api_key
    @password = password
    @client = Twilio::REST::Client.new(api_key, password)
  end

  def send(user_phone_number, subject, message)
    begin
      message = "[#{subject}] #{message}"

      @client.messages.create(
        from: '+15555555555',
        to: user_phone_number,
        body: message
      )
    rescue Twilio::REST::RequestError => e
      puts Failed to send sms: #{e.message}"
    end
  end
end

たとえ通知がメールだろうとSMSだろうとNotificationServiceクラスの実装に修正は必要ありません。

# 依存性の注入を使っている場合
class NotificationService
 # 引数が EmailService クラスのインスタンスでも SmsService クラスのインスタンスでも変わらない
  def initialize(sender)
    @sender = sender
  end

  def notify(user_contact, subject, message)
    @sender.send(user_contact, subject, message)
  end
end

SmsService.new(ENV['TWILIO_API_KEY'], ENV['TWILIO_PASSWORD'])

# email_service ではなく sms_service を引数として渡せば後は同じ
notification_service = NotificationService.new(sms_service)
notification_service.notify('user@example.com', 'Welcome!', 'Welcome to our service!')

このように、他の種類の機能追加をカンタンに実装できるのが「依存性の注入」の2つ目のメリットです。

3. テストがカンタン

一言で表すと、「テストがしやすくなる」ということです。

NotificationServiceクラスのテストを書く際には、EmailServiceクラスのインスタンスをモック化する必要があります。

依存性の注入を使わなかった場合、NotificationServiceクラス内で直接EmailServiceクラスのインスタンスを生成しているため、EmailServiceクラスの内部挙動に依存したモックとなってしまいます、、。

# 依存性の注入を使っていない場合
describe NotificationService do
  it '通知の送信に成功する' do
    # EmailService の実際のインスタンスが生成されるため、メールサーバーへの接続が発生しうる
    notification_service = NotificationService.new

    # テスト実行時に実際にメールを送信しないよう、Net::SMTP をモックするなどの工夫が必要
    # ↑はEmailServiceクラスの内部挙動に依存してしまっている
    allow(Net::SMTP).to receive(:start).and_return(true)

    expect(notification_service.notify('user@example.com', 'Welcome!', 'Welcome to our service!')).to be_truthy
  end
end

依存性の注入を使えば、このテストはより簡潔になります。

# 依存性の注入を使う場合
describe NotificationService do
  it '通知の送信に成功する' do
    # double メソッドで EmailService クラス自体をモック化
    mock_email_service = double('EmailService')
    # mock_email_service が send メソッドを受け取った時に true を返すよう設定
    allow(mock_email_service).to receive(:send).and_return(true)

    notification_service = NotificationService.new(mock_email_service)
    expect(notification_service.notify('user@example.com', 'Welcome!', 'Welcome to our service!')).to be_truthy
  end
end

このテストコードは、以下の2点をテストしています。

  • NotificationServiceクラスがEmailServiceクラスのsendメソッドを呼び出しているかどうか
  • notifyメソッドがEmailServicesendメソッドに適切な引数を渡しているかどうか

EmailServiceクラスの内部挙動には依存せずに済みます。

このように、シンプルかつ堅牢なテストを書けるのが「依存性の注入」を用いる3つ目のメリットです。

おわりに

ようやく依存性の注入が理解できました。

ドメイン駆動設計やクリーンアーキテクチャといった設計思想では、この依存性の管理が肝になるので、そういった概念を学ぶ最初の一歩を進めた気がします。

参考文献
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

未経験でSESから従業員300名以上の自社開発企業に転職しました。業務や個人開発で直面した問題や、転職・学習の経験を発信していきます。

コメント

コメントする

目次