10
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Elixir: 振る舞いと汎用GenServerの設計メモ

10
Last updated at Posted at 2025-12-17

はじめに

Nerves で 3.5インチLCDを扱うプロジェクトを進める中で、サポートしたい制御ICが増えてきました。ILI9486、ST7796などはやりたいことは同じなのに、初期化手順やレジスタが違うのでどのようにしてコードを整理するのかが課題でした。

最初は defprotocol + defimpl を使うことも検討しましたが、構成が複雑になりやすく割が合わないと感じて見送りました。

そこで最終的に落ち着いたのが、振る舞い(behaviour) と 汎用GenServer を組み合わせる構成です。見通しが良く、あとから制御ICを追加する場合も手を入れる範囲が読みやすくなるので、個人的に氣に入っています。

やりたいこと

私がやりたかったのは、 制御ICの違いを内部で吸収しつつ、利用側には統一された API を提供することです。特に以下のような構成が理想でした。

  • 制御ICの違いを内部で隠蔽
  • 表示器とのやりとりは統一APIで行う
  • GPIOやSPIの詳細、初期化手順などはプロセス内部に閉じる

当初は制御ICごとに個別実装していたのですが、規模が大きくなるにつれて保守性に限界を感じました。

振る舞い + 汎用GenServer

最終的に落ち着いた構成は、単純な役割分担でした。

  • 「状態をどう更新するか」: 振る舞いを実装した制御ロジック(制御ICごとの実装)
  • 「状態を持つ」: GenServer

この形にすると、GenServer 側のコードはかなり機械的になり、制御ICごとのクセや初期化手順は制御ロジック側に集約できるため、全体の見通しが良くなりました。

制御ロジックの契約を振る舞いで定義

まず、駆動部が満たすべき関数を振る舞いで定義します。ここでは初期化で状態を作り、以後は状態を更新しながら返す形にしています。

制御ロジックの実装例

制御ICごとに制御ロジック用モジュールを作り、振る舞いを実装します。

ポイントはこれだけです。

  • init/1 で初期状態(構造体)を作る
  • 以後の関数は状態を受け取り、必要な処理を行い、更新した状態を返す
  • GenServer 側はその状態を持つだけ

細かいレジスタ値などは本筋ではないので省略します。

defmodule LcdDisplay.ILI9486.Display do
  @behaviour LcdDisplay.DisplayDriver.DisplayContract

  defstruct [:spi, :gpio, :opts, :pixel_format]

  @impl true
  def init(opts) do
    # ここでSPI/GPIO を開いたり、初期化シーケンスを実行したりする
    
    %__MODULE__{spi: :spi_handle, gpio: :gpio_handle, opts: opts, pixel_format: :rgb565}
  end

  @impl true
  def reset(display) do
    # ここでハードウェアを操作する
    
    display
  end

  @impl true
  def size(%__MODULE__{opts: opts}) do
    %{width: opts[:width] || 480, height: opts[:height] || 320}
  end

  @impl true
  def set_pixel_format(display, fmt) do
    # ここで必要な設定を書き込む
    
    %{display | pixel_format: fmt}
  end
end

状態を保持する汎用 GenServer

次に、状態を保持して制御ロジックへ委譲する汎用 GenServer を用意します。ここは「どの制御ICか」を知りません。

  • display_impl に制御ロジックのモジュールを渡す
  • display_state にその状態(構造体)を持つ
  • handle_call では display_impl.xxx(display_state) を呼び、戻り値で状態を更新する
defmodule LcdDisplay.DisplayDriver.GenericDisplayDriver do
  use GenServer

  def start_link(opts) do
    display_impl = Keyword.fetch!(opts, :display_impl)
    name = Keyword.get(opts, :name)
    GenServer.start_link(__MODULE__, {display_impl, opts}, name: name)
  end

  @impl true
  def init({display_impl, opts}) do
    Process.flag(:trap_exit, true)
    display_state = display_impl.init(opts)
    {:ok, %{display_impl: display_impl, display_state: display_state}}
  end

  @impl true
  def terminate(reason, %{display_impl: impl, display_state: state}) do
    impl.terminate(reason, state)
  end

  ## 公開API(PIDまたは登録名で呼ぶ)

  def reset(server), do: GenServer.call(server, :reset)
  def size(server), do: GenServer.call(server, :size)
  def set_pixel_format(server, fmt), do: GenServer.call(server, {:set_pixel_format, fmt})
  def write_frame_565(server, data), do: GenServer.call(server, {:write_frame_565, data})

  ## 委譲

  @impl true
  def handle_call(:reset, _from, %{display_impl: impl, display_state: d} = s) do
    {:reply, :ok, %{s | display_state: impl.reset(d)}}
  end

  @impl true
  def handle_call(:size, _from, %{display_impl: impl, display_state: d} = s) do
    {:reply, impl.size(d), s}
  end

  @impl true
  def handle_call({:set_pixel_format, fmt}, _from, %{display_impl: impl, display_state: d} = s) do
    {:reply, :ok, %{s | display_state: impl.set_pixel_format(d, fmt)}}
  end
end

制御ICごとのラッパーモジュールを用意

利用側から見ると、display_impl を毎回渡すのは面倒です。そこで、制御ICごとのラッパーモジュールを用意しました。

まず、共通の薄い層を作るマクロです。

defmodule LcdDisplay.DisplayDriver do
  defmacro __using__(opts) do
    impl = Keyword.fetch!(opts, :display_impl)

    quote do
      alias LcdDisplay.DisplayDriver.GenericDisplayDriver

      def start_link(opts \\ []) do
        GenericDisplayDriver.start_link(Keyword.put(opts, :display_impl, unquote(impl)))
      end

      defdelegate reset(server), to: GenericDisplayDriver
      defdelegate size(server), to: GenericDisplayDriver
      defdelegate set_pixel_format(server, fmt), to: GenericDisplayDriver
      defdelegate write_frame_565(server, data), to: GenericDisplayDriver
    end
  end
end

次に、制御ICごとの窓口モジュールは 1 行で済みます。

defmodule LcdDisplay.ILI9486 do
  use LcdDisplay.DisplayDriver, display_impl: LcdDisplay.ILI9486.Display
end

defmodule LcdDisplay.ST7796 do
  use LcdDisplay.DisplayDriver, display_impl: LcdDisplay.ST7796.Display
end

利用側はこうなります。

{:ok, lcd} =
  LcdDisplay.ILI9486.start_link(
    name: :main_lcd,
    spi_bus: "spidev0.0",
    data_command_pin: 24,
    reset_pin: 25,
    width: 480,
    height: 320
  )

LcdDisplay.ILI9486.reset(lcd)
LcdDisplay.ILI9486.write_frame_565(lcd, image_data)

制御ICを切り替えるときは、LcdDisplay.ILI9486LcdDisplay.ST7796 に変える(と必要な設定値を調整する)だけで済みます。利用側の関数名は固定のままです。

この形が効く場面

このパターンをどういうところで使うと効果的か考えてみました。

  • 扱いたいデバイスの種類が複数ある
  • やりたい操作は共通しているが、初期化手順や内部仕様が異なる
  • デバイスごとに状態を持ち、プロセスとして分離したい
  • 利用側のコードは、できるだけ単純なAPIで書きたい

LCD表示器に限らず、SPI や I2C で接続する周辺機器全般で使いやすい形だと感じています。

おわりに

振る舞いと汎用GenServerで役割を分け、制御ロジックを差し替える構成について一例をご紹介させていただきました。

制御ICごとの違いは制御ロジックに閉じ込め、GenServerは状態管理と委譲に専念します。その結果、新しい表示器を追加しても利用側のコードを触らずに済むようになりました。

LCDに限らず、初期化手順やI/Oが異なる周辺機器を複数扱う場面でも使いやすい形だと感じています。

:tada::tada::tada:

10
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?