エンジニアのソフトウェア的愛情

または私は如何にして心配するのを止めてプログラムを・愛する・ようになったか

分岐をハードコーディングしない方法についての考察

条件による分岐をハードコーディングせずに実現する方法について考えてみました。

ただし、ここで試してみた方法はライブラリやフレームワークとして再利用するというよりも、再実装せずにふるまいを変えることができる電子回路のジャンパピンのような仕組みのイメージです。

最初に分岐をハードコーディング

まずはハードコーディングして実現したいことを整理します。

例は入力で与えられる Map の中の path の値を元に呼び出す関数を切り替える簡単な実装です。

本体。 params.path の値によって Foo, Bar, Baz を呼び分けます。 該当しない場合は Error の関数を呼び出します。

defmodule MyApp do
  def create(params) do
    case params.path do
      "/foo/" <> _ -> MyApp.Foo.create(params)
      "/bar/" <> _ -> MyApp.Bar.create(params)
      "/baz/" <> _ -> MyApp.Baz.create(params)
      _ -> MyApp.Error.create(params)
    end
  end

  def update(params) do
    case params.path do
      "/foo/" <> _ -> MyApp.Foo.update(params)
      "/bar/" <> _ -> MyApp.Bar.update(params)
      "/baz/" <> _ -> MyApp.Baz.update(params)
      _ -> MyApp.Error.update(params)
    end
  end
end

分岐後に呼び出される関数を定義した MyApp.Foo の実装です。 Bar, Baz, Error は名前が異なるだけの同じ内容になるので省略します。

defmodule MyApp.Foo do
  def create(_params) do
    "Foo.create/1"
  end

  def update(_params) do
    "Foo.update/1"
  end
end

テストコード。

defmodule MyAppTest do
  use ExUnit.Case
  doctest MyApp

  describe "create/" do
    test "call create /foo/1", do: assert MyApp.create(%{path: "/foo/1"}) == "Foo.create/1"
    test "call create /bar/1", do: assert MyApp.create(%{path: "/bar/1"}) == "Bar.create/1"
    test "call create /baz/1", do: assert MyApp.create(%{path: "/baz/1"}) == "Baz.create/1"
    test "call create /abc/1", do: assert MyApp.create(%{path: "/abc/1"}) == "Error.create/1"
  end

  describe "update/1" do
    test "call update /foo/1", do: assert MyApp.update(%{path: "/foo/1"}) == "Foo.update/1"
    test "call update /bar/1", do: assert MyApp.update(%{path: "/bar/1"}) == "Bar.update/1"
    test "call update /baz/1", do: assert MyApp.update(%{path: "/baz/1"}) == "Baz.update/1"
    test "call update /abc/1", do: assert MyApp.update(%{path: "/abc/1"}) == "Error.update/1"
  end
end

ここから分岐をはがしてゆきます。

呼び出しを値に置き換える

最初に、関数の呼び出しを分岐から分離します。

分岐は利用するモジュールの取得のみにして、呼び出しには Kernel.apply/3 を利用するように変更しました。

defmodule MyApp do
  def create(params) do
    module =
      case params.path do
      "/foo/" <> _ -> MyApp.Foo
      "/bar/" <> _ -> MyApp.Bar
      "/baz/" <> _ -> MyApp.Baz
        _ -> MyApp.Error
      end

    apply(module, :create, [params])
  end

  def update(params) do
    module =
      case params.path do
      "/foo/" <> _ -> MyApp.Foo
      "/bar/" <> _ -> MyApp.Bar
      "/baz/" <> _ -> MyApp.Baz
        _ -> MyApp.Error
      end

    apply(module, :update, [params])
  end
end

分岐を値に置き換える(1)

次に、分岐を表の検索に置き換えます。

条件と利用するモジュールの対応を表として分離し、条件を元にモジュールを検索することで分岐を置き換えます。

defmodule MyApp do
  def create(params) do
    table = 
      [
        [~r"^/foo/", MyApp.Foo],
        [~r"^/bar/", MyApp.Bar],
        [~r"^/baz/", MyApp.Baz],
        [~r".*",  MyApp.Error]
      ]

    [_, module] =
      Enum.find(table, fn [pattern, _] ->
        params.path =~ pattern
      end)

    apply(module, :create, [params])
  end

  def update(params) do
    table = 
      [
        [~r"^/foo/", MyApp.Foo],
        [~r"^/bar/", MyApp.Bar],
        [~r"^/baz/", MyApp.Baz],
        [~r".*",  MyApp.Error]
      ]

    [_, module] =
      Enum.find(table, fn [pattern, _] ->
        params.path =~ pattern
      end)

    apply(module, :update, [params])
  end
end

分岐を値に置き換える(2)

二つの関数でそれぞれ表を作成しましたが、これを一段入れ子を深くして一つの表にまとめてしまいます。

defmodule MyApp do
  @table %{
    create: [
        [~r"^/foo/", MyApp.Foo, :create],
        [~r"^/bar/", MyApp.Bar, :create],
        [~r"^/baz/", MyApp.Baz, :create],
        [~r".*",  MyApp.Error, :create]
    ],
    update: [
        [~r"^/foo/", MyApp.Foo, :update],
        [~r"^/bar/", MyApp.Bar, :update],
        [~r"^/baz/", MyApp.Baz, :update],
        [~r".*",  MyApp.Error, :update]
    ]
  }

  def create(params) do
    dispatch(:create, params)
  end

  def update(params) do
    dispatch(:update, params)
  end

  def dispatch(action, params) do
    [_, module, function] =
      Enum.find(@table[action], fn [pattern, _, _] ->
        params.path =~ pattern
      end)

    apply(module, function, [params])
  end
end

値を設定ファイルに移動する

最後に、分離した表をモジュールの実装から移動します。

ここで、移動先は config ファイルにします。 中でも config/runtime.exs はアプリケーションの起動時に評価されるので、再コンパイルすることなしにふるまいを変更するのに最適です。

defmodule MyApp do
  def create(params) do
    dispatch(:create, params)
  end

  def update(params) do
    dispatch(:update, params)
  end

  def dispatch(action, params) do
    table = Application.fetch_env!(:my_app, :routing_table)

    [_, module, function] =
      Enum.find(table[action], fn [pattern, _, _] ->
        params.path =~ pattern
      end)

    apply(module, function, [params])
  end
end

config/runtime.exs の定義です。

import Config

config :my_app, :routing_table, %{
  create: [
    [~r"^/foo/", MyApp.Foo, :create],
    [~r"^/bar/", MyApp.Bar, :create],
    [~r"^/baz/", MyApp.Baz, :create],
    [~r".*", MyApp.Error, :create]
  ],
  update: [
    [~r"^/foo/", MyApp.Foo, :update],
    [~r"^/bar/", MyApp.Bar, :update],
    [~r"^/baz/", MyApp.Baz, :update],
    [~r".*", MyApp.Error, :update]
  ]
}

抜き身の Map を使う素朴な実装で、ライブラリなどで利用する仕組みとしてはあまりよいものではありません。 それでも、冒頭の話のようにジャンパピンとして割り切れば悪くはなさそうです。