Chienomi

実験的なElixir製チャットアプリ Exolyte

開発::webapp

私は今、ちょっとやろうとしていることがある。

これについてはまだオープンにしていないのだけれども、そのやろうとしていることのために、新しいプログラミング言語を習得している。

Elixirだ。

Elixir自体はAkkomaを運用しはじめた時期から触っていて、軽く勉強もしたのだけど、正直本がイマイチで学びにくくて塩漬けしていた。

それが、「やろうと思い立ったこと」と「学ぼうと思っていたプログラミング言語」がうまく噛み合ったことから、本格的にElixirを学び始めたというわけだ。

そして、Elixir/Phoenixの機能を色々触ってみるという目的で、Exolyteというチャットアプリを作った。

Elixirについて

Elixirというプログラミング言語を知っている人は、多分それなりにプログラミング言語について興味がある人であり、Chienomi読者といえども知らない人のほうが多いだろう。

まず前提として、Erlangという関数型プログラミング言語がある。

関数型プログラミング言語は、かなり強く数学的知識と結びついていることが多く、数学が苦手な人にはとっつきにくい傾向がある。 ただ、その関数型言語でも関数型の思想に忠実なタイプと、一般的なプログラミング言語に近い書き心地を求めたものとで振り具合が結構分かれる。 ガチガチの関数型プログラミング言語といえば、やはりHaskellだろう。 そう、私にはとてもじゃないが書けないやつだ。

それと比べ、Erlangはかなり実用寄り。 関数型プログラミング言語の中では馴染みやすい部類になる。

Erlangはスウェーデンの通信機器メーカーのErricsonの社内言語だった。 電話交換機のために作られた言語だ。

1986年に誕生し、1998年にオープンソース化された。

その最大の特徴は並列処理だ。 並列処理単位として「プロセス」があり、シンプルなプロセス間通信によって簡潔かつ強力に並列処理ができる。 しかも軽量かつ高速で、プロセスがエラーにより停止したとしても全体には影響を及ぼさないようにできて いる。

私は、このErlangのプロセスが現在この世界にある中で最も理想的な並列実行モデルだと思っている。

Erlangはコードをバイトコードにコンパイルし、Erlang VM “BEAM”上で動作させる。 このBEAMの性能の高さが、Erlangを支えている大きな要素だ。 ちなみに、逐次実行速度はスクリプト言語連中よりは速いが、そんなに速くない。 が、簡単に並列処理が書けること、Erlangでコードを書くことが並列処理可能なコードを書くことに非常に近いことから、同じ課題に対するアプローチとして並列処理ありきで書けるため、条件次第では尋常じゃないほど速い。

とはいえ、関数型プログラミング言語なので、それなりにクセはある。 「関数型プログラミング言語の中では書きやすい」からと言って、万人に馴染みやすいわけではないのだ……

そんなErlangを気持ちよく書けるようにしたのがElixir。 ElixirはBEAM上で動作するバイトコードにコンパイルするプログラミング言語で、Erlangの機能を使うことも可能である。

Elixirによって、関数型プログラミング言語としては驚異的に書きやすいプログラミング言語が誕生した。 それでもまだクセはあるが、これくらいであれば受け入れられる人は多いだろう。

Elixirとウェブ

並列実行を得意とすることから、ウェブアプリケーションはElixirの得意分野だ。 非常に強力なウェブアプリケーション向けインターフェイスのPlugを標準搭載。条件にもよるが、非常に書きやすい。

そして、Elixirのウェブアプリケーションフレームワークとして有名なのがPhoenixだ。

Phoenixは若干のRuby on Railsみのあるウェブアプリケーションフレームワークで、特に特徴的なものとしてWebSocketを用いてリアルタイム更新が可能なLiveViewがある。

基本的にはElixirでウェブアプリケーションを書くというと、PhoenixとEctoを使うのが基本となる。 EctoはElixirのPostgreSQLドライバで、Elixirは割とデータベースライブラリに乏しいので、Ectoを使わないとなるとちょっとがんばらないといけない面もある。 ちなみに、PhoenixにはEctoに依存している機能が結構ある。

が、Plugが強力なのでPhoenixがないとウェブアプリケーションが書けないかというとそんなことはないし、ExolyteはPhoenixを使っているがEctoは使っていない。

Plugはウェブアプリケーションを書くのがかなり楽になる機能が多く、Phoenixはさらに便利な機能がある。 Phoenixで楽に書いたアプリケーションが結構現代的なものになるのもポイント。 ある程度HTML(というか、JSXみたいなもの)を書く必要はあるが、Tailwind CSSとDaisyUIが使えるようになっており、結構リッチな環境で書ける。

逆に言うと、APIサーバーとして使うだけであればPhoenixはなくてもいいなという感じ。

初歩的なElixirコード

恒例の三山崩し。

defmodule MountainsGame do
  def change_player(player) do
    rem(player + 1, 2)
  end

  def gameloop(mounts, player) do
    IO.puts("Player #{player + 1}'s turn.")
    IO.inspect(mounts)

    mt_input = IO.gets("Take from [1-3]>")
    {mt_index, _} =
      mt_input
      |> String.trim()
      |> Integer.parse()

    with true <- mt_index in 1..3,
         count when count > 0 <- Enum.at(mounts, (mt_index - 1)) do
      takable_max = min(count, 3)
      vol_input = IO.gets("How many take[1-#{takable_max}]")
      {vol_value, _} =
        vol_input
        |> String.trim()
        |> Integer.parse()
      if vol_value in 1..takable_max do
        index = mt_index - 1
        current = Enum.at(mounts, index)
        mounts_updated = List.replace_at(mounts, index, current - vol_value)

        mounts_sum = Enum.sum(mounts_updated)
        if mounts_sum == 0 do
          gameover(player)
        else
          next_player = change_player(player)
          gameloop(mounts_updated, next_player)
        end
      else
        IO.puts "You cannot take #{vol_value}"
        gameloop(mounts, player)
      end
    else
      _ ->
        IO.puts "You cannot take from #{mt_index}"
        gameloop(mounts, player)
    end
  end

  def gamestart(mounts) do
    gameloop(mounts, 0)
  end

  def gameover(player) do
    winner = change_player(player)
    IO.puts "Game is over"
    IO.puts "Player #{winner + 1} won!"
  end
end

mt = [Enum.random(1..20), Enum.random(1..20), Enum.random(1..20)]
MountainsGame.gamestart mt

結構馴染みやすいと感じる人が多いのではないだろうか。

ただ、一般的なプログラムを一般的な書き方をすると、あんまりElixirの魅力は感じられない。 だから、このコードはElixirらしさはあんまりない。

Exolyteで試したかった要素

そもそも私はElixirの学習途中だし、Elixirを書き慣れることや、Phoenixの機能に触ってみること自体が目的だったのだが、Exolyteで試したかったこととしては

  • Phoenix
  • LiveViewと非LiveView
  • JSON API
  • send_file/2
  • CubDB (Elixir向けのKVS)
  • セッション管理

といったところ。

Exolyteのデータ構造

ExolyteはCubDBとJSONファイルの2種類のデータベースを使用する。

CubDB

CubDBはユーザー情報とチャンネル情報を収録している。

ElixirのMap1あるあるなのだけど、キーにタプルを使う。 そして、この「キーにタプル」がElixirらしさ全開なのである。

lib/exolyte/user_db.exを見てみよう。 まずはput_user/3

def put_user(id, name, plain_pw) do
  db = Exolyte.DB.get_db()
  normalized_id = String.downcase(id)
  hashed_pw = Bcrypt.hash_pwd_salt(plain_pw)
  user_color = Enum.random(@name_colors)

  CubDB.put(db, {:user, normalized_id}, %{
    id: normalized_id,
    display_name: name,
    password_hash: hashed_pw,
    user_color: user_color
  })
end

ちなみに当たり前のように使っているが/3というのは引数の数を指している。 Elixirは引数の形式が違う同名の関数を定義することができる。 というか、引数の形式が違うようで違わない関数も定義できるのだけど、そこはちょっと複雑になってしまう。 なので、基本的には引数の数で区別し、逆に言うと引数の数が違う同名の関数は別物なのである。 あと、ここでは引数のことはアリティと呼ぶ。

話を戻そう。CubDB.put/3に渡している引数は

  1. データベース
  2. タプル
  3. マップ

であり、

  1. データベース
  2. キー
  3. バリュー

である。そう、キーがタプルなのだ。 そして当たり前だが、{:user, "foo"}{:channel, "foo"}は別のキーだ。

で、Elixirで重要になるのがパターンマッチ。 まあこれは基本的には束縛する変数名との関係で

{type, name} = {:user, "John"}

とかできるのだが、単なる多重代入ではなく、

{:user, name} = {:user, "John"}

みたいなことができる。 これは、name"John"が代入されるわけだが、じゃあ

{:user, name} = {:nick, "Jack"}

とかやったらどうなるのかというと、エラーになる。 で、そういう仕組みで条件分岐もできるし、アリティの数が同じでもマッチする関数が呼ばれるみたいなこともできる。

で、channel_db.exではこんなことをしている。

def create_channel(id, name) do
  db = Exolyte.DB.get_db()

  channel_data = %{
    id: id,
    name: name,
    description: "",
    users: MapSet.new(),
    latest: 1
  }

  :ok = CubDB.put(db, {:channel, id}, channel_data)

  try do
    Exolyte.ChannelLog.create_log_dir(id)
    :ok
  rescue
    e in File.Error ->
      CubDB.delete(db, {:channel, id})
      {:error, {:log_dir_failed, e}}
  end
end

ユーザーもチャンネルも同じCubDBに入れられている。 けど、さっき言ったように{:user, "foo"}{:channel, "foo"}は別のキーだからごっちゃになることはない。 そしてlist_users/0では

def list_users() do
  db = Exolyte.DB.get_db()

  CubDB.select(db, keys: :all)
  |> Enum.filter(fn
    {{:user, _id}, _value} -> true
    _ -> false
  end)
end

という形でフィルタしている。 {:user, _id}_idは変数への束縛(ただし捨てる)だけれど、タプルの1個目は:userである必要があるから、ユーザーのエンティティだけが拾われる。

まぁ、なんかそういうふうに使うものだと言うから私も従ったけど、正直ユーザーとチャンネルで分ければいいじゃん、と思っている。

ユーザーとチャンネルの情報を抱えているCubDBだが、書き込み操作のほとんどはCLIで行うようにしている。 ただ、CubDBは並列アクセスに対応していないので、CLIを使って書き込んでしまうとそれを反映するのにサーバーの再起動が必要になる。 だから結局はサーバーに操作用のAPIを生やすしかないなと感じている。

JSONファイル

JSONファイルは

  • チャットログ
  • パスワードリセット

の2つで使っている。

JSONファイル形式にする大きなメリットは、send_file/2を使ってストリームで返せること。 認証が必要ないなら、Nginxで静的配信させても良い。

最初はそういうデザインを採用する予定だったのだが、実際に作ったらそうはならなかった。

パスワードリセットのほうはそもそもデータ的にクライアントに露出すべきでないものなので当たり前ではあるのだけど、チャットログに関してもLiveViewを採用したためにログをクライアントが直接受け取る必要がなく、サーバー内で完結する形になった。

JSONファイルベースのデータベースは非常に単純で

def create_reset_link(user) do
  link_uuid = UUID.uuid4()
  path = Path.join([@reset_db, "#{link_uuid}.json"])
  expire = System.os_time(:second) + 60 * 60 * 24

  data = %{
    user: user,
    expire_at: expire
  }

  File.write!(path, Jason.encode!(data))

  link_uuid
end

Elixir 1.8には外部ライブラリのJasonを使わずともJSONがあったりするのだが、pretty printができなかったので一旦Jasonを使用する形にした。

何を解説しようか。LiveViewの違いとか?

いつもならdevelカテゴリはテクいことをしたとか、変わったことをしたとかでネタにするんだけど、今回の場合私がそもそもElixirにそこまで馴染んでないから特に高度なことはしていないし、コードとしても実験要素が強くてちゃんと考慮してない部分が多いし、私の書いたコードがElixir的に良いコードかどうかの判定をするだけの知識がないので割と解説に困る。

まぁ、強いていうならLiveViewと非LiveViewの違い?

非LiveViewの場合、routes.exからコントローラを呼び出す。 ここで主役になるのはconnreqressessionを合体させたようなもので、Plugの本体といってもいい。 まぁ、ウェブアプリを書いたことがある人ならだいたい分かりやすいと思う。

ビューはlib/myapp_web/controllers/*_htmlの下に.heexのファイルを置く感じ。

対してLiveViewはlib/myapp_web/live/*_live/*_live.exに書く感じ。 こっちのメインはsocket

socketconn同様にassign, assignsという関数が使える。 assignsがテンプレートの中で見えるんだけど、LiveViewの場合はsocketassignsが変化するとそれに合わせて差分が送られて描画される。

あと、LiveViewの場合は.heexファイルを置くんじゃなく、render/1関数に書くというのも大きな違い。

APIを作るんじゃなく、heexの中でイベントを発生させて、そのイベントコールバックを書くという形式になるのも結構違う。

ページ数自体が多いなら非LiveViewのほうが明らかに楽なんだけど、操作の多いSPA指向ならLiveViewのほうが楽。 ただどうしてもLiveViewはサーバー主体になるから、クライアント主体でやりたい場合はPhoenix自体が自由度を下げている感じがしてあんまり歓迎できないという問題もある。

まぁ別に、Phoenixを使っていてもビューを使うことは強制ではないから、切り捨ててしまえばあんまり気にならないかもしれないけど。

Phoenixの感想

Phoenixを使う場合、EctoよりもLiveViewのほうが重要だと感じた。

Ectoを使うなら書きやすい、という面はなくはないのだけど、Railsほどべったべたにコード自体をRDBMSに寄せるようにはできていないため、恩恵は意外と限定的。

それよりもLiveViewが特徴的で、機能的にもかなり充実している。 だからLiveViewとDaisyUIをフルに駆使したアプリを作る場合はPhoenixの恩恵を強く感じる。

これはRailsで作るものと比べると手の込んだアプリを作ることを想定している話になり、「手の込んだ感じのするアプリを結構簡単に作れる」というところがPhoenixの魅力だと思う。

基本的には「簡単に作れる」が魅力の中心で、LiveView使うとクライアント的にもサーバー的にも結構重くなるので、同時接続が多いとしんどい。 パフォーマンスチューニングを含めてしっかり最適化して作り込みたいという場合にはそんなに向いてはいない。 クライアント側の重さは現代の基準で言えば全然許容範囲なのだろうけれども、私の基準で言うと厳しい。

もっともそれなりの計算リソースを使える端末で使い、同時接続はそれなりという条件下であればLiveViewでアプリを作り込むのも全然アリ。 私が「LiveViewが重い」というのは私の「既存フレームワークなんか使わずに最適化して書く」という基準から言っているので、別に他のフレームワークよりも重いということを言っているわけではない。

一言でまとめるなら「めっちゃ便利だけどある程度書く必要はあるし、動作モデルやパフォーマンスまで最適化するのには向いてない」というのがLiveView。

作るのが楽というのは大きい。簡単なアプリなので手間はかけたくない、でも見栄えが欲しい、差分更新やユーザー管理といった作るのが面倒な機能が必要、みたいなケースでは非常に楽。 

Exolyteについて

実験としての意味を一旦置いておくとして、Exolyteというアプリの話をしよう。

ExolyteはSlackとかDiscordとかと、LINEとかTelegramとかの中間みたいな感じのチャットアプリ。 実際はLINEとかTelegramとかにだいぶ寄ってる。

まずユーザーとチャンネルという概念がある。 チャンネルはルームみたいなもので、チャンネルには所属ユーザーという概念がある。 ルームに参加できるのは所属ユーザーのみなので、ログは公開されない。

ユーザーは複数のチャンネルに所属できる。 チームみたいな機能はないので、チャンネルをまとめる概念はなくて、あくまでユーザーがどのチャンネルに所属してるかという話。 実装的には逆で、個々のチャンネルにどのユーザーが所属しているかの視点になっている。

けれど、チャンネルにアクセスしないとチャットを始められないというのはかなり不便だから、マイページでは全チャンネルを探索して所属チャンネルを一覧するようになっている。

データとしては大変シンプルで、ユーザーが持っている情報は

  • ユーザーID
  • 表示名
  • (パスワード)

チャンネルが持っている情報は

  • チャンネルID
  • チャンネル名
  • 所属ユーザー

だけ。

アバターアップロードとかは運営上めんどくさい要素が多すぎるのでナシ。

UI的にはユーザーを編集する画面も、チャンネルを編集する画面もない。 ユーザーの「色」というのは、ユーザーの表示名のカラーコードを、アカウント作成時に候補の中からランダムで設定するもの。ユーザーは選べない。

ユーザーやチャンネルの操作はCLIで行うのだけど、CubDBが別プロセスで変更されたものを反映しないため、それをやった後はサーバーの再起動が必要になる。 まともに運用するなら、管理用APIをウェブ側に生やす必要がある。

ダイレクトメッセージはない。

色々試してみるというのが主目的なので、手抜かり要素は多い。 一番致命的なのは、「チャンネルに参加しているときにチャンネルにユーザーが追加されてそのユーザーが発言すると落ちる」だと思う。 やればいいのだけどやってない。APIを生やす必要があるから。

チャット表示はバブル形式。LINEとかと同じやつ。 ただし、幅の制限はないから横長の内容を発言するとビューポートいっぱいまで伸びる。

メッセージはGFMに対応している。

チャット入力部分はEnterで改行、Ctrl+Enterで送信タイプ。IMEの入力を破壊しないように注意している。 標準では1行だけど、複数行入力してると伸びる。

ファイルのアップロードやメンション機能、スレッド機能はない。 昔ながらのテキストだけのチャット。

過去ログはスクロールしてると辿れる。 検索機能はない。

チャット部分はLiveViewで作られていて、WebSocketでやりとりする。 そういう仕組みなので、あんまり軽くない。ただ、PubSub経由で配信されるので、他のユーザーの発言はすぐに反映される。

Exolyteの今後

基本的にちゃんとしたアプリに成長させる気はない。 実験なので。

でも、まかり間違って誰かが熱心に使い始めちゃったりするとか、私が何らかの理由で実用するとかいうことがあったらちゃんとしたものになるかもしれない。

ただ、こういう目的にはDiscordとかRevoltとかSlackとかMattermostとか、もっと便利なものが色々あるので、需要はないだろうなと思っている。 特に、モバイルアプリがないというのは痛いと思うし、現状だと通知もない。

Exolyteを使ってみたい

基本的には自分でホストしてね、ということになる。 公開登録できるほどの完成度ではないし、オープンではないチャットの運営はかなり手間をかけてやらないといけないのでやる気にならない。 課金してくれるなら別だけど……

自分で試すだけならダウンロードしてmixしていけばいいだけだから簡単。

私の知り合いなら言ってくれれば対応しなくもないけど、そもそも参加する全員分のアカウントを作る、チャンネルを作る、チャンネルに参加させるというのが私の手動でやる必要があるからあんまり現実的じゃない。

まぁ、結局のところ私の実験が目的だから、仕方ないねという感じ。 ここまで作ったんだから公開までやればいいのにと思うかもしれないけど、公開登録できるレベルまで仕上げるには今までの倍以上の手間がかかる上に、運用負荷もあるからね。


  1. 連想配列的なデータ構造↩︎