Help us understand the problem. What is going on with this article?

次世代プログラミング言語ElixirとElmでCRUD

More than 3 years have passed since last update.

はじめに

最近気になっている、これから盛り上がる、もしくは盛り上がり始めている2つの言語でミニマルなCRUDをするデモを作ってみました。全体を説明しようとすると非常に長くなってしまうため、かいつまみながら言語やコードの説明をしていきたいと思います。Elmは一年近く触り続けていますが、Elixirは今回始めて触ったため、誤った説明がありましたらご指摘お願いします。

crud.gif

環境

今回使用した環境を以下に列挙します。今回選んだ言語の特徴として、何かに特化したDSLに近い特徴を持つ言語を選択しました。特化した言語は、汎用言語のようにどんな処理でも書くことは出来ませんが、その代わりに得意な分野ではライブラリ・フレームワーク等を入れずとも言語の特徴自身で様々なことができます。今回は、簡単な例ではありますが、Webサーバとフロントエンドのプログラミングをするのにライブラリを、ほとんど使っていないことがわかります。

バックエンド

ElixirはErlangにRubyライクな文法を採用した言語で、並列処理に特化した言語でアクターモデルをプログラミングの主体に置いています。Erlangの仮想マシン(BEAM)上で動きErlangの財産が使えます。今回Elixirを使ってみて、Erlang/OTPを使えること自体も素晴らしかったのですがDBMSが何の設定も無く2種類(ETS, Mnesia)も使え、REPL上で即試せるということが衝撃的でした。大抵の言語では環境構築が憂鬱になるのですが、Elixirは何も躓くことなくプログラミングに移れたことが好印象でした。

  • Elixir
    • Cowboy
      • Erlang/OTPによるHTTPサーバ
    • Plug
      • Webサーバ接続のためのモジュール
    • CorsPlug
      • CORSをするためのPlugモジュール
    • Mnesia
      • Erlang組込み分散DBMS
    • Poison
      • Jsonライブラリ

フロントエンド

Elmは見ての通り標準のライブラリで済んでしまいました。また、Reduxの元となる言語で、The Elm Architectureと言うフロントエンドアプリケーションを書くための構造を提供し、それに則るだけで綺麗にプログラミングできるのが特徴です。また、Bulmaという、CSSのみに依存するだけでマテリアルデザインができてしまうフレームワークも気に入っていて採用しました。BootStrapを普段採用している皆さんも是非試してみてください。今回、ページ全体のPadding以外、CSSを一行も書いていません(コンポーネントに状態が無いためSPA向きです。)!

コード解説

ここからはコードを解説していきます。文法の説明は既存の文献に任せるとして、やってみて素晴らしい!と思った部分をかいつまんで説明していきます。

バックエンド

今回バックエンドのコードでは、3つのモジュールからなります。

  • Application
    • メインモジュール、スーパバイザの役割を持つ
  • Router
    • HTTPのルーティングを行うモジュール
  • Repository
    • DBMSと通信するためのモジュール、GenSever

Elixirは今回、始めて触れた言語だったためPlugを利用したルーティング処理はつまずきながらも見よう見まねでなんとかなりましたが、Repositoryをどう使用するかが悩みの種でした。オブジェクト指向言語ではないためシングルトンオブジェクトを作り、ルータに差し込むわけにはいきません。そこで役に立ったのはOTPのGenServerでした。GenServerはクライアント/サーバの良くありがちなパターンを実装するためのもので、これを利用することでRepositoryのためのアクターを簡単に作ることができ、このRepositoryアクターとRouterのアクターをApplicationの子供としてしまうことで、実に簡単に悩みを解消してしまいました!いつもフレームワークのDBについての実装ベストプラクティスを探って解決する自分にとって、言語の標準機能であっさり解決できてしまうのは非常に感動を覚えました。

Untitled Diagram.png

これがApplicationのモジュールです。Webサーバを立ち上げ、Routerモジュールに実装を任せ、RepositoryのGenServerをSupervisorのworkerとして起動し、最後にApplication自身をSupervisorとして起動しています。

defmodule Crud.Application do
  use Application

  def start(_type, _args) do
    children = [
      Plug.Adapters.Cowboy.child_spec(:http, Crud.Router, [], port: 8000),
      Supervisor.Spec.worker(Crud.Repository, [])
    ]

    opts = [strategy: :one_for_one, name: Crud.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

Repositoryはstart_link関数でDB(mnesia)の起動とテーブルの定義、stateの初期化(id = 1)をおこないます。前半部のas_entity_listからkillまでが、他モジュール(Router)から使われるインターフェースの役割をしています。GenServer.castという関数を呼び出しているのがわかります。その関数は、後半部のhandle_call関数をシンボルを使いパターンマッチ呼び出しをしています。基本的にはmnesiaの関数を呼び出しテーブルを書き換え、最後にstateの書き換えがあれば、その処理をおこないます。ScalaのAkkaを触れていた為、同じ感覚で実装ができました。Akkaを触れていない人でもオブジェクト指向のインターフェースと内部実装をおこなう感覚でできると思います。並列処理がおこなえるメッセージパッシングスタイルなオブジェクト指向と捉えるのが良いと思います。

defmodule Crud.Repository do
  use GenServer

  def init(args) do
    {:ok, args}
  end

  def start_link() do
    :mnesia.start()
    :mnesia.create_table(Person, attributes: [:id, :name, :job])
    :mnesia.add_table_index(Person, :id)
    GenServer.start_link(__MODULE__, %{id: 1}, name: __MODULE__)
  end

  def as_entity_list() do
    GenServer.call(__MODULE__, :as_entity_list)
  end

  def store(person) do
    GenServer.cast(__MODULE__, {:store, person})
  end

  def update(id, person) do
    GenServer.cast(__MODULE__, {:update, id, person})
  end

  def delete(id) do
    GenServer.cast(__MODULE__, {:delete, id})
  end

  def kill do
    GenServer.cast(__MODULE__, :kill)
  end

  ####################
  def handle_call(:as_entity_list, _from, state) do
    persons = :mnesia.dirty_match_object({Person, :_, :_, :_})

    persons_map =
      Enum.map(persons, fn {Person, id, name, job} -> %{id: id, name: name, job: job} end)
      |> Enum.sort_by(fn person -> person.id end)

    {:reply, persons_map, state}
  end

  def handle_cast({:store, person}, state) do
    :mnesia.dirty_write({Person, state.id, person["name"], person["job"]})

    {:noreply, %{id: state.id + 1}}
  end

  def handle_cast({:update, id, person}, state) do
    :mnesia.dirty_write({Person, id, person["name"], person["job"]})

    {:noreply, state}
  end

  def handle_cast({:delete, id}, state) do
    :mnesia.dirty_delete({Person, id})

    {:noreply, state}
  end
end

RouterはPlug Routerを利用することでRubyのSinatraライクに記述することができました。plug()関数を利用することで機能を差し込んで行く形でパワフルで便利だと感じました。一方plug()の順番等を間違えると突然死んでしまうので、そこは仕様を把握しないといけないため不親切と感じました。また、先ほど作ったRepositoryが既にApplication Supervisorの子要素、つまり兄弟同士のため、インターフェースを呼び出すだけでDBの書き込み、読み込みが出来てしまうのが本当に便利だと感じました。

defmodule Crud.Router do
  use Plug.Router
  alias Crud.Repository, as: Repository

  plug(:match)

  plug(CORSPlug, origin: ["http://localhost:3000"])

  plug(
    Plug.Parsers,
    parsers: [:json],
    pass: ["application/json"],
    json_decoder: Poison
  )

  plug(:dispatch)

  # [ {id: 1, name: "Mike", job: "WD"}, {id: 2, name: "John", job: "PG"} ]
  get "/persons" do
    params = Repository.as_entity_list()

    send_resp(conn, 200, Poison.encode!(params)) |> halt
  end

  # {name: "John", job: "SE"}
  post "/persons" do
    Repository.store(conn.body_params)
    send_resp(conn, 201, "") |> halt
  end

  # {id: 1, name: "Mike", job: "WD"}
  put "/persons/:id" do
    Repository.update(String.to_integer(id), conn.body_params)
    send_resp(conn, 200, "") |> halt
  end

  delete "/persons/:id" do
    Repository.delete(String.to_integer(id))
    send_resp(conn, 200, "") |> halt
  end

  match(_, do: send_resp(conn, 404, "Not Found!") |> halt)
end

最後に、標準でパワフルな並列処理がわかりやすいアクターモデルで出来てしまうElixirでしたがエラーのほとんどがパターンマッチ失敗のエラーで、詳しい情報が出ないため型が欲しいな・・・と思ってしまったこととErlangとElixir共にドキュメントが充実しておらず、非常に苦労が強いられました。私自身もこういった記事等で少しでも協力していければな、と思いました。

フロントエンド

Elmは普段記事を書いているため、多くの解説などはおこないません。改めて触った感想ですが、Elixirを終えてから触った分、型のサポートが非常に嬉しく感じました。どうしてもWebAPIと通信する部分では実行時エラーが起きがちですが、そもそもサーバの実装ミスが多かったため、Elm側の問題ではないケースがほとんどでした。

実際のHTTPリクエストをおこなう箇所は以下のようなコードで、シンプルかつ(多少ボイラプレート気味ですが)型安全にJSON <-> 内部データとの変換がおこなえるのが嬉しい箇所でした。

getPersons : Cmd Msg
getPersons =
    let
        url =
            "http://localhost:8000/persons"
    in
        Http.send GetPersonsResponse (Http.get url decodePersons)


decodePerson : Decode.Decoder Person
decodePerson =
    Decode.map3 Person
        (Decode.at [ "id" ] Decode.int)
        (Decode.at [ "name" ] Decode.string)
        (Decode.at [ "job" ] Decode.string)

viewはコンポーネント分けなどはせず、単なる関数として分割できるため、シンプルに見通しが良い結果となりました。

view : Model -> Html Msg
view ({ persons, name, job, editID, editName, editJob, error } as model) =
    div [ class "container" ]
        [ addForm name job
        , errorMsg error
        , personList persons editID editName editJob
        ]


errorMsg : ErrorMsg -> Html Msg
errorMsg addError =
    case addError of
        Just e ->
            div [ class "notification is-danger" ] [ text e ]

        Nothing ->
            empty


addForm : Name -> Job -> Html Msg
addForm name job =
    div [ class "columns" ]
        [ div [ class "column is-one-quarter" ] [ input [ class "input", type_ "text", value name, placeholder "Name", onInput NewName ] [] ]
        , div [ class "column is-one-quarter" ] [ input [ class "input", type_ "text", value job, placeholder "Job", onInput NewJob ] [] ]
        , div [ class "column" ] [ a [ class "button is-medium is-success", onClick NewPerson ] [ text "Add" ] ]
        ]


personList : List Person -> EditID -> Name -> Job -> Html Msg
personList persons editId editName editJob =
    table [ class "table" ]
        [ thead []
            [ th [] [ text "ID" ]
            , th [] [ text "Name" ]
            , th [] [ text "Job" ]
            , th [] []
            , th [] []
            ]
        , tbody [] <|
            List.map
                (\person ->
                    personItem editId editName editJob person
                )
                persons
        ]


personItem : EditID -> Name -> Job -> Person -> Html Msg
personItem editID editName editJob { id, name, job } =
    let
        item =
            tr []
                [ td [] [ text <| toString id ]
                , td [] [ text name ]
                , td [] [ text job ]
                , td [] [ a [ class "button", onClick <| EditStart id name job ] [ text "Edit" ] ]
                , td [] [ a [ class "delete is-large", onClick <| DeletePerson id ] [] ]
                ]
    in
        case editID of
            Just eid ->
                if id == eid then
                    tr []
                        [ td [] [ text <| toString id ]
                        , td [] [ input [ class "input", type_ "text", value editName, onInput EditName ] [] ]
                        , td [] [ input [ class "input", type_ "text", value editJob, onInput EditJob ] [] ]
                        , td [] [ a [ class "button is-primary", onClick EditEnd ] [ text "Confirm" ] ]
                        , td [] [ a [ class "delete is-large", onClick <| DeletePerson id ] [] ]
                        ]
                else
                    item

            Nothing ->
                item


empty : Html Msg
empty =
    text ""

viewを描画するためのアプリケーション全体の状態、Modelは以下のような定義になっています。type aliasを利用することで、後から見直しても型で混乱することはありません。

type alias Model =
    { persons : List Person
    , name : Name
    , job : Job
    , editName : Name
    , editJob : Job
    , editID : EditID
    , error : ErrorMsg
    }


init : ( Model, Cmd Msg )
init =
    ( { persons = []
      , name = ""
      , job = ""
      , editName = ""
      , editJob = ""
      , editID = Nothing
      , error = Nothing
      }
    , getPersons
    )


type alias Person =
    { id : ID, name : Name, job : Job }


type alias ID =
    Int


type alias PostedPerson =
    { name : Name, job : Job }


type alias Name =
    String


type alias Job =
    String


type alias ErrorMsg =
    Maybe String


type alias EditID =
    Maybe ID

ViewからMsgと呼ばれるイベントをキャッチして、Modelを書き換えるupdate関数は、少し長くなったため、記事では割愛させていただきます。

近年、肥大化しがちなフロントエンド実装を型安全に、かつ、何のライブラリ・フレームワークに依存すること無く書けるのは非常に強力ではないでしょうか。今回はWebAPIとの通信が主でしたが、ビジネスロジックが多く入り込むような場面ではテストが書きやすく、さらに強力な言語機能が役立ちます。近頃では一番の推し言語になります。ぜひ触ってみてください。

elm-jp
主に日本で活動する Elm 利用者のコミュニティです。
https://elm-lang.jp
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away