第14章: Abstract Server パターン¶
はじめに¶
Abstract Server パターンは、依存関係逆転の原則(DIP)を実現するパターンです。高レベルモジュールが低レベルモジュールの詳細に依存するのではなく、両者が抽象(プロトコル)に依存することで疎結合を実現します。
Elixir では、プロトコルを使用して Abstract Server を定義し、異なる具体的な実装を提供します。
1. Switchable パターン¶
Switchable プロトコル¶
defprotocol Switchable do
@moduledoc "スイッチ可能なデバイスのプロトコル"
@doc "デバイスをオンにする"
@spec turn_on(t()) :: t()
def turn_on(device)
@doc "デバイスをオフにする"
@spec turn_off(t()) :: t()
def turn_off(device)
@doc "デバイスがオンかどうか"
@spec on?(t()) :: boolean()
def on?(device)
end
Concrete Server: Light¶
defmodule Light do
defstruct state: :off, brightness: 100
def new, do: %__MODULE__{}
def new(brightness), do: %__MODULE__{brightness: brightness}
def set_brightness(%__MODULE__{state: :on} = light, brightness)
when brightness >= 0 and brightness <= 100 do
%{light | brightness: brightness}
end
def set_brightness(light, _), do: light
end
defimpl Switchable, for: Light do
def turn_on(%Light{} = light), do: %{light | state: :on}
def turn_off(%Light{} = light), do: %{light | state: :off}
def on?(%Light{state: state}), do: state == :on
end
Concrete Server: Fan¶
defmodule Fan do
defstruct state: :off, speed: nil
def new, do: %__MODULE__{}
def set_speed(%__MODULE__{state: :on} = fan, speed)
when speed in [:low, :medium, :high] do
%{fan | speed: speed}
end
def set_speed(fan, _), do: fan
end
defimpl Switchable, for: Fan do
def turn_on(%Fan{} = fan), do: %{fan | state: :on, speed: :low}
def turn_off(%Fan{} = fan), do: %{fan | state: :off, speed: nil}
def on?(%Fan{state: state}), do: state == :on
end
Concrete Server: Motor¶
defmodule Motor do
defstruct state: :off, direction: nil, rpm: 0
def new, do: %__MODULE__{}
def reverse(%__MODULE__{state: :on, direction: dir} = motor) do
new_dir = if dir == :forward, do: :reverse, else: :forward
%{motor | direction: new_dir}
end
def reverse(motor), do: motor
def set_rpm(%__MODULE__{state: :on} = motor, rpm) when rpm >= 0 do
%{motor | rpm: rpm}
end
def set_rpm(motor, _), do: motor
end
defimpl Switchable, for: Motor do
def turn_on(%Motor{} = motor), do: %{motor | state: :on, direction: :forward, rpm: 1000}
def turn_off(%Motor{} = motor), do: %{motor | state: :off, direction: nil, rpm: 0}
def on?(%Motor{state: state}), do: state == :on
end
Client: Switch¶
defmodule Switch do
@moduledoc """
スイッチクライアント。
Switchable プロトコルを通じてデバイスを操作する。
"""
@doc "スイッチを入れる"
def engage(device), do: Switchable.turn_on(device)
@doc "スイッチを切る"
def disengage(device), do: Switchable.turn_off(device)
@doc "スイッチを切り替える"
def toggle(device) do
if Switchable.on?(device) do
Switchable.turn_off(device)
else
Switchable.turn_on(device)
end
end
@doc "デバイスの状態を取得"
def status(device), do: if(Switchable.on?(device), do: :on, else: :off)
end
使用例¶
# Light を操作
light = Light.new()
light = Switch.engage(light) # オン
light = Switch.toggle(light) # オフ
# Fan を操作(同じ Switch コード)
fan = Fan.new()
fan = Switch.engage(fan) # オン(speed: :low)
fan = Fan.set_speed(fan, :high) # 速度を変更
fan = Switch.disengage(fan) # オフ
# Motor を操作(同じ Switch コード)
motor = Motor.new()
motor = Switch.engage(motor) # オン(direction: :forward, rpm: 1000)
motor = Motor.reverse(motor) # 方向を反転
2. Repository パターン¶
Repository プロトコル¶
defprotocol Repository do
@moduledoc "データリポジトリのプロトコル"
@doc "IDでエンティティを取得"
@spec find_by_id(t(), String.t()) :: {:ok, map()} | {:error, :not_found}
def find_by_id(repo, id)
@doc "全てのエンティティを取得"
@spec find_all(t()) :: [map()]
def find_all(repo)
@doc "エンティティを保存"
@spec save(t(), map()) :: {:ok, map()}
def save(repo, entity)
@doc "エンティティを削除"
@spec delete(t(), String.t()) :: {:ok, map()} | {:error, :not_found}
def delete(repo, id)
end
Concrete Server: MemoryRepository¶
defmodule MemoryRepository do
@moduledoc "インメモリリポジトリ"
defstruct [:agent]
def new do
{:ok, agent} = Agent.start_link(fn -> %{} end)
%__MODULE__{agent: agent}
end
def stop(%__MODULE__{agent: agent}) do
if Process.alive?(agent) do
Agent.stop(agent)
else
:ok
end
end
end
defimpl Repository, for: MemoryRepository do
def find_by_id(%MemoryRepository{agent: agent}, id) do
case Agent.get(agent, &Map.get(&1, id)) do
nil -> {:error, :not_found}
entity -> {:ok, entity}
end
end
def find_all(%MemoryRepository{agent: agent}) do
Agent.get(agent, &Map.values(&1))
end
def save(%MemoryRepository{agent: agent}, entity) do
id = Map.get(entity, :id) || generate_id()
entity_with_id = Map.put(entity, :id, id)
Agent.update(agent, &Map.put(&1, id, entity_with_id))
{:ok, entity_with_id}
end
def delete(%MemoryRepository{agent: agent}, id) do
case Agent.get(agent, &Map.get(&1, id)) do
nil ->
{:error, :not_found}
entity ->
Agent.update(agent, &Map.delete(&1, id))
{:ok, entity}
end
end
defp generate_id, do: :crypto.strong_rand_bytes(8) |> Base.encode16(case: :lower)
end
Concrete Server: MockRepository(テスト用)¶
defmodule MockRepository do
@moduledoc "モックリポジトリ(テスト用)"
defstruct data: %{}
def new(data \\ %{}), do: %__MODULE__{data: data}
end
defimpl Repository, for: MockRepository do
def find_by_id(%MockRepository{data: data}, id) do
case Map.get(data, id) do
nil -> {:error, :not_found}
entity -> {:ok, entity}
end
end
def find_all(%MockRepository{data: data}), do: Map.values(data)
def save(%MockRepository{}, entity) do
id = Map.get(entity, :id) || "mock-id"
entity_with_id = Map.put(entity, :id, id)
{:ok, entity_with_id}
end
def delete(%MockRepository{data: data}, id) do
case Map.get(data, id) do
nil -> {:error, :not_found}
entity -> {:ok, entity}
end
end
end
3. Logger パターン¶
Logger プロトコル¶
defprotocol Logger do
@moduledoc "ロガーのプロトコル"
@doc "デバッグログ"
@spec debug(t(), String.t()) :: t()
def debug(logger, message)
@doc "情報ログ"
@spec info(t(), String.t()) :: t()
def info(logger, message)
@doc "警告ログ"
@spec warn(t(), String.t()) :: t()
def warn(logger, message)
@doc "エラーログ"
@spec error(t(), String.t()) :: t()
def error(logger, message)
end
Concrete Server: ConsoleLogger¶
defmodule ConsoleLogger do
@moduledoc "コンソールロガー"
defstruct level: :debug
@levels [:debug, :info, :warn, :error]
def new(level \\ :debug), do: %__MODULE__{level: level}
def should_log?(%__MODULE__{level: min_level}, level) do
min_idx = Enum.find_index(@levels, &(&1 == min_level))
level_idx = Enum.find_index(@levels, &(&1 == level))
level_idx >= min_idx
end
end
defimpl Logger, for: ConsoleLogger do
def debug(logger, message) do
if ConsoleLogger.should_log?(logger, :debug), do: IO.puts("[DEBUG] #{message}")
logger
end
def info(logger, message) do
if ConsoleLogger.should_log?(logger, :info), do: IO.puts("[INFO] #{message}")
logger
end
def warn(logger, message) do
if ConsoleLogger.should_log?(logger, :warn), do: IO.puts("[WARN] #{message}")
logger
end
def error(logger, message) do
if ConsoleLogger.should_log?(logger, :error), do: IO.puts("[ERROR] #{message}")
logger
end
end
Concrete Server: MemoryLogger(テスト用)¶
defmodule MemoryLogger do
@moduledoc "メモリロガー(テスト用)"
defstruct entries: []
def new, do: %__MODULE__{}
def get_entries(%__MODULE__{entries: entries}), do: Enum.reverse(entries)
def clear(%__MODULE__{}), do: %__MODULE__{}
end
defimpl Logger, for: MemoryLogger do
def debug(%MemoryLogger{entries: entries} = logger, message) do
%{logger | entries: [{:debug, message} | entries]}
end
def info(%MemoryLogger{entries: entries} = logger, message) do
%{logger | entries: [{:info, message} | entries]}
end
def warn(%MemoryLogger{entries: entries} = logger, message) do
%{logger | entries: [{:warn, message} | entries]}
end
def error(%MemoryLogger{entries: entries} = logger, message) do
%{logger | entries: [{:error, message} | entries]}
end
end
4. Notifier パターン¶
Notifier プロトコル¶
defprotocol Notifier do
@moduledoc "通知のプロトコル"
@doc "通知を送信"
@spec notify(t(), String.t(), String.t()) :: {:ok, t()} | {:error, term()}
def notify(notifier, recipient, message)
end
複数の通知実装¶
defmodule EmailNotifier do
defstruct from: "noreply@example.com", sent: []
def new(from \\ "noreply@example.com"), do: %__MODULE__{from: from}
def get_sent(%__MODULE__{sent: sent}), do: Enum.reverse(sent)
end
defimpl Notifier, for: EmailNotifier do
def notify(%EmailNotifier{from: from, sent: sent} = notifier, recipient, message) do
email = %{from: from, to: recipient, subject: "Notification", body: message}
{:ok, %{notifier | sent: [email | sent]}}
end
end
defmodule SMSNotifier do
defstruct sender: "+1234567890", sent: []
def new(sender \\ "+1234567890"), do: %__MODULE__{sender: sender}
def get_sent(%__MODULE__{sent: sent}), do: Enum.reverse(sent)
end
defimpl Notifier, for: SMSNotifier do
def notify(%SMSNotifier{sender: sender, sent: sent} = notifier, recipient, message) do
truncated = String.slice(message, 0, 160) # SMS character limit
sms = %{from: sender, to: recipient, body: truncated}
{:ok, %{notifier | sent: [sms | sent]}}
end
end
defmodule PushNotifier do
defstruct app_id: "default", sent: []
def new(app_id \\ "default"), do: %__MODULE__{app_id: app_id}
def get_sent(%__MODULE__{sent: sent}), do: Enum.reverse(sent)
end
defimpl Notifier, for: PushNotifier do
def notify(%PushNotifier{app_id: app_id, sent: sent} = notifier, recipient, message) do
push = %{app_id: app_id, device_token: recipient, body: message}
{:ok, %{notifier | sent: [push | sent]}}
end
end
CompositeNotifier(複数チャネルに送信)¶
defmodule CompositeNotifier do
@moduledoc "複合通知(複数チャネルに同時送信)"
defstruct notifiers: []
def new(notifiers), do: %__MODULE__{notifiers: notifiers}
def add(%__MODULE__{notifiers: ns} = cn, notifier) do
%{cn | notifiers: ns ++ [notifier]}
end
end
defimpl Notifier, for: CompositeNotifier do
def notify(%CompositeNotifier{notifiers: notifiers} = composite, recipient, message) do
updated_notifiers = Enum.map(notifiers, fn n ->
case Notifier.notify(n, recipient, message) do
{:ok, updated} -> updated
{:error, _} -> n
end
end)
{:ok, %{composite | notifiers: updated_notifiers}}
end
end
5. Service Layer¶
UserService¶
defmodule UserService do
@moduledoc """
ユーザーサービス。
Repository と Logger に依存するが、具体的な実装は知らない。
"""
defstruct [:repo, :logger]
def new(repo, logger), do: %__MODULE__{repo: repo, logger: logger}
@doc "ユーザーを作成"
def create_user(%__MODULE__{repo: repo, logger: logger} = service, name, email) do
logger = Logger.info(logger, "Creating user: #{name}")
user = %{name: name, email: email, created_at: DateTime.utc_now()}
case Repository.save(repo, user) do
{:ok, saved} ->
Logger.info(logger, "User created: #{saved.id}")
{:ok, saved, %{service | logger: logger}}
error ->
Logger.error(logger, "Failed to create user: #{inspect(error)}")
error
end
end
@doc "ユーザーを取得"
def get_user(%__MODULE__{repo: repo, logger: logger} = service, id) do
logger = Logger.debug(logger, "Getting user: #{id}")
case Repository.find_by_id(repo, id) do
{:ok, user} ->
{:ok, user, %{service | logger: logger}}
{:error, :not_found} = error ->
Logger.warn(logger, "User not found: #{id}")
error
end
end
@doc "全ユーザーを取得"
def get_all_users(%__MODULE__{repo: repo, logger: logger} = service) do
logger = Logger.debug(logger, "Getting all users")
users = Repository.find_all(repo)
{:ok, users, %{service | logger: logger}}
end
end
NotificationService¶
defmodule NotificationService do
@moduledoc """
通知サービス。
Notifier と Logger に依存する。
"""
defstruct [:notifier, :logger]
def new(notifier, logger), do: %__MODULE__{notifier: notifier, logger: logger}
@doc "ユーザーに通知を送信"
def notify_user(%__MODULE__{notifier: notifier, logger: logger} = service, user, message) do
recipient = Map.get(user, :email) || Map.get(user, :phone) || Map.get(user, :device_token)
logger = Logger.info(logger, "Sending notification to: #{recipient}")
case Notifier.notify(notifier, recipient, message) do
{:ok, updated_notifier} ->
logger = Logger.info(logger, "Notification sent successfully")
{:ok, %{service | notifier: updated_notifier, logger: logger}}
{:error, reason} = error ->
Logger.error(logger, "Failed to send notification: #{inspect(reason)}")
error
end
end
@doc "複数ユーザーに通知を送信"
def broadcast(%__MODULE__{} = service, users, message) do
Enum.reduce(users, {:ok, service}, fn user, acc ->
case acc do
{:ok, srv} -> notify_user(srv, user, message)
error -> error
end
end)
end
end
6. Dependency Injection Container¶
defmodule Container do
@moduledoc """
依存性注入コンテナ。
Abstract Server の具体的な実装を管理する。
"""
defstruct services: %{}
def new, do: %__MODULE__{}
@doc "サービスを登録"
def register(%__MODULE__{services: services} = container, key, service) do
%{container | services: Map.put(services, key, service)}
end
@doc "サービスを取得"
def resolve(%__MODULE__{services: services}, key) do
case Map.get(services, key) do
nil -> {:error, {:not_found, key}}
service -> {:ok, service}
end
end
@doc "サービスを取得(存在しない場合はエラー)"
def resolve!(%__MODULE__{} = container, key) do
case resolve(container, key) do
{:ok, service} -> service
{:error, _} -> raise "Service not found: #{inspect(key)}"
end
end
@doc "ファクトリ関数でサービスを登録"
def register_factory(%__MODULE__{services: services} = container, key, factory)
when is_function(factory, 1) do
%{container | services: Map.put(services, key, {:factory, factory})}
end
@doc "ファクトリを考慮してサービスを解決"
def resolve_with_factory(%__MODULE__{services: services} = container, key) do
case Map.get(services, key) do
nil -> {:error, {:not_found, key}}
{:factory, factory} -> {:ok, factory.(container)}
service -> {:ok, service}
end
end
end
7. Application セットアップ¶
defmodule Application do
@moduledoc "アプリケーションのセットアップ例"
@doc "開発環境用のコンテナを作成"
def development_container do
Container.new()
|> Container.register(:repository, MemoryRepository.new())
|> Container.register(:logger, ConsoleLogger.new(:debug))
|> Container.register(:notifier, EmailNotifier.new("dev@example.com"))
|> Container.register_factory(:user_service, fn container ->
repo = Container.resolve!(container, :repository)
logger = Container.resolve!(container, :logger)
UserService.new(repo, logger)
end)
|> Container.register_factory(:notification_service, fn container ->
notifier = Container.resolve!(container, :notifier)
logger = Container.resolve!(container, :logger)
NotificationService.new(notifier, logger)
end)
end
@doc "テスト環境用のコンテナを作成"
def test_container do
Container.new()
|> Container.register(:repository, MockRepository.new())
|> Container.register(:logger, MemoryLogger.new())
|> Container.register(:notifier, EmailNotifier.new("test@example.com"))
|> Container.register_factory(:user_service, fn container ->
repo = Container.resolve!(container, :repository)
logger = Container.resolve!(container, :logger)
UserService.new(repo, logger)
end)
end
end
使用例¶
# 開発環境でアプリケーションをセットアップ
container = Application.development_container()
# サービスを取得
{:ok, user_service} = Container.resolve_with_factory(container, :user_service)
{:ok, notification_service} = Container.resolve_with_factory(container, :notification_service)
# サービスを使用
{:ok, user, _} = UserService.create_user(user_service, "Alice", "alice@example.com")
{:ok, _} = NotificationService.notify_user(notification_service, user, "Welcome!")
# テスト環境に切り替え
test_container = Application.test_container()
{:ok, test_user_service} = Container.resolve_with_factory(test_container, :user_service)
# 同じサービスインターフェースで、異なる実装(モック)を使用
設計のポイント¶
依存関係逆転の原則(DIP)¶
従来の依存関係:
┌─────────────┐ ┌─────────────┐
│ UserService │ ──► │MemoryRepo │
└─────────────┘ └─────────────┘
Abstract Server による依存関係:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ UserService │ ──► │ Repository │ ◄── │MemoryRepo │
└─────────────┘ │ (Protocol) │ │ MockRepo │
└─────────────┘ └─────────────┘
テスト容易性¶
# テスト時はモック実装を注入
defmodule UserServiceTest do
test "creates user" do
mock_repo = MockRepository.new()
memory_logger = MemoryLogger.new()
service = UserService.new(mock_repo, memory_logger)
{:ok, user, updated_service} = UserService.create_user(service, "Test", "test@example.com")
assert user.name == "Test"
# ログを検証
entries = MemoryLogger.get_entries(updated_service.logger)
assert Enum.any?(entries, fn {level, _} -> level == :info end)
end
end
拡張性¶
新しい実装を追加するには、プロトコルを実装するだけ:
# 新しいリポジトリ実装
defmodule PostgresRepository do
defstruct [:connection]
def new(connection), do: %__MODULE__{connection: connection}
end
defimpl Repository, for: PostgresRepository do
def find_by_id(repo, id), do: # PostgreSQL クエリ
def find_all(repo), do: # PostgreSQL クエリ
def save(repo, entity), do: # PostgreSQL INSERT/UPDATE
def delete(repo, id), do: # PostgreSQL DELETE
end
# 既存のサービスコードは変更不要
service = UserService.new(PostgresRepository.new(conn), ConsoleLogger.new())
まとめ¶
Abstract Server パターンの利点:
- 疎結合: 高レベルモジュールが低レベルモジュールの詳細に依存しない
- テスト容易性: モック実装を容易に注入可能
- 拡張性: 新しい実装を追加しても既存コードに影響なし
- 設定可能性: 環境に応じて異なる実装を選択可能
Elixir のプロトコルは、Abstract Server パターンを自然に実装するための強力な仕組みを提供します。構造体と組み合わせることで、型安全で拡張可能なシステムを構築できます。