技術メモ

プログラミングとか電子工作とか

AWS Lambdaでrequestsなどのライブラリを使う

f:id:ysmn_deus:20190318182949p:plain

どうも、靖宗です。
たまにはAWSの話なんかも。

AWSでpipでインストールされるライブラリを使用する

よくある欲求で、なおかつウェブ上に結構ドキュメントはあると思いますが、自分のメモ用に。
基本的にAWS Lambdaでは標準ライブラリ以外は使えませんが、zipファイルに固めてアップロードすれば利用できることが知られています。

基本的にはvenv

仮想環境で開発されてる方には「当たり前やろ!」と怒られそうですが、Pythonのようにバージョンに左右される言語を利用するときは開発は極力venvなど環境をローカルとは切り離して開発するのが未然にトラブルを防ぐ手段です。
venvで必要なライブラリだけインポートして、それらをzipに固めます。venvは導入済みとし、今回はrequestsモジュールを使う関数を作成します。

(venv) project_name>pip install requests

とりあえず動かす

Lambdaで実行したいファイルを作成します。一応ここではindex.pyとしておきます。

import re
import requests


INFO_URL = "https://www.python.org/"


def lambda_handler(event, context):
    response = requests.get(INFO_URL)
    math_ob = re.search(r'<div class=\"shrubbery\">([\s\S]*?)</div>', response.text)
    if math_ob:
        return {
            'statusCode': response.status_code,
            'body': math_ob.group(1)
        }
    else:
        return {
            'statusCode': 500,
            'body': ""
        }

引数などにかかわらずpythonの公式サイトのニュースを取得してくるような関数になってます。
一応動作確認しておきます。同フォルダでPythonを仮想環境で実行。

(venv) >python
Python 3.7.2 ~~~
Type "help", "copyright", "credits" or "license" for more information.
>>> from index import *
>>> lambda_handler(None, None)
{'statusCode': 200, 'body':
...
>>>

正常に動作すればとりあえずヨシ。(ホントはテストとかを入れときたいですが、とりあえずこれで行きます。)

必要ファイルをローカルにインストール

します。まずはrequirements.txtを作成しておきます。

(venv) >pip freeze > requirements.txt

requirements.txtからローカルにライブラリをインストールします。

(venv) >pip install -r requirements.txt -t .

これを実行するとローカルに色々ファイルが出てくると思います。
ここまで来れば、後はzipに固めてLambdaにアップロードするだけです。MacLinuxではzipコマンドがあるかと思いますが、Windowsではないのでなにがしかの方法で圧縮して下さい。
なお、venvディレクトリは不要です。(サイズが大きいので間違えないように)
本来であればライブラリ名-バージョン-dist-infoみたいなディレクトリも要らないと思いますが、そこまでファイルサイズ大きくも無いですし、手間なら一緒に圧縮しても良いと思います。

Lambdaにアップロード

zipに固めたファイルをアップロードします。既存のLamndaか、新しく作ったLambdaの「関数コード」という所からアップロードします。

f:id:ysmn_deus:20190319000545p:plain

ランタイムをPythonにして置いて下さい(バージョンは適宜変更)。
また、上記のコードを利用していればハンドラの名前が異なっているので、ハンドラの名前も合わせて下さい。 (index,pyならindex..lambda_handler

適当なテストイベントを作成してテストを実行すれば、実行結果が表示されます。
きちんとライブラリが動作していれば実行結果は成功になり、ログに取得した情報が表示されるかと思います。

インストールされたライブラリをpipで-t .オプションを付けてインストールし直すことで、importなどの依存関係をそのまま引き継ぐことができます。

Phoenix入門 (番外編 LiveView その1 `mix phx.new`からThermostatが動くまで)

f:id:ysmn_deus:20190219130922p:plain

どうも、靖宗です。 なにやらLiveViewなる機能がちらほらツイッターで見かけまして、一応触っておこうと思いました。
たぶん未履修分野には被らないはず・・・

下記サイトを参考にしております。

dockyard.com

github.com

LiveView

名前からも感じますが、どうやらLiveViewというのはリアルタイムの双方向性のあるレンダリング機能のようです。via WebSocketsとあるのでWebSocketでうまいこと実装されているんでしょう。
JavaScriptでフロント+バックエンド(Phoenix)やと複雑やしメンテナンスたいへんやろ?というモチベーションのようです。

主な機能としては

  • リアルタイムで値をアップデートする
  • クライアント側でのデータのバリデーション(バックエンドでの評価が要らない)
  • 入力のオートコンプリート
  • リアルタイム性のあるゲーム

JavaScript無しでできるそうです。まじか。

Programming Model

書き方は大まかにはController+Viewと通常のHTMLレンダリングに近いようです。
おそらく今までレンダリングしていたテンプレートがモジュールと化し、そのモジュールで定義する関数が様々なイベントのコールバック関数になるイメージでしょう。

プロジェクト作成

なにはともあれ動かしてみます。新しいプロジェクトでやってみましょう。
新しいプロジェクト作る練習にもなります。名前はhello_liveviewにしました。

PS > mix phx.new hello_liveview

hello_liveview/config/dev.exsを編集します。

...
# Configure your database
config :hello_liveview, HelloLiveview.Repo,
  username: "elixir",
  password: "elixir",
  database: "hello_liveview_dev",
  hostname: "localhost",
  pool_size: 10

データベースのユーザー情報などは適宜合わせて下さい。
再起動してコンテナが落ちてたので起動。

PS > cd hello_liveview
PS \hello_liveview> docker start ph_psql
ph_psql

mix ecto.createでデータ作成。

PS \hello_liveview> mix ecto.create
The database for HelloLiveview.Repo has been created

これで起動まではいく筈。とりあえず起動。

PS \hello_liveview> mix phx.server
...

問題ナシ。
参考にしたブログでもPageControllerを編集してるのでそれに則る。

LiveViewの機能を有効にする

Phoenix側の依存関係を追記する

LiveViewは標準機能ではなくオプションとして提供されています。
ですので依存関係をmix.exsに追記してmix deps.getしてやる必要があります。

...
  # Specifies your project dependencies.
  #
  # Type `mix help deps` for examples and options.
  defp deps do
    [
...
      {:plug_cowboy, "~> 2.0"},
      {:phoenix_live_view, github: "phoenixframework/phoenix_live_view"} #コレを追記
    ]
  end
...

mix deps.getする。

PS \hello_liveview> mix deps.get
* Getting phoenix_live_view (https://github.com/phoenixframework/phoenix_live_view.git)
...

ソケット通信の設定

もうちょっと設定が必要なよう。エンドポイントにsigning_saltなるものが必要だそうで。mix phx.gen.secret 32を実行して得られたお塩(salt)をconfig.exsのエンドポイントの設定に追記してやります。

PS \hello_liveview> mix phx.gen.secret 32
SECRET_SALT(なんか変な文字列がでます)

config.exsのエンドポイントの設定に追記します。

...
# Configures the endpoint
config :hello_liveview, HelloLiveviewWeb.Endpoint,
...
  pubsub: [name: HelloLiveview.PubSub, adapter: Phoenix.PubSub.PG2], # ここのカンマつけ忘れてエラーでてた・・・
  live_view: [
    signing_salt: SECRET_SALT(さっき出力した文字列)
  ]
...

config :hello_liveview,句の一番最後に追記しました。

テンプレートファイルを有効にする

ついでにLiveViewのテンプレートは.leexになるそうなので、この設定も同じconfig.exsに書いときます。

...
config :phoenix,
  template_engines: [leex: Phoenix.LiveView.Engine]

一番最後に追記しました。

router.exのパイプラインに設定を追加する

お次にパイプラインに設定を追加しておきます。lib/hello_liveview_web/router.ex:browserパイプラインを編集します。

defmodule HelloLiveviewWeb.Router do
  use HelloLiveviewWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_flash
    plug Phoenix.LiveView.Flash
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    plug :put_layout, {HelloLiveviewWeb.LayoutView, :app}
  end
...

レイアウトに当てはめるplugを明示的に記載していますが、後述するコントローラからLiveViewを呼び出す場合は要らないと思います。

hello_liveview_web.exのviewとrouterでインポートの設定を追記

次にlib/hello_liveview_web.exのviewとrouterの箇所も編集します。

def view do
  quote do
    ...
    import Phoenix.LiveView, only: [live_render: 2, live_render: 3]
  end
end

def router do
  quote do
    ...
    import Phoenix.LiveView.Router
  end
end

LiveViewのレンダリング関数とルーティング機能のインポートを追記しました。
これは全てのViewでインポートされてしまうのでいちいちLiveViewの方で読み込んでも良いのかもしれませんが、LiveView多めのアプリケーションならこの手法で良いと思います。

Endpointの設定

まだ設定は続きます。(かなり手間なのでそのうちこの辺はmix phx.newのオプションとかになるんじゃないでしょうか?)
lib/hello_liveview_web/endpoint.exにLiveViewに使用されるソケット通信を追記します。

defmodule HelloLiveviewWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :hello_liveview

  socket "/live", Phoenix.LiveView.Socket,
    websocket: true
...

PhoenixGitHubではotp_app: :hello_liveviewは無いですが、Phoenixのデフォルトなので一応書いておきます。

LiveViewを入れとくフォルダの作成+ホットリロードの設定

LiveViewを入れておくフォルダlib/hello_liveview_web/liveを作成しておき、ホットリロードを有効にするためにconfig/dev.exsに追記しておきます。

...
# Watch static and templates for browser reloading.
config :hello_liveview, HelloLiveviewWeb.Endpoint,
  live_reload: [
    patterns: [
      ~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$},
      ~r{priv/gettext/.*(po)$},
      ~r{lib/hello_liveview_web/views/.*(ex)$},
      ~r{lib/hello_liveview_web/templates/.*(eex)$},
      ~r{lib/hello_liveview_web/live/.*(ex)$} #これ
    ]
  ]
...

JavaScript側の依存関係を追記する

以上でおそらくPhoenix自体の初期設定は終わりました。仕上げにレンダリングされるJavaScript周りに手を加えます。
バックで動いているJSの依存関係を編集します。assets/package.jsonを編集します。

...
  "dependencies": {
    "phoenix": "file:../deps/phoenix",
    "phoenix_html": "file:../deps/phoenix_html",
    "phoenix_live_view": "file:../deps/phoenix_live_view"
  },
...

上記依存関係を追記したらnpm installしておいて下さい。単純な事ですが僕はコレでハマりました(笑)

PS \hello_liveview> cd .\assets\
PS \hello_liveview\assets> npm isntall

ソケット通信開始の箇所を追記する

レンダリングされるJavaScript側からPhoenixへの通信を開始する箇所を追記します。
assets/js/app.jsの最後に下記を追記します。

import LiveSocket from "phoenix_live_view"

let liveSocket = new LiveSocket("/live")
liveSocket.connect()

これで初期設定は大体OKなはずです。

ThermostatViewの実装

試しにサンプルのThermostatViewを実装してみます。

追加の依存関係

ThermostatViewはcalendarというライブラリを使用しています。mix.exs:calendarの依存関係を追記しておきます。

...
  defp deps do
    [
      {:phoenix, "~> 1.4.1"},
      {:phoenix_pubsub, "~> 1.1"},
      {:phoenix_ecto, "~> 4.0"},
      {:ecto_sql, "~> 3.0"},
      {:postgrex, ">= 0.0.0"},
      {:phoenix_html, "~> 2.11"},
      {:phoenix_live_reload, "~> 1.2", only: :dev},
      {:gettext, "~> 0.11"},
      {:jason, "~> 1.0"},
      {:plug_cowboy, "~> 2.0"},
      {:phoenix_live_view, github: "phoenixframework/phoenix_live_view"},
      {:calendar, "~> 0.17.4"}
    ]
  end
...

mix deps.getをお忘れ無きよう。(忘れててもmixが教えてくれますが。)

CSSの追加

ThermostatのCSSを追加します。この編は素直にassets/cssに保存しておきます。
ただ、レイアウトが違うのかちょっと位置が微妙なのでassets/css/thermostat.cssのmargin-topだけいじります。

...
.thermostat {
...
  margin-top: 250px;
}
...

assets/css/app.cssでインポートしておきます。

@import "./phoenix.css";
@import "./thermostat.css";

LiveViewのルーティング方法は2種類

LiveViewのルーティング方法は2種類あります。
1個目はrouter.exからコントローラを介さずに直接レンダリングする方法で、liveマクロを利用します。

...
  scope "/", HelloLiveviewWeb do
    pipe_through :browser

    get "/", PageController, :index
    live "/thermostat", ThermostatView
  end
...

2個目は従来通りコントローラを介してレンダリングします。

...
  scope "/", HelloLiveviewWeb do
    pipe_through :browser

    get "/", PageController, :index
    get "/thermostat", PageController, :thermo
  end
...

たぶんコントローラを介してレンダリングする方が柔軟性があると思いますのでそちらで実装していきたいと思います。
故に:brouserパイプラインのplug :put_layout, {HelloLiveviewWeb.LayoutView, :app}は不要ですので消していただいても問題無いかと思います。

ルーティング設定

上記に従ってlib/hello_liveview_web/router.exを編集します。

defmodule HelloLiveviewWeb.Router do
  use HelloLiveviewWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_flash
    plug Phoenix.LiveView.Flash
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", HelloLiveviewWeb do
    pipe_through :browser

    get "/", PageController, :index
    get "/thermostat", PageController, :thermo
  end
end

コントローラの設定

めんどくさいのでlib/hello_liveview_web//controllers/page_controller.exを編集して使います。

defmodule HelloLiveviewWeb.PageController do
  use HelloLiveviewWeb, :controller
  alias Phoenix.LiveView

  def index(conn, _params) do
    render(conn, "index.html")
  end

  def thermo(conn, _params) do
    LiveView.Controller.live_render(conn, HelloLiveviewWeb.ThermostatView, session: %{})
  end
end

live_renderlive_render/2もインポートしてるので最後のマップは要らなさそうな気もするんですがなんか渡さないと怒られたのでlive_render/3を利用しています。
ちょっとこの辺は後ほど再確認したいところです・・・

index/2thermo/2を比較すると、関数名こそ違えどほぼ同じ様な構造になっているので抵抗感無く使えるかと思います。

LiveViewの追加

lib/hello_liveview_web/live/thermostat_live.exを作成し、LiveViewを作って行きます。

defmodule HelloLiveviewWeb.ThermostatView do
    use Phoenix.LiveView
    import Calendar.Strftime
  
    def render(assigns) do
      ~L"""
      <div class="thermostat">
  
        <div class="bar <%= @mode %>">
          <a phx-click="toggle-mode"><%= @mode %></a>
          <span><%= strftime!(@time, "%r") %></span>
        </div>
        <div class="controls">
          <span class="reading"><%= @val %></span>
          <button phx-click="dec" class="minus">-</button>
          <button phx-click="inc" class="plus">+</button>
        </div>
      </div>
      """
    end
  
    def mount(_session, socket) do
      if connected?(socket), do: Process.send_after(self(), :tick, 1000)
      {:ok, assign(socket, val: 72, mode: :cooling, time: :calendar.local_time())}
    end
  
    def handle_info(:tick, socket) do
      Process.send_after(self(), :tick, 1000)
      {:noreply, assign(socket, time: :calendar.local_time())}
    end
  
    def handle_event("inc", _, socket) do
      {:noreply, update(socket, :val, &(&1 + 1))}
    end
  
    def handle_event("dec", _, socket) do
      {:noreply, update(socket, :val, &(&1 - 1))}
    end
  
    def handle_event("toggle-mode", _, socket) do
      {:noreply,
       update(socket, :mode, fn
         :cooling -> :heating
         :heating -> :cooling
       end)}
    end  
  end

render/1の所は通常のViewと同様にテンプレートを置いておけば書かなくても良いのかもしれませんが、とりあえずサンプル通りにしておきます。
上記の挙動から鑑みるに、下記のタイミングで再レンダリングがかかっています。

  • メッセージを受けた時
  • update/3を使用した時

厳密にはソケットのステートが変更された際(Phoenix側でsocketに値がアサインされた時)に再レンダリングするようです。

Thermostatの例では、マウント時に1秒後に:tickというメッセージを自分宛に発行しています。これを利用して:tickというメッセージを受けた際にhandle_info/2が呼び出される(このへんはGenServerを参照してください)ので、そのコールバック関数の中でソケットに値をアサインしています。(コレを1秒毎に繰り返す)

クライアントサイドではphx-click="hoge"というディレクティブを要素に追加するとクリック時にイベントが発生し、handle_event/3で受けれるといった仕組みのようです。
それぞれ呼び出されるイベント毎にソケットに値をアサインしてレンダリングを実行しているようです。

所感

実際にやってることとしては他のフレームワークとそう大差ない(通常のフレームワークであればおそらくソケット通信よりも非同期通信で処理するのが一般的だとは思いますが)ですが、裏を返せば他のフレームワークを学習しなくてもデータバインディングコンポーネントの再レンダリングができるので、ラピッドプロトタイピングがかなり捗りそうです。
既存の資産に縁が無い(初心者やバックエンドしかやったことのない人)なら、これはかなり有り難いんじゃ無いでしょうか?僕は有り難いです!

でももうちょっとすんなり入るようになってほしい・・・(´゚'ω゚`)

Phoenix入門 (第11章 Presence)

f:id:ysmn_deus:20190219130922p:plain

どうも、靖宗です。 今回はPresenceということであまりなじみのない概念・・・
どうやらChannnel絡みの機能だそうですので、前回のサンプルの続き、ということになりそうです。

Presence

Presenceはトピックのプロセス情報を取得したり、クラスタ上にこの情報を複製したりする機能のようです。
幸いサーバー側もクライアント側も実装がシンプルになっているそうなのでそこまで苦労することはないでしょう。

ごちゃごちゃ書いてますが要はCRDTであるといった感じです。
CRDTに関しては下記を参考にしました。

qiita.com

ぶっちゃけよく分かってませんが、要はチャットとかで時間の整合性が若干ズレても構わんけど最終的に全員の発言をきっちり表示したいとかそんなんだと思います。

Presenceモジュール作成

Presenceを利用するにはPresenceモジュールを作成する必要があるそうです。mixで作成していきます。

PS \hello> mix phx.gen.presence
* creating lib/hello_web/channels/presence.ex

Add your new module to your supervision tree,
in lib/hello/application.ex:

    children = [
      ...
      HelloWeb.Presence
    ]

You're all set! See the Phoenix.Presence docs for more details:
http://hexdocs.pm/phoenix/Phoenix.Presence.html

mix phx.gen.presenceするとlib/hello_web/channels/presence.exが作成されます。
該当するファイルはほとんどがコメントで中身はほとんどないです。

defmodule HelloWeb.Presence do
...
  use Phoenix.Presence, otp_app: :hello,
                        pubsub_server: Hello.PubSub
end

ひとまずこのままにしておきます。
生成した際にでていた指定通りにlib/hello/application.exHelloWeb.Presenceを追記します。

...
  def start(_type, _args) do
    # List all child processes to be supervised
    children = [
      # Start the Ecto repository
      Hello.Repo,
      # Start the endpoint when the application starts
      HelloWeb.Endpoint,
      # Starts a worker by calling: Hello.Worker.start_link(arg)
      # {Hello.Worker, arg},
      HelloWeb.Presence,
    ]
...

mixを利用したChannel作成

次にRoomChannelを作成します。前回作成したんですが、どうやらmixでも生成できるみたいなので作り直します。

PS \hello> mix phx.gen.channel Room
* creating lib/hello_web/channels/room_channel.ex
* creating test/hello_web/channels/room_channel_test.exs

Add the channel to your `lib/hello_web/channels/user_socket.ex` handler, for example:

    channel "room:lobby", HelloWeb.RoomChannel

lib/hello_web/channels/user_socket.exに上記の情報を追加し、connect関数を書き換えます。

defmodule HelloWeb.UserSocket do
  use Phoenix.Socket

  channel "room:lobby", HelloWeb.RoomChannel

  def connect(params, socket) do
    {:ok, assign(socket, :user_id, params["user_id"])}
  end
  def id(_socket), do: nil
end

channelのルーティングの方は前回通りです。
connectはパラメータからユーザーIDを取り出し、ソケット情報にアサイン(登録)しておきます。
おそらく接続ユーザー一覧とかを作成したい意図でしょう。とりあえず先に進みます。

最後のdef id(_socket), do: nilですが、サンプルではとくに記載されていませんがコレが無いとUserSocketの生成の際にエラーが発生するようです。

Channel編集

RoomChannelでPresenceを利用するように編集していきます。

defmodule HelloWeb.RoomChannel do
  use HelloWeb, :channel
  alias HelloWeb.Presence

  def join("room:lobby", payload, socket) do
    send(self(), :after_join)
    {:ok, socket}
  end

  def handle_info(:after_join, socket) do
    push(socket, "presence_state", Presence.list(socket))
    {:ok, _} = Presence.track(socket, socket.assigns.user_id, %{
        online_at: inspect(System.system_time(:second))
    })
    {:noreply, socket}
  end
end

接続を確立した際にsend/2:after_joinというメッセージを送っています。
これはChannelの内部実装がGenServerになっていて、send/2でメッセージを送信しているようなものでしょう。なのでhandle_infoが発火してdef handle_info(:after_join, socket) doの処理が始まるといったところです。

肝心のhandle_infoでは"presence_state"というイベントにPresence.list(socket)というメッセージを載せてプッシュ(サブスクライブ)しています。
Presenceはどうやら"presence_state""presence_diff"といったイベントを監視して状態を共有しているのだと思います。
Presence.trackの箇所はユーザーID毎にどのタイミングまでオンラインであったかをトラッキングできるように情報を更新しているようなものでしょうか。inspect(System.system_time(:second))で現在のシステムの時間がUNIX時間で格納されるようです。

クライアントサイドを実装する

phoenix.jsを使っているクライアント側でPresenceを取得したりするよう実装します。
どうやら"presence_state""presence_diff"といったイベントが発生した際にpresence.onSyncでその時に実行されるコールバック関数を登録できるようです。
現在接続しているユーザーを表示している際などは、レンダリング関数などもこのコールバックの中に入れておくのが望ましいでしょう。

Presenceに登録されている情報を取得するにはpresence.listを利用するようです。
presences.list()のコールバックには引数が二つ用意されており、第一引数はPresence id(今回でいうユーザーID)と、第二引数は登録してあるメタ情報(今回で言うonline_atのマップ)が利用できます。

サンプルではassets/js/app.jsを利用してますが、ここでは前回同様assets/js/socket.jsを編集します。

import {Socket, Presence} from "phoenix"

let socket = new Socket("/socket", {
  params: {user_id: window.location.search.split("=")[1]}
})

let channel = socket.channel("room:lobby", {});
let presence = new Presence(channel);

function renderOnlineUsers(presence) {
  let response = "";

  presence.list((id, {metas: [first, ...rest]}) => {
    let count = rest.length + 1;
    response += `<br>${id} (count: ${count})</br>`; 
  });
  
  document.querySelector("#main").innerHTML = response;
}

socket.connect();

presence.onSync(() => renderOnlineUsers(presence));

channel.join();

export default socket

上記で#mainを利用するような記述に変更しましたのでlib/hello_web/templates/page/index.html.eexを編集しておきます。

<div id="main"></div>

今回は接続しているクライアントを表示するだけにします。
これで実装完了です。実際にブラウザで見てみましょう。

動作確認

実際にブラウザで見てみます。
http://localhost:4000/?name=Aliceを2個、http://localhost:4000/?name=Bobを1個のブラウザで開きます。

f:id:ysmn_deus:20190316191033g:plain

ここでは写し忘れましたが、ブラウザを閉じるときちんと名前が消えたりカウントが減ったりします。

厳密にはnameというクエリを取得しているわけではないんですが、とりあえずそれっぽいものはできました。
実際の実装ではクエリ文字からユーザーIDを取得するよりかはユーザートークンから取得することになりそうです。

Phoenix入門 (第10章 Channels その2 リアルタイムチャット機能の実装)

f:id:ysmn_deus:20190219130922p:plain

どうも、靖宗です。 前回の続きでChannelですが、今回はおそらくサンプルコードを動かすお話。

Tying it all together

とりあえず試してみようぜ!ということで、今までのコードはゴチャゴチャしてますし一旦作り直します。
地味に色々見て下さっている方だとお気づきかもしれませんが、サンプルはhelloというプロジェクト名で自分はhello_phoenixになってました。
なのでサンプルどおりhelloで作成します。

PS > mix phx.new hello
* creating hello/config/config.exs
...

例のごとくconfig/dev.exsを編集します。たぶんデータベース名が異なるから同じdokcerで動いてるPostgreSQL使える筈。

...
# Configure your database
config :hello, Hello.Repo,
  username: "elixir",
  password: "elixir",
  database: "hello_dev",
  hostname: "localhost",
  pool_size: 10
...

ecto.createする。

PS \hello> mix ecto.create
Compiling 13 files (.ex)
Generated hello app
The database for Hello.Repo has been created

一応サーバーが立ち上がるかチェック。

PS \hello> mix phx.server
[warn] Phoenix is unable to create symlinks. Phoenix' ...

http://localhost:4000/にアクセス。

f:id:ysmn_deus:20190315115753p:plain

問題ナシ!

ここからようやくChannelのサンプルに戻ります。
lib/hello_web/endpoint.exを確認します。(編集は不要だと思います。)

defmodule HelloWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :hello

  socket "/socket", HelloWeb.UserSocket
  ...
end

初期設定でいいはず。
ここでlib/hello_web/channels/user_socket.exを編集します。といっても今回はコメントを外すだけ。

defmodule HelloWeb.UserSocket do
  use Phoenix.Socket

  ## Channels
  channel "room:*", HelloWeb.RoomChannel # ここのコメントを外す。
  ...

ここまででルーティングまでが終わる。簡単!
次はHelloWeb.RoomChannelを作って行きます。

Joining Channels

HelloWeb.RoomChannelを定義するためにlib/hello_web/channels/room_channel.exを作成します。
まずは認証の為にjoin/3を実装します。

defmodule HelloWeb.RoomChannel do
  use Phoenix.Channel

  def join("room:lobby", _message, socket) do
    {:ok, socket}
  end
  def join("room:" <> _private_room_id, _params, _socket) do
    {:error, %{reason: "unauthorized"}}
  end
end

"room:lobby"というトピックへは無条件に誰でも入室できるイメージです。
その他のトピック("room:hoge"など)は認証が必要な想定です。今はとりあえずエラーを返すようにしているのでしょう。

認証が成功したら{:ok, socket}または{:ok, reply, socket}を返し、アクセス拒否する場合は{:error, reply}を返すように実装します。
この辺はPhoenix.TokenというPhoenixの認証機能の仕様みたいです。詳しく読んでいきたい所ですが、今回はサンプルを動かすことに徹します。

webpackを有効にして(デフォルトで有効です)Phoenixプロジェクトを作成していればassets/js/socket.jsJavaScriptのクライアントが実装されているようです。ありがたや。
これをトピック"room:lobby"を利用するように編集します。

// assets/js/socket.js
...
socket.connect()

// Now that you are connected, you can join channels with a topic:
let channel = socket.channel("room:lobby", {})
channel.join()
  .receive("ok", resp => { console.log("Joined successfully", resp) })
  .receive("error", resp => { console.log("Unable to join", resp) })

export default socket

このクライアントを読み込むようにassets/js/app.jsでインポートするように変更します。
コメント上にsocket.jsをインポートする文が記載されているのでコメントアウトで対応します。

...
// Import local files
//
// Local files can be imported directly using relative paths, for example:
import socket from "./socket"

ここまで完了すればあとはライブリロードでロードされます。
lib/hello_web/templates/layout/app.html.eexに既にassets/js/app.jsをロードする文言が記載されているので、とりあえずこれだけでOKです。
開発者ツールなどを開いてコンソールを確認します。

f:id:ysmn_deus:20190315122443p:plain

Joined successfullyと表示されていれば動作しているということでしょう。

とはいえこんなので「Channel分かった」とは全くもって思いません。
実際にやりとりするところを実装していきます。lib/hello_web/templates/page/index.html.eexを編集していきます。

<div id="messages"></div>
<input id="chat-input" type="text"></input>

他の文言は邪魔なだけですし上記のコードのみで良いと思います。
次にassets/js/socket.jsを編集していきます。

...
// Finally, connect to the socket:
socket.connect()

// Now that you are connected, you can join channels with a topic:
let channel = socket.channel("room:lobby", {})
let chatInput = document.querySelector("#chat-input"); // #chat-inputの要素を取り出す
let messagesContainer = document.querySelector("#messages"); // #messagesの要素を取り出す

chatInput.addEventListener("keypress", event => {
  if(event.keyCode === 13){ // エンターコードが押されたら
    channel.push("new_msg", {body: chatInput.value}); // 接続しているトピックへ文字列を送信
    chatInput.value = ""; // 入力欄を空にする
  }
});

channel.join()
  .receive("ok", resp => { console.log("Joined successfully", resp) })
  .receive("error", resp => { console.log("Unable to join", resp) })

export default socket

まずはEnterを押したら入力された文字をトピックにプッシュ(送信)するイベントを作成しました。
次にchannelに"new_msg"というイベントが発生したら実行するコールバック関数を追加する処理を書いておきます。

...
let channel           = socket.channel("room:lobby", {})
let chatInput         = document.querySelector("#chat-input")
let messagesContainer = document.querySelector("#messages")

chatInput.addEventListener("keypress", event => {
  if(event.keyCode === 13){
    channel.push("new_msg", {body: chatInput.value})
    chatInput.value = ""
  }
})

channel.on("new_msg", payload => { // "new_msg"というイベントが発生したら
  let messageItem = document.createElement("li") // リスト要素を作成
  messageItem.innerText = `[${Date()}] ${payload.body}` // テキストデータを作成。[日付] 本文
  messagesContainer.appendChild(messageItem) // messageItemに要素を追加
})

channel.join()
  .receive("ok", resp => { console.log("Joined successfully", resp) })
  .receive("error", resp => { console.log("Unable to join", resp) })

export default socket

ここまでできたらJSの実装は終わりです。
あとはChannel側の実装です。

Incoming Events

pushされてくるイベントを受けるときはhandle_in/3を実装します。
今回は"new_msg"というイベントを受けたらトピックにブロードキャスト(全体発信)する処理にします。
lib/hello_web/channels/room_channel.exを編集します。

defmodule HelloWeb.RoomChannel do
    use Phoenix.Channel
  
    def join("room:lobby", _message, socket) do
      {:ok, socket}
    end
    def join("room:" <> _private_room_id, _params, _socket) do
      {:error, %{reason: "unauthorized"}}
    end
    
    def handle_in("new_msg", %{"body" => body}, socket) do
      broadcast!(socket, "new_msg", %{body: body})
      {:noreply, socket}
    end
  end

broadcast!/3関数は指定したソケットのトピックに接続している全てのクライアントに情報を発信し、全てのクライアントのコールバック関数handle_out/3を実行させます。
ここまでで一通りの実装が完了しました。実際にブラウザを2個立ち上げて動作チェックしてみます。

f:id:ysmn_deus:20190315130710g:plain

(´◔౪◔)۶ヨッシャ!
こんなに簡単にリアルタイムで更新されていくチャットが作成できるなんて感無量ですね。(もちろん、ElixirだけでなくJavaScriptの恩恵も他大に受けてますが)

先ほどスルーしたhandle_out/3で様々な情報の加工やフィルタリングを実装できるようです。今回は実装しませんが、重要な機能のようなので一応確認します。

Intercepting Outgoing Events

イベントが発生した際にフィルタリングしたりなにがしかの処理を挟みたい時なんかがあると思います。
そういう際にはhandle_out/3を利用して処理を定義します。
下記の例はユーザーがチャットルームに入ってきた際に発行されるイベントを想定しており、既にアサインしているユーザー(接続が切れて再接続したとか?)は「入室しました」とかメッセージが出ないようにするといったところでしょうか。

intercept ["user_joined"]

def handle_out("user_joined", msg, socket) do
  if Accounts.ignoring_user?(socket.assigns[:user], msg.user_id) do
    {:noreply, socket}
  else
    push(socket, "user_joined", msg)
    {:noreply, socket}
  end
end

最初にintercept/1handle_out/3で加工したりするイベントを定義しておきます。
イベントが発行されたらユーザーがアサインされているかどうかを判定します。もしされていれば何も処理をせず、されていなければソケットに接続しているクライアントにpush/3でメッセージを送信します。
このhandle_out/3はイベント名でパターンマッチされるとはいえイベント毎に実行されます。なのでデータベースに接続して情報を取得する、などの負荷の高い処理をここに書くかどうかは慎重に吟味する必要がありそうです。

Socket Assigns

%Plug.Conn{}のようにチャンネルのソケットに値を追加する(アサインする)ことができるそうです。
Phoenix.Socket.assign/3で登録します。

socket = assign(socket, :user, msg["user"])

socket.assignsというマップとしてソケットに値が保存されます。

Using Token Authentication

ユーザー認証などが必要なケースが大半だと思います。
PhoenixではPhoenix.Tokenを利用することで4ステップでchannelのユーザー認証を実装できるそうです。
実際に実装して動作確認はしませんが、一応一通り見ておきます。

Step 1 - Assign a Token in the Connection

OurAuthという認証のプラグを作成したと仮定します。このプラグではユーザーが認証に通れば%Plug.Conn{}:current_userというキーでユーザー情報をアサインするとします。
このプラグを通過した接続情報に:current_userが存在すればPhoenix.Tokenを利用してトークンを発行し接続情報にトークンを追加する、そうでなければ接続情報には何も入れ無いという処理をプラグとして書きます。
これをput_user_token/2という関数プラグで書いたとします。
上記を踏まえてルーターを実装します。

pipeline :browser do
  ...
  plug OurAuth
  plug :put_user_token
end

defp put_user_token(conn, _) do
  if current_user = conn.assigns[:current_user] do
    token = Phoenix.Token.sign(conn, "user socket", current_user.id)
    assign(conn, :user_token, token)
  else
    conn
  end
end

パイプライン:browserを通過して認証が得られていればcurrent_useruser_tokenが接続情報conn.assignsに含まれます。

Step 2 - Pass the Token to the JavaScript

JavaScriptにユーザートークンを渡すためにテンプレート(レイアウト)にトークン情報を乗せます。
hello_web/templates/layout/app.html.eexなどでしょうか。

<script>window.userToken = "<%= assigns[:user_token] %>";</script>
<script src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>

JavaScriptに直接渡したい所ですが、テンプレートはレンダリングされてメモリ上で管理され、その後HTTPレスポンスとしてブラウザに渡されます。JSはブラウザに渡された後に静的ファイルとして配信されブラウザ上で統合されるのでテンプレートでレンダリングしておいてブラウザ側で取り組むようにしなければなりませんのでこのような処置になっているのでしょう。

Step 3 - Pass the Token to the Socket Constructor and Verify

パラメータとしてトークンが渡されることが想定されます。なのでhello_web/channels/user_socket.exconnect/3関数でトークン情報"token"を受け取り、評価する処置を実装します。
デフォルトではdef connect(_params, socket, _connect_info)になっていますが、この_paramsにパラメータが放り込まれる想定です。

def connect(%{"token" => token}, socket, _connect_info) do
  # max_age: 1209600 is equivalent to two weeks in seconds
  case Phoenix.Token.verify(socket, "user socket", token, max_age: 1209600) do
    {:ok, user_id} ->
      {:ok, assign(socket, :current_user, user_id)}
    {:error, reason} ->
      :error
  end
end

この処置でトークンからユーザーIDが得られます。channelで使用するsocketにユーザーIDをアサインして完了です。
ブラウザ上でロードされてるJavaScript(本章のサンプルで言えばsocket.js)ではトークンをソケットに乗せます。

let socket = new Socket("/socket", {params: {token: window.userToken}})

この辺はデフォルトで用意されてるあたり、Phoenixではこういう想定なんでしょう。

Step 4 - Connect to the socket in JavaScript

ここまで来たら通常のchannelと変わりません。コネクトして使うのみです。

let socket = new Socket("/socket", {params: {token: window.userToken}})
socket.connect()

let channel = socket.channel("topic:subtopic", {})
channel.join()
  .receive("ok", resp => { console.log("Joined successfully", resp) })
  .receive("error", resp => { console.log("Unable to join", resp) })

export default socket

認証あたりはどっかで試してみたいですね。

Handling Reconnection

クライアントがトピックへサブスクライブした(メッセージを送った)情報はメモリ上にETSテーブルとして保管されています。なんらかの原因でチャンネルがクラッシュした時にはクライアントはサブスクライブしていたトピックへ接続し直す必要があります。
JavaScriptのChannelライブラリにはこのへんの機能が備わっており、サーバー側でChannelがクラッシュしたらChannel.onErrorというコールバックが実行されるようです。この辺を利用してクライアントに再接続させる処理を書くのが良いでしょう。
JavaScriptのライブラリにしか言及がありませんが、たぶんサードパーティのライブラリにも同様の機能が実装されているとは思います。

Resending Client Messages

PhoenixのChannelのクライアントはメッセージ送信時にメッセージをバッファに入れてから送信しているようです。もし接続ができていない、なんか知らんけど送れてないとかある場合はバッファに記録したままタイムアウトを待つ挙動だそうです。(たぶんタイムアウト時間も設定できる)
ブラウザを閉じない限りはこのバッファは継続しているようです。

Resending Server Messages

サーバー側から発行するメッセージは基本的にバッファに溜めたり再送されたりすることは無いそうです。
この辺を保証するようなシステムを作成するには自分たちでその辺のプロトコルを実装する必要がありそうです。

Presence

たぶんある情報をクラスターやサーバー-クライアント間で一意に保つ機能だと思います。

コレに関しては章が分かれてるのでそちらで詳しく見ていきます。

Example Application

たぶんPresenceのサンプル。

phoenixchat.herokuapp.com

↑のソースコード

github.com

Phoenix入門 (第10章 Channels その1)

f:id:ysmn_deus:20190219130922p:plain

どうも、靖宗です。 実は個人的に一番知りたかった項目で、Channelに関してです。
この章も少々長くなりそうなので2記事に分割予定です。

Channels

PhoenixのChannelは何百万のクライアントを接続するリアルタイムコミュニケーション機能です。ある意味Phoenixのコア技術と勝手に感じております(Elixir自体がたくさんのリクエストを捌くイメージなので)。
想定される使い方としては

  • チャットルームとメッセージを送ったりするAPIの提供
  • 速報などのニュース(地震速報とか)
  • 地図上でのトラッキング(電車とかトラックとか)
  • マルチユーザのゲームに於けるイベント
  • センサーのモニタリングや照明のコントロール
  • CSSJavaScriptが変更されたときの通知(開発時など)

などが挙げられております。

チャンネルにクライアントが接続して、トピックにサブスクライブすると、Phoenixサーバーが他のクライアントに配信する、という仕組みだそうです。
公式にAAみたいなのがはっついてました。たぶんスマホとかでみると崩れる。

                                                                  +----------------+
                                                     +--Topic X-->| Mobile Client  |
                                                     |            +----------------+
                              +-------------------+  |
+----------------+            |                   |  |            +----------------+
| Browser Client |--Topic X-->| Phoenix Server(s) |--+--Topic X-->| Desktop Client |
+----------------+            |                   |  |            +----------------+
                              +-------------------+  |
                                                     |            +----------------+
                                                     +--Topic X-->|   IoT Client   |
                                                                  +----------------+

言うは易く行うは難しな気はしますが、ソースコードがあればできないことはない。はず・・・(´・ω:;.:...

ちなみに、様々なクライアントを想定しているだけあって様々なライブラリが整備されているようです。

hexdocs.pm

The Moving Parts

Overview

おそらく簡単に使えるんでしょうが、実際の挙動はやや複雑です(とはいっても概念だけなのでそこまで難しく無さそう。)

クライアントがPhoenix Serverと通信を開始する際に1クライアント1トピック(チャットルームの様なイメージ?)につき通信用のプロセスを生成します。この際に通信で使われる%Phoenix.Socket(いわゆる%Plug.Connのようなもの?)が初期化されるそうです。詳しくはたぶん後で出てくるのでそのときに確認しましょう。

f:id:ysmn_deus:20190314121243p:plain

一度通信が成立すると、クライアントとトピックで一意に決まるプロセス(図で言う「Sending Client, Topic 1」)へのルーティングが確立し、クライアントからの通信がきちんとチャンネルのサーバーへ繋がるようになります。
通信が確立した後にブロードキャストのメッセージを発行すると、一度Local PubSubというプロセス?を経て同じトピックに繋がっているサーバーへ配信されるようです。

f:id:ysmn_deus:20190314121334p:plain

Local PubSubに送られたメッセージは、同一トピックに接続しているプロセス全部に送信され、無事メッセージが各クライアントに届きます。

f:id:ysmn_deus:20190314121516p:plain

同一ノードはこのような仕組みになっていますが、別のノードはPubSub間で通信があった後に各クライアントプロセスへ配信という仕組みになっているそうです。

f:id:ysmn_deus:20190314121636p:plain

Endpoint

Endpointの章で若干触りましたが、Endpointにsocketの設定を書く箇所があります。

defmodule HelloPhoenixWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :hello_phoenix

  socket "/socket", HelloPhoenixWeb.UserSocket,
    websocket: true,
    longpoll: false
...

URIの設定やwebscoketを利用するかロングポーリングを使用するかの設定です。

Socket Handlers

チャンネルの接続がセットアップされる度に、上記で言うHelloPhoenixWeb.UserSocketがソケットハンドラとして呼び出されるようです。
おそらく初期化関数や認証などはHelloPhoenixWeb.UserSocketに書くのでしょう。

Channel Routes

チャンネルのルーティングもHelloPhoenixWeb.UserSocketに書くそうです。Phoenixプロジェクトを生成して最初から書かれているコメントアウトされている箇所にも書かれています。

defmodule HelloPhoenixWeb.UserSocket do
  use Phoenix.Socket

  ## Channels
  # channel "room:*", HelloPhoenixWeb.RoomChannel
...

channel "room:*", HelloPhoenixWeb.RoomChannelroom:lobbyroom:123といったトピックへのルートを生成できるようになるようです。

Channels

上記で言うHelloPhoenixWeb.RoomChannelでしょうか。
ソケット通信でないHTTPリクエストで言うコントローラに該当するようなモジュールのようです。ただし、コントローラは基本的に受け身ですが、Channelは双方向という違いはあります。
Channelはjoin/3terminate/2handle_in/3handle_out/3という4つの関数を実装して作って行くようです。

Topics

トピックはChannel Routesでワイルドカード指定した箇所が該当します。
基本的にroom:123の要にレコードIDを付与していく形で使う事が多いそうですが、名前を付けるのかID割り振っていくのかはケースバイケースだと思います。

Messages

Phoenix.Socket.Messageというモジュールが存在し、これを利用してトピックにメッセージなどを発行するのでしょう。
Channelの通信で使うメッセージの構造を定義しているようです。

  • topic - トピック名。"messages"とか"messages:123"とか。
  • event - イベント名。"phx_join"とか。よくわかんないので保留。
  • payload - メッセージのペイロード。たぶんメッセージ本体を意味している。"おはよう!"とか。
  • ref - 一意に決まる文字列?よくわかんない。

サンプルとかもあるのでよく分からない箇所は後回しにしましょう。

PubSub

Overviewのところで出てやつ。図からも分かるとおり内部実装で稼働してる箇所なので直接使う事はほぼないらしい。ただし設定することはあるかもとのこと。

PubSubはPhoenix.PubSubというモジュールで構成されていて、こいつはGenServerとかいろんなものの組み合わせだそうで。
ノード間通信も面倒みてくれるのですが、ノード間の通信はデフォルトではPhoenix.PubSub.PG2というおそらく内部実装がErlangのpg2でできてるモジュールを利用しているそうです。
なんらかの原因(古いElixirやErlangのバージョンを利用しないといけないケース?)でpg2などが利用できない場合はRedisを利用する設定もあるそうです。詳しくはドキュメントを参照とのこと。

hexdocs.pm

もしRedisの方が早い環境などあったらチューニングでRedisを選択する、なんてことも想定しといたほうが良いんでしょうか?
ただ、デフォルトでPG2を指定されているなら基本的にはPG2をチョイスする方が無難な気はします。

Client Libraries

Phoenixを利用するライブラリが公式+サードパーティーから提供されています。フルスクラッチも勉強にはなると思いますが基本的にはこの辺を利用することになるでしょう。

Official

hexdocs.pm

ギットハブ

github.com

3rd Party

次回は実際にサンプルを動かしていく箇所です。たぶん。

Phoenix入門 (第9章 Templates)

f:id:ysmn_deus:20190219130922p:plain

どうも、靖宗です。 今回はTemplatesということでテンプレートに関して深掘りしていきます。
大体Viewsで知りたいことは知った感もあるのですが、念のためにチェックしておきます。

Templates

Viewsでも取り扱いましたがテンプレートはHTMLやJSONXMLなどで利用できます。
基本的にこれらのテンプレートはコンパイル時に読み込まれ、実行中はメモリ上に展開されているようです。なので早いと。

EExというテンプレートシステム(Elixirのライブラリ?)が採用されているようで、RubyでいうところのERBだそうです。自分はRuby触ったことないのでなにがなんだかさっぱりです。
あんまり気にしなくてもビューは作れそうです。

Examples

hello_phoenix_web.ex

とりあえずサンプルを見ていきましょう。 Adding Pagesは経験してる前提で細かい説明はすっ飛ばします。 まずはルーティングを確認しておきます。lib/hello_phoenix_web/router.exです。

defmodule HelloPhoenixWeb.Router do
  use HelloPhoenixWeb, :router
...
  scope "/", HelloPhoenixWeb do
    pipe_through :browser

    get "/", PageController, :index
    get "/test", PageController, :test
  end
...

/testのルーティングが増えてます。なのでコントローラにアクションを追加します。

...
  def test(conn, _params) do
    render(conn, "test.html")
  end
...

ここで、なにやらaction_name/1controller_module/1が要る、と書かれておりlib/hello_phoenix_web.exを編集します。

...
  def view do
    quote do
      use Phoenix.View,
        root: "lib/hello_phoenix_web/templates",
        namespace: HelloPhoenixWeb

      # Import convenience functions from controllers
      import Phoenix.Controller, only: [get_flash: 1, get_flash: 2, view_module: 1, action_name: 1, controller_module: 1]
...

たぶんこの章のサンプルの動作に必要なものをインポートしているのでしょう。
細かい事は後回しにして次に進みます。

次はHelloPhoenixWeb.PageViewを編集します。
先ほどインポートしたaction_name/1controller_module/1を使ってhandler_info/1という関数を作っています。

defmodule HelloPhoenixWeb.PageView do
  use HelloPhoenixWeb, :view

  def handler_info(conn) do
    "Request Handled By: #{controller_module(conn)}.#{action_name(conn)}"
  end

  def connection_keys(conn) do
    conn
    |> Map.from_struct()
    |> Map.keys()
  end
end

あと、Plug.Connの中にあるキーでも吐き出すようなconnection_keys/1という関数も追加しました。
たぶん表示される物を見ればなんなのか予想できそうなので今は放置します。

次にテンプレートを追加します。lib/hello_phoenix_web/templates/page/test.html.eexを作成します。

<div class="phx-hero">
  <p><%= handler_info(@conn) %></p>
</div>

レイアウトのおかげで書くのはこれだけです。どうやらlocalhost:4000/testにアクセスしたときにコントローラのモジュール名やアクション名を表示するようなページを書いていたようです。
早速http://localhost:4000/testにアクセスします。

f:id:ysmn_deus:20190311100804p:plain

バッチリです✌('ω')
当たり前っちゃ当たり前なんですが、ここで利用しているhandler_info(@conn)はこのHelloPhoenixWeb.PageViewレンダリングされるlib/hello_phoenix_web/templates/pageでしか使えません。
あくまでテンプレートはビュー上で評価されていますので、ビューの関数のみ利用可能です。

Displaying Lists

テンプレートにはリストを表示するディレクティブのようなものもあるようです。
lib/hello_phoenix_web/templates/page/test.html.eexを下記のように編集します。

<div class="phx-hero">
  <p><%= handler_info(@conn) %></p>

  <h3>Keys for the conn Struct</h3>

  <%= for key <- connection_keys(@conn) do %>
    <p><%= key %></p>
  <% end %>
</div>

先ほどと同様にhttp://localhost:4000/testにアクセスします。

f:id:ysmn_deus:20190311112220p:plain

conのキーが表示されました。

Render templates within templates

テンプレートの構造が複雑になってきて可読性が下がる場合などがあります。
そもそもレイアウト(app.html.eex)も可読性を上げたりヘッダやフッタなどの冗長性を下げたりする仕組みですが、これはどのテンプレートでも取り入れられます。
つまりテンプレート内でテンプレートを呼び出せます!

早速やってみましょう。lib/hello_phoenix_web/templates/page/key.html.eexを作成します。

<p><%= @key %></p>

先ほどはテンプレート内で発生した変数keyを利用していましたが、今回はテンプレートをレンダリングする際に渡される変数keyなので@が着いてます。

今回はショボいですが、これがAmazonの検索結果の1個1個のアイテムだとしたらテンプレートが分かりやすくなり、恩恵を得られそうです。
次にlib/hello_phoenix_web/templates/page/test.html.eexを編集します。

<div class="phx-hero">
  <p><%= handler_info(@conn) %></p>

  <h3>Keys for the conn Struct</h3>

  <%= for key <- connection_keys(@conn) do %>
    <%= render("key.html", key: key) %>
  <% end %>
</div>

keyの変数名を利用していたところにrender/2関数を呼んでいます。
同様にhttp://localhost:4000/testにアクセスすれば、先ほどと変わらないページが表示されると思います。

以外とこの辺はJSONの構造などにも利用できそうです。

Shared Templates Across Views

上記で作成したような細かいテンプレートを別のビューで呼び出したい時があると思います。
そういう際には

<div class="phx-hero">
  ...

  <%= for key <- connection_keys(@conn) do %>
    <%= render(HelloPhoenixWeb.PageView, "key.html", key: key) %>
  <% end %>
</div>

としてモジュール名をきちんと表記してrender/3を利用することが想定されます。が、いかんせんメンテナンス性に欠けそうです。
そこでPhoenixがベストプラクティスとして提案しているのがSharedViewというものを作成してそこからロードする、といった手法です。
他のビューと同様にlib/hello_phoenix_web/views/shared_view.exを作成し、lib/hello_phoenix_web/templates/sharedディレクトリも作成します。
shared_view.ex

defmodule HelloPhoenixWeb.SharedView do
  use HelloPhoenixWeb, :view
end

だけでいいです。(必要に応じてカスタマイズされるかもしれませんが、大体のケースはこれでいいはず)

たとえば今回であればkey.html.eexlib/hello_phoenix_web/templates/sharedに入れて利用する場合であればlib/hello_phoenix_web/templates/page/test.html.eexの該当箇所は

<%= for key <- connection_keys(@conn) do %>
  <%= render(HelloWeb.SharedView, "key.html", key: key) %>
<% end %>

となります。
規模が大きくなったときには有用な方針だと思います。
あくまで方針なので、どうするかは開発者の自由ではあるようです。

Elixirで外部コマンドの実行

f:id:ysmn_deus:20190122112104p:plain

どうも、靖宗です。
いつもはシリーズ物ばかり書いてますがたまには単発記事も書きたいです。
技術”メモ”なので、好きに書けばええやんって言われそうですが(笑)

プログラムの醍醐味は普段の作業を効率化、自動化してくれるところだと思ってます。
そういう意味では「外部コマンドの実行」は自分のよく触るPythonでもよく行います。
そこで一応Elixirの外部コマンドを実行する手立てを調べておきます。

Systemモジュール

どうやらElixirでは外部コマンドを実行するときにはSystemモジュールを利用するそうです。

hexdocs.pm

また、Qiitaのこの記事も参考にさせていただきました。

qiita.com

どうやら「そもそもコマンドが存在するか否か」を判定する関数と、「コマンドを実行する関数」があるそうです。
(Systemモジュールはその他の機能もたくさんあります)

find_executable("コマンド")

find_executable/1でまずはコマンドが存在しているかどうか確認します。
試しにpythonが実行できるか確認します。

iex(1)> System.find_executable("python")
"c:/Python/Python36/python.exe"

パスが返ってきました、問題無いようです。
逆に実行不可の時は?

iex(2)> System.find_executable("pithon")
nil

nilになりました。なんとなくElixirなら成功の時も{:ok, result}とか{:error, nil}とかになりそうな気はしますが、こんなもんでしょう。

cmd("コマンド", 引数, オプション)

コマンドは上記で確認したコマンドなど、引数はコマンドに引数が必要でない場合も空リストを渡してあげるようです。オプションに関しては必要無ければ書かなくてもいいようですが、環境変数や実行時にcdしたいときなどには利用しそうです。
とりあえず試します。

iex(3)> System.cmd("python", ["--version"])
{"Python 3.6.1\r\n", 0}

単純な話ではありますが、これらを知ってるだけで色々な事が自動化できると思います。