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

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

Elixirのプロセスの起動順について

Elixir で GenServer, Supervisor, mix deps.get で取得したパッケージの Application の起動順について調べたのでそのまとめ。

GenServer

GenServer.start_link/3 のドキュメントに次のように記述されています。

To ensure a synchronized start-up procedure, this function does not return until init/1 has returned.

Genserver.start_link/3

と、いうわけで。同じプロセスで複数の GenServer プロセスを起動するばあい、先に起動したプロセスの init/1 が完了してから後続のプロセスが起動することが保証できます。先に起動したプロセス宛に安全にメッセージを送信できると考えてよさそうです。

メッセージを受信する GenServer 。

defmodule Hoge.Foo do
  use GenServer

  require Logger

  def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
  def init(state), do: {:ok, state}

  def handle_cast({:foo, sender}, state) do
    Logger.info("#{__MODULE__} has received a message from #{sender}")
    {:noreply, state}
  end
end

メッセージを送信する GenServer 。

defmodule Hoge.Bar do
  use GenServer

  def start_link(opts) do
    receiver = get_in(opts, [:receiver])
    GenServer.start_link(__MODULE__, %{receiver: receiver})
  end

  def init(state) do
    GenServer.cast(state.receiver, {:foo, __MODULE__})

    {:ok, state}
  end
end

メッセージを受信する GenServer と送信する GenServer を順に起動します。

defmodule Hoge do
  def do_something do
    {:ok, foo_pid} = Hoge.Foo.start_link([])
    {:ok, bar_pid} = Hoge.Bar.start_link(receiver: foo_pid)
  end
end

実行。

$ iex -S mix
iex(1)> Hoge.do_something()         
18:21:48.782 [info]  Elixir.Hoge.Foo has received a message from Elixir.Hoge.Bar

この例ではほとんど処理時間がないためわかりにくいですが、Foo.init/1 で時間がかかる処理を記述してみても完了するまで Foo.start_link/1 は処理を待つので、Bar は安全にメッセージを Foo 宛に送信することができます。

Supervisor

Supervisor のドキュメントに次のように記述されています。

When the supervisor starts, it traverses all child specifications and then starts each child in the order they are defined.

start and shutdownSupervisor

また終了についても同じページの同じセクションに次のようにあります。

The shutdown process happens in reverse order.

監督する子プロセスの定義順に起動し、終了時はその逆順で停止されるようです。

Hoge を次のように書き換えます。

defmodule Hoge do
  def do_something do
    children = [
      Hoge.Foo,
      {Hoge.Bar, receiver: Hoge.Foo}
    ]

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

Hoge.Foo は名前付きで起動しているので GenServer.cast/2 は名前で呼び出すことができます。それを利用して、Hoge.Bar には送信先を指定するパラメータに名前を渡しています。

実行。

$ iex -S mix
iex(1)> Hoge.do_something
18:32:38.261 [info]  Elixir.Hoge.Foo has received a message from Elixir.Hoge.Bar

特に代わり映えはしないです、はい。

mix deps.get で取得した Application

例えば次のように FooBar がアプリケーションで、Hoge がそれらを取り込んでなおかつ起動順を指定したいばあいです。

./
├── bar/
│   └── lib/
│        ├── bar/
│        │   └── application.ex
│        └── bar.ex
├── foo/
│   └── lib/
│        ├── foo/
│        │   └── application.ex
│        └── foo.ex
└── hoge/
    ├── lib/
    │   ├── hoge/
    │   │   └── application.ex
    │   └── hoge.ex
    └── mix.exs

このようなばあいは、まず、 application/0 が返すパラメータに :included_application を追加してそこにアプリケーション名を記述します。

アプリケーション名は mix.exsproject/0:app で記載しているパラメータの値になります。モジュール名でないので注意してください。

defmodule Hoge.MixProject do
  use Mix.Project

  def project do
    [
      app: :hoge,
      version: "0.1.0",
      elixir: "~> 1.8",
      start_permanent: Mix.env() == :prod,
      deps: deps()
    ]
  end

  def application do
    [
      extra_applications: [:logger],
      included_applications: [:foo, :bar], # 取り込むアプリケーションの名前を記述
      mod: {Hoge.Application, []}
    ]
  end

  defp deps do
    [
      {:foo, path: "../foo"},
      {:bar, path: "../bar"}
    ]
  end
end

ドキュメントにある通り :included_applications に記載されたアプリケーションは自動的には起動しなくなります。

Any included application, defined in the :included_applications key of the .app file will also be loaded, but they won't be started.

*Application.start/2

取り込んだ側のアプリケーションで Supervisor などを利用して起動することで、アプリケーションの起動順を制御することができます。

たとえば。Foo.ApplicationBar.Application を次のように定義しておくと、

defmodule Foo.Application do
  use Application

  def start(_type, _args) do
    children = [
      Foo
    ]

    opts = [strategy: :one_for_one, name: Foo.Supervisor]
    Supervisor.start_link(children, opts)
  end
end
defmodule Bar.Application do
  use Application

  def start(_type, args) do
    children = [
      {Bar, args}
    ]

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

Hoge.Application に次のように記述することで、順序を指定して起動させることができるようになります。

defmodule Hoge.Application do
  use Application

  def start(type, _args) do
    children = [
      %{id: Foo.Application, start: {Foo.Application, :start, [type, nil]}},
      %{id: Bar.Application, start: {Bar.Application, :start, [type, [receiver: Foo]]}}
    ]

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

子プロセスを定義する記述は、 Supervisor.start_link/2 のエラーメッセージに書かれた説明が一番わかりやすそうです。

If you own the given module, please define a child_spec/1 function
that receives an argument and returns a child specification as a map.
For example:

    def child_spec(opts) do
      %{
        id: __MODULE__,
        start: {__MODULE__, :start_link, [opts]},
        type: :worker,
        restart: :permanent,
        shutdown: 500
      }
    end

Note that "use Agent", "use GenServer" and so on automatically define
this function for you.

However, if you don't own the given module and it doesn't implement
child_spec/1, instead of passing the module name directly as a supervisor
child, you will have to pass a child specification as a map:

    %{
      id: Foo.Application,
      start: {Foo.Application, :start_link, [arg1, arg2]}
    }

メッセージにあるようにモジュールに child_spec/1 と定義するか、:id:start を持つ Map を渡すかするようにします。 上記の例では Map を渡しています。

いつか読むはずっと読まない: Futabasaurus suzukii

国内で最も有名な首長竜にも関わらず正式な学名がついたのは 2006 年。 「かはく」こと 国立科学博物館の日本館三階北翼 をぜひ訪ねてみてください。

フタバスズキリュウ もうひとつの物語

フタバスズキリュウ もうひとつの物語

Elm で mouse のイベントを取得する覚書

Elm のイベント用の関数は onClick など主だったものはライブラリで用意されていますが、それ以外のイベントは on 関数を利用して自分で合成する必要があります。

以下、合成方法の覚書です。

mousemove イベントをハンドリングする

イベントの値を受け取る型を用意します。

type Msg = Move Int Int

イベントのデコーダを用意します。

mousemove イベントのうち clientXclientY の二つのフィールドをそれぞれ Int として取得し、Move を適用するデコーダです。

map2 Move (field "clientX" int) (field "clientY" int)

map2, field, intJson.Decode で定義されている関数です。

動作を REPL で確認してみます。

$ elm repl
---- Elm 0.19.0 ----------------------------------------------------------------
Read <https://elm-lang.org/0.19.0/repl> to learn more: exit, help, imports, etc.
--------------------------------------------------------------------------------
> import Json.Decode exposing(map2, field, int, decodeString)
> type Msg = Move Int Int
> decodeString (map2 Move (field "clientX" int) (field "clientY" int)) "{\"clientX\":10,\"clientY\":20}"
Ok (Move 10 20)
    : Result Json.Decode.Error Msg
> 

{"clientX":10,"clientY":20} という JSON から Move 10 20 という値を取得することができました。

このデコーダon 関数に与えます。

イベントの構造については MDN などを参照してください。

実装

div 要素上のマウスカーソルの位置を表示するだけのサンプルです。

import Browser
import Html exposing (Html, div, span, text)
import Html.Events exposing (on)
import Html.Attributes exposing (style)
import Json.Decode exposing (map2, field, int)

main =
  Browser.sandbox
    { init = init
    , update = update
    , view = view
    }

type alias Model = { x: Int , y: Int }

init : Model
init = { x = 0 , y = 0 }

type Msg = Move Int Int

update : Msg -> Model -> Model
update msg model =
  case msg of
    Move x y -> {x = x, y = y}

view : Model -> Html Msg
view model =
  div []
    [ span
        []
        [ text ("(" ++ (String.fromInt model.x) ++ ", " ++ String.fromInt model.y ++ ")") ]
    , div
        [ style "background-color" "gray"
        , style "height" "80vh"
        , on "mousemove" (map2 Move (field "clientX" int) (field "clientY" int))
        ]
        []
    ]

合成する関数に名前をつける

見通しをよくするために名前をつけます。ここでは Move を引数で受け取るようにすることで他のメッセージにも利用できるようにしています。

-- view 以外は上と同じ

view : Model -> Html Msg
view model =
  div []
    [ span
        []
        [ text ("(" ++ (String.fromInt model.x) ++ ", " ++ String.fromInt model.y ++ ")") ]
    , div
        [ style "background-color" "gray"
        , style "height" "80vh"
        , onMouseMove Move
        ]
        []
    ]

onMouseMove : (Int -> Int -> msg) -> Html.Attribute msg
onMouseMove f =
  on "mousemove" (map2 f (field "clientX" int) (field "clientY" int))

外部で生成したデータをActive Storageでattachしたいとき

つまりこうゆうことです。

探した範囲でははっきりとした情報が見つからなかったので、めも。

このようなモデルがあるばあい、

class Article < ApplicationRecord
  has_one_attached :image
end

たとえばこのようにすることで storage にデータを格納できますが、Rails は元のデータとは別のオブジェクトを storage に生成します。

Article.find(id).image(filename: image_filename, io: File.read(image_filename))

worker が生成したデータの出力先を制御できるであれば、worker から storage へ直接出力させて Rails では attach 情報だけを更新することで実現できます。

blob =
  ActiveStorage::Blob.create(
    key: 'key-of-object',                # 格納したファイルのキー(実際に格納されているファイルのファイル名や S3 のキー)
    filename: 'something.jpg',           # ダウンロードする時のファイル名
    content_type: 'image/jpeg',          # content-type
    byte_size: 12345,                    # 格納したファイルのバイトサイズ
    checksum: 'TcZwnbeihC6rAB8p5LeRuQ==' # 格納したファイルのチェックサム
  )

Article.find(id).image.attach(blob)

チェックサムOpenSSL::Digest::MD5 で生成して Base64エンコードしたものです。

Base64.strict_encode64(OpenSSL::Digest::MD5.digest(格納したファイルの内容))

ローカルに格納されているばあい、キー(ファイル名)の先頭 4 文字を 2 文字ずつ使ったサブディレクトリに格納されます。

たとえば格納先のディレクトリが storage/ で格納するファイルのファイル名が ABCDEFGHIJKLMNOPQRSTUVWX だったばあい、次のように storage/AB/CD/ABCDEFGHIJKLMNOPQRSTUVWX というパスになります。

storage/
└ AB/
   └ CD/
      └ ABCDEFGHIJKLMNOPQRSTUVWX

CSSでモーダルを表示する覚え書き

CSS:chekced 擬似クラスを利用して表示を制御する方法です。

参考にさせていただいたサイトです。

上記のサイトでは要素を id で指定していますが、class で指定するように変更することで複数のモーダルの表示ができるようになります。

以下の例では同じページで二つのモーダルを表示させています。

コード

HTML

<label for="first-modal"><span>最初のモーダルを表示</span></label>
<label for="second-modal"><span>二つ目のモーダルを表示</span></label>

<div class="modal">
  <input id="first-modal" class="modal-checkbox" type="checkbox">
  <label for="first-modal" class="modal-close"></label>
  <div class="modal-content">
    <div>最初のモーダル</div>
  </div>
</div>

<div class="modal">
  <input id="second-modal" class="modal-checkbox" type="checkbox">
  <label for="second-modal" class="modal-close"></label>
  <div class="modal-content">
    <div>二つ目のモーダル</div>
  </div>
</div>

CSS (SASS)

// モーダルの土台
.modal {
  // ページの中央に配置
  margin: auto;
  width: 50%;
}

// モーダルの中身
.modal-content {
  // モーダルをページ外(ページの上部)に配置する
  position: fixed;
  top: 0;
  transform: translateY(-100%);

  // 表示する時のアニメーション
  transition: all 0.3s ease-in-out 0s;

  // 表示時の背景との表示順を指定
  z-index: 40;

  // 表示時の背景のグレイアウトに影響されないように背景色を設定
  background: #fff;

  // 見た目を綺麗に
  padding: 20px;
  border-radius: 8px;
  width: 640px;
}

// モーダルの背景(クリックするとモーダルを閉じる)
.modal-close {
  // 通常は非表示
  display: none;

  // ページ全体を黒色で覆う
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: #000;
  opacity: 0;

  // 表示時のフェードイン
  transition: all 0.3s ease-in-out 0s;

  // モーダルの背面に配置
  z-index: 39;
}

// 表示の制御をするためのチェックボックス
.modal-checkbox {
  // チェックボックス自体は非表示
  // (!important は必要な場合に設定してください)
  display: none !important;

  // チェックされたときの表示の設定
  &:checked ~ {
    .modal-content {
      // 表示位置をページ内に変更する
      transform: translateY(0);
      top: 100px;

      // 影をつける
      box-shadow: 6px 0 25px rgba(0, 0, 0, 0.16);
    }

    .modal-close {
      // 背景に薄く表示する
      display: block;
      opacity: 0.3;
    }
  }
}

明日の自分のための補足説明。

~ は兄弟要素を指しています。

実行

ページを開くと二つのキャプションが表示されます。

f:id:E_Mattsan:20190420132006p:plain

最初のキャプションをクリックした状態。

f:id:E_Mattsan:20190420131839p:plain

二つ目のキャプションをクリックした状態。

f:id:E_Mattsan:20190420131851p:plain

<label for="first-modal">&times;</label>

Elm の JSON のパースの覚え書き

Elm に手を出しました。

mousemoveイベントハンドラを使うのに MouseEventJSON をパースする必要があって四苦八苦したので、明日の自分のために JSON のパースの仕方をメモっておきます。 後日イベントハンドラについても書く予定。

パーサを書く

SomethingJson という雑なモジュールを作成しました。

module SomethingJson exposing
  ( Something
  , Somethings
  , somethingDecoder
  , parseSomething
  , parseSomethings
  , FooBarBaz
  , parseFooBarBaz
  )

import Json.Decode exposing
  ( Decoder
  , Error
  , decodeString
  , at
  , string
  , int
  , map
  , map3
  , list
  )

-- "something" という文字列の要素を持つレコード型
type alias Something =
  { something : String
  }

-- Something のリスト型
type alias Somethings =
  List Something

-- "something" という要素を含む JSON のデコーダ
somethingDecoder : Decoder String
somethingDecoder =
  at ["something"] string

-- JSON 文字列をパースして Something 型の値を返す関数
parseSomething : String -> Result Error Something
parseSomething =
  decodeString (map Something somethingDecoder)

-- JSON 文字列をパースして Somethings 型の値を返す関数
parseSomethings : String -> Result Error Somethings
parseSomethings =
  decodeString (list (map Something somethingDecoder))

-- 三つの要素を持つレコード型
type alias FooBarBaz =
  { foo : String
  , bar : Int
  , baz : List Int
  }

-- "foo", "bar", "baz" という要素を含む JSON のデコーダ
fooBarBazDecoder : Decoder FooBarBaz
fooBarBazDecoder =
  map3 FooBarBaz
    (at ["foo"] string)
    (at ["bar"] int)
    (at ["baz"] (list int))

-- JSON 文字列をパースして FooBarBaz 型の値を返す関数
parseFooBarBaz : String -> Result Error FooBarBaz
parseFooBarBaz =
  decodeString fooBarBazDecoder

elm init した時に作成される src/ というディレクトリに SomethingJson.elm という名前で保存します。

パーサを使う

REPL で動作を確認します。

$ elm repl
---- Elm 0.19.0 ----------------------------------------------------------------
Read <https://elm-lang.org/0.19.0/repl> to learn more: exit, help, imports, etc.
--------------------------------------------------------------------------------
>

インポートします。

> import SomethingJson

Something 型を表す JSON をパースします。

> SomethingJson.parseSomething "{\"something\":\"何か\"}"
Ok { something = "何か" }
    : Result Json.Decode.Error SomethingJson.Something

Somethings 型(Something のリスト型)を表す JSON をパースします。

> SomethingJson.parseSomethings "[{\"something\":\"何か\"},{\"something\":\"どれか\"}]"
Ok [{ something = "何か" },{ something = "どれか" }]
    : Result Json.Decode.Error SomethingJson.Somethings

三種類の型の値を持つ FooBarBaz 型を表す JSON をパースします。

> SomethingJson.parseFooBarBaz "{\"foo\":\"ふー\",\"bar\":123,\"baz\":[1,2,3]}"
Ok { bar = 123, baz = [1,2,3], foo = "ふー" }
    : Result Json.Decode.Error SomethingJson.FooBarBaz

もっと要素の多い JSON のパーサをかく

Json.Decode には map8 まで用意されていて 8 要素まではパーサを書くことができます。それ以上の場合はドキュメントにも記載されているように他のパッケージなどを利用します。

Note: If you run out of map functions, take a look at elm-json-decode-pipeline which makes it easier to handle large objects, but produces lower quality type errors.

リンクされている elm-json-decode-pipeline を使ってみます。

インストールします。

$ elm install NoRedInk/elm-json-decode-pipeline

HTML の MouseEvent の内容を解釈するパーサを書いてみます。

module MouseEvents exposing (EventData, mouseEventDecoder, parseMouseEvent)

import Json.Decode exposing (Decoder, Error, decodeString, int, bool, succeed)
import Json.Decode.Pipeline exposing (required)

type alias EventData =
  { altKey : Bool
  , ctrlKey : Bool
  , shiftKey : Bool
  , metaKey : Bool
  , button : Int
  , clientX : Int
  , clientY : Int
  , movementX : Int
  , movementY : Int
  , screenX : Int
  , screenY : Int
  }

mouseEventDecoder : Decoder EventData
mouseEventDecoder =
  succeed EventData
    |> required "altKey" bool
    |> required "ctrlKey" bool
    |> required "shiftKey" bool
    |> required "metaKey" bool
    |> required "button" int
    |> required "clientX" int
    |> required "clientY" int
    |> required "movementX" int
    |> required "movementY" int
    |> required "screenX" int
    |> required "screenY" int

parseMouseEvent : String -> Result Error EventData
parseMouseEvent =
  decodeString mouseEventDecoder

MouseEvents.elm というファイル名で src/ に保存します。

REPL で確認します。

$ elm repl
---- Elm 0.19.0 ----------------------------------------------------------------
Read <https://elm-lang.org/0.19.0/repl> to learn more: exit, help, imports, etc.
--------------------------------------------------------------------------------
> import MouseEvents
> MouseEvents.parseMouseEvent "{\"screenX\":1,\"screenY\":1,\"movementX\":1,\"movementY\":1,\"clientX\":1,\"clientY\":1,\"button\":0,\"metaKey\":true,\"shiftKey\":true,\"ctrlKey\":true,\"altKey\":true}"
Ok { altKey = True, button = 0, clientX = 1, clientY = 1, ctrlKey = True, metaKey = True, movementX = 1, movementY = 1, screenX = 1, screenY = 1, shiftKey = True }
    : Result Json.Decode.Error MouseEvents.EventData

これで MouseEvent を扱う準備ができました。つづく。

いつか読むはずっと読まない:はじまりの艦隊

The Lost Fleet (彷徨える艦隊)の最新刊、The Genesis Fleet: Vanguard 、の邦訳、ようやく読了。って刊行から一年近く経ってしまっていた。

来月には原著のシリーズ最新刊 The Genesis Fleet: Triumphant がもう刊行される模様。

彷徨える艦隊 ジェネシス 先駆者たち (ハヤカワ文庫SF)

彷徨える艦隊 ジェネシス 先駆者たち (ハヤカワ文庫SF)

ActionCable のクライアントも Ruby で書きたい(前篇)〜Opal覚え書き

Action Cable を使いたいときがあります。 Ruby on Rails の app を書いているのですから、サーバもクライアントも Ruby で書くのが自然です。

というわけで。Opal を導入してみました。

Rails app を用意する

rails new コマンドで適当な Rails app を作成します。 Action Cable は有効にしておきます。

webpacker を利用する方法も調べたのですが、いまひとつうまくいかなかったので今回は Sprockets を利用する方法を書きます。

Rails に Opal を追加する

opal-rails gem を使います。

詳細はドキュメントにありますのでそちらを確認してください。

Gemfile に gem 'opal-rails' を追加してインストールします。

config/initializers/assets.rb を編集してオプションを指定します。ひとまず README にある通りの内容を追加します。

Rails.application.config.opal.method_missing           = true
Rails.application.config.opal.optimized_operators      = true
Rails.application.config.opal.arity_check              = !Rails.env.production?
Rails.application.config.opal.const_missing            = true
Rails.application.config.opal.dynamic_require_severity = :ignore

app/assets/javascripts/application.jsapp/assets/javascripts/application.js.rb に rename して内容を編集します…というか、JS から Ruby への書き直しなので前者を削除して新しくファイルを作成すると考えた方がよいです。

  • Ruby の文法で書く
  • rails-ujsopal_ujs に変更する
  • 先頭に require 'opal' を追加する。
require 'opal'
require 'opal_ujs'
require 'turbolinks'
require_tree '.'

動作を確認します。 app/assets/javascripts/ に次のような .rb のファイルを追加して Rails app のサーバを起動します。

# app/assets/javascripts/hello.rb

puts 'Hello, Opal!'

正しく動作すればブラウザのコンソールに puts した文字列が出力されます。

Action Cable を使う

対比のために、まず Opal を利用しない版を書きます。

Action Cable の channel を追加する

rails g channel コマンドを利用して channel を追加します。

$ bin/rails g channel mumbler

サーバ側の app/channels/mumbler_channel.rb とクライアント側の app/assets/javascripts/channels/mumbler.js が追加されます。

それぞれ次のように編集します。

class MumblerChannel < ApplicationCable::Channel
  def subscribed
    stream_for 'any_channel'
  end

  def unsubscribed
  end

  def mumble(data)
    MumblerChannel.broadcast_to 'any_channel', {monologue: data['monologue']}
  end
end
function connected() {
}

function disconnected() {
}

function received(data) {
  const monologue = document.createElement('li')
  monologue.appendChild(document.createTextNode(data.monologue))
  const monologues = document.getElementById('monologues')
  monologues.prepend(monologue)
}

const handlers = { connected, disconnected, received }
App.mumbler = App.cable.subscriptions.create('MumblerChannel', handlers)

window.addEventListener('load', () => {
  const munbleButton = document.getElementById('mumbleButton')
  mumbleButton.addEventListener('click', () => {
    App.mumbler.perform('mumble', {monologue: document.getElementById('monologue').value})
  })
})

Action Cable を利用するページを追加する

上で書いた Action Cable を利用する monologues というページを追加します。

config/routes.rbmonologues のページのルーティングを追加します

  get 'monologues', to: 'monologues#index'

コントローラ app/controllers/monologues_controller.rb を追加します。

class MonologuesController < ApplicationController
  def index
  end
end

ビュー app/views/monologues/index.html.erb を追加します。

<h1>Monologues</h1>

<input type="text" id="monologue"></input>
<button type="submit" id="mumbleButton">MUMBULE</button>
<ul id="monologues"></ul>

Rails app サーバを起動します。 正しく動作すれば入力欄とボタンが表示され、テキストを入力してボタンを押すとそのテキストがリストに追加されます。

複数のウィンドウでこのページを開くとボタンを押すたびに同時にテキストが追加されることが確認できます。

f:id:E_Mattsan:20190330114206p:plain

Action Cable のクライアントを Ruby(Opal) で書き直す

ファイル名を app/assets/javascripts/channels/mumbler.js から app/assets/javascripts/channels/mumbler.js.rb に変更して、内容を次のように書き換えます。

Window = Native(`window`)
Document = Native(`document`)

handlers = {
  connected: -> () { },
  disconnected: -> () { },
  received: -> (data) {
    monologue = Document.createElement('li')
    Native(`monologue`).appendChild(Document.createTextNode(Native(`data`).monologue))
    monologues = Document.getElementById('monologues')
    monologues.prepend(monologue)
  }
}

mumbler = Native(`App`).cable.subscriptions.create('MumblerChannel', handlers)

Window.addEventListener('load', -> (_) {
  mumbleButton = Document.getElementById('mumbleButton')
  mumbleButton.addEventListener('click', ->(_) {
    mumbler.perform('mumble', {monologue: Document.getElementById('monologue').value})
  })
})

ページを読み込み直すと同じように動作することが確認できます。

Ruby でクライアントが書けました。

…。

これだけだとあまり嬉しくないかもしれませんが、これで Ruby(Opal) で書かれたフレームワークを利用することができるようになりました。

続く。

いつか読むはずっと読まない:暗黒通信団

PhoenixChannel のクライアントを Elixir で書いてみたい、という衝動もあります。

まぁそれは、 Elm を使おうかな…。

Erlangで言語処理系作成

Erlangで言語処理系作成

Apex + AWS Lambda + Ruby 覚書

Ruby で書いた AWS Lambda の関数を Apex を使ってデプロイできたので、その時の覚書です。 誤りや認識間違いが混ざっているかもしれません。ご指摘いただけたら幸いです。

仕事ではずっと Serverless + nodejs を使っていたのでこちらの方が慣れているのですが、別件で Apex を使うことになったのでその学習も兼ねて。

インストールとか

詳細は公式サイトを参照してください。

$ curl https://raw.githubusercontent.com/apex/apex/master/install.sh | sh

プロジェクトを作成する

適当なディレクトリを作成して移動し、apex init コマンドを実行します。 プロジェクト名とその説明の入力を求められるので、適当な内容を入力します。

$ mkdir apex-ruby
$ cd apex-ruby/
$ apex init


             _    ____  _______  __
            / \  |  _ \| ____\ \/ /
           / _ \ | |_) |  _|  \  /
          / ___ \|  __/| |___ /  \
         /_/   \_\_|   |_____/_/\_\



  Enter the name of your project. It should be machine-friendly, as this
  is used to prefix your functions in Lambda.

    Project name: apex-ruby

  Enter an optional description of your project.

    Project description: Apex + AWS Lambda + Ruby

  [+] creating IAM apex-ruby_lambda_function role
  [+] creating IAM apex-ruby_lambda_logs policy
  [+] attaching policy to lambda_function role.
  [+] creating ./project.json
  [+] creating ./functions

  Setup complete, deploy those functions!

    $ apex deploy

作成されるディレクトリとファイルはこんな感じ。

$ tree
.
├── functions
│   └── hello
│       └── index.js
└── project.json

project.json の内容はこんな感じ。

{
  "name": "apex-ruby",
  "description": "Apex + AWS Lambda + Ruby",
  "memory": 128,
  "timeout": 5,
  "role": "arn:aws:iam::548673361492:role/apex-ruby_lambda_function",
  "environment": {}
}

function/hello/index.js は不要なので削除します。

$ rm functions/hello/index.js

関数を作成する

function/hello/index.rb を作成してエントリポイントを記述します。

エントリポイントは eventcontext をキーワード引数で受け取るトップレベルのメソッドかクラスメソッドとして定義します。

def handler(event:, context:)
  'Hello, Apex + AWS Lambda + Ruby!'
end

念のため動作を確認。

$ ruby -r './functions/hello/index.rb' -e 'puts handler(event: nil, context: nil)'
Hello, Apex + AWS Lambda + Ruby!

設定を記述する

functions/hello/ の下に function.json というファイルを作成し設定を記述します。

{
  "runtime": "ruby2.5",
  "handler": "index.handler"
}

runtimeAWS Lambda で利用するランタイムです。今回は Ruby を使うので ruby2.5 を指定します。 handler は実行時に呼び出されるメソッドを指定します。トップレベルのメソッドの場合は ファイル名.メソッド名 という形式で、クラスメソッドの場合は ファイル名.クラス名.メソッド名 という形式で指定します。

デプロイする

apex deploy コマンドでデプロイします。

$ apex deploy
   • creating function         env= function=hello
   • created alias current     env= function=hello version=1
   • function created          env= function=hello name=apex-ruby_hello version=1

デプロイ状況を確認します。ここでは AWS CLI で関数名の一覧を取得して確認しています。

$ aws lambda list-functions --query Functions[].FunctionName
[
    "apex-ruby_hello"
]

関数名は プロジェクト名_(functionsの下の)ディレクトリ名 という形式になっています。

実行する

apex invoke コマンドで実行します。指定する関数名は AWS Lambda の関数名ではなく、Apex 内の名前になります。

$ apex invoke hello
"Hello, Apex + AWS Lambda + Ruby!"

削除する

apex delete コマンドで削除します。

$ apex delete
Are you sure? (yes/no) yes
   • deleting                  env= function=hello
   • function deleted          env= function=hello

AWS SDK for Ruby を使う

AWS SDK for Ruby はランタイムに含まれているので require するだけで利用することができます。

require 'aws-sdk-lambda'

def handler(event:, context:)
  Aws::Lambda::GEM_VERSION
end

再デプロイして実行します。

$ apex deploy
$ apex invoke hello
"1.15.0"

なお関数をデプロイしただけでは色々と許可されていないので、このままでは AWS のリソースにアクセスしようとするとエラーになってしまいます。

require 'aws-sdk-lambda'

def handler(event:, context:)
  # AWS Lambda の関数を一覧する
  client = Aws::Lambda::Client.new
  resp = client.list_functions
  resp.functions.map(&:function_name).join(',')
end
$ apex deploy
$ apex invoke hello
   ⨯ Error: function response: User: arn:aws:sts::xxxxxxxxxxxx:assumed-role/apex-ruby_lambda_function/apex-ruby_hello is not authorized to perform: lambda:ListFunctions on resource: *

Terraform で色々設定する

Apex 自身にはこれらを設定する機能はありませんが、Terraform を利用して解決します。

プロジェクト内にディレクトinfrastructure を作成し、そこに Terraform の設定を記述します。

ポリシーの設定を infrastructure/main.tf に記述します。ここで roleproject.json にある role の内容を記述します。

resource "aws_iam_role_policy" "lambda_policy" {
  name = "lambda_policy"
  role = "apex-ruby_lambda_function"
  policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "lambda:*",
      "Resource": "*"
    }
  ]
}
EOF
}

適用は infrastructure ディレクトリに移動して terraform コマンドを実行する代わりに、apex infra initapex infra apply を実行することで実行できます。

$ apex infra init
$ apex infra apply

これでリソースにアクセスできるようになります。

$ apex invoke hello
"apex-ruby_hello"

これらは関数のデプロイとは独立しているので、適用や更新や削除はそれぞれ実行する必要があります。

gem を利用する

標準添付ライブラリと AWS SDK for Ruby 以外の gem を利用する場合はインストールする必要があります。

Faker を使う例で試してみます。

require 'faker'

def handler(event:, context:)
  Faker::Name.name
end

このままでデプロイ、実行すると、予想通り gem を読み込めずにエラーになります。

$ apex deploy
$ apex invoke hello
   ⨯ Error: function response: cannot load such file -- faker

Ruby のバージョンを AWS Lambda に合わせる

gem をインストールする前に、Ruby のバージョンを AWS Lambda のランタイムに合わせておきます。 rbenv などを利用してバージョンを切り替えます。

$ rbenv local 2.5.3

Gemfile を作成する

関数のディレクトリに移動し bundle init を実行するなどして Gemfile を作成します。

$ cd functions/hello/
$ bundle init

Gemfilefaker を追加します。

# frozen_string_literal: true

source 'https://rubygems.org'

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

gem 'faker'

gem をインストールする

Apex は関数を定義しているディレクトリの内容を AWS Lambda にアップロードします。 --path オプションで vendor/bundle/ 以下にインストールするか、Gemfile.lock 生成後に --deployment オプションを指定してインストールを実行します。

この辺りの詳細については Bundler のドキュメントを確認してください。

$ bundle --path vendor/bundle
Fetching gem metadata from https://rubygems.org/..
Using bundler 2.0.1
Fetching concurrent-ruby 1.1.4
Installing concurrent-ruby 1.1.4
Fetching i18n 1.5.3
Installing i18n 1.5.3
Fetching faker 1.9.3
Installing faker 1.9.3
Bundle complete! 1 Gemfile dependency, 4 gems now installed.
Bundled gems are installed into `./vendor/bundle`

インストールできたらプロジェクトのディレクトリに戻ります。

あらためてデプロイする

gem をインストールした状態でデプロイすると、gem も合わせてアップロードされます。

$ apex deply

実行すると gem がロードできないというエラーは出なくなりますが、タイムアウトが発生すると思います。

$ apex invoke hello
   ⨯ Error: function response: 2019-02-23T02:31:00.996Z fc0f76c0-2238-434b-8a21-722917e1042f Task timed out after 5.00 seconds

Apex はタイムアウトの初期値として 5 秒を設定していますが、初回の起動時に 10 秒あまりかかるためタイムアウトしてしまいます。 project.jsontimeout の値を 15 程度に変更してから再度デプロイし実行します。

$ apex invoke hello
"Dwayne Murphy DVM"

無事起動しました。

gem のインストールを自動化する

Apex のフック機能を利用して gem のインストールを自動化します。

function.jsonhooks を追加します。build--deployment オプションを付けてのインストールを指定します。デプロイ後に「凍結」を解除したいので frozen の削除を clean で指定しています。

{
  "runtime": "ruby2.5",
  "handler": "index.handler",
  "hooks": {
    "build": "bundle install --deployment",
    "clean": "bundle config --delete frozen"
  }
}

native extension を利用する gem を利用する場合

native extension を利用する gem の場合は実行環境と同等の環境でインストールする必要がありますが、Docker で同等の環境を入手することができるのでこれを利用すれば可能になります。

この中でタグ build-ruby2.5 を指定すると Ruby もインストールされた環境を入手できます。

環境を切り替える

開発版やリリース版などの環境を切り替える場合、設定ファイルに環境名を追加します。また Terraform は環境ごとのディレクトリを作成します。

ここまでのファイルの内容:

apex-ruby/
├── functions/
│   └── hello/
│       ├── Gemfile
│       ├── Gemfile.lock
│       ├── function.json
│       └── index.rb
├── infrastructure/
│   └── main.tf
└── project.json

これを developmentproduction に分離します。

apex-ruby/
├── functions/
│   └── hello/
│       ├── Gemfile
│       ├── Gemfile.lock
│       ├── function.development.json
│       ├── function.production.json
│       └── index.rb
├── infrastructure/
│   ├── development/
│   │   └── main.tf
│   └── production/
│       └── main.tf
├── project.development.json
└── project.production.json

project.環境名.json で指定する name の値など、環境によって内容が異なるものはそれぞれの環境用の値を設定します。

適用する環境は、コマンドの実行時に --env オプションで指定します。

$ apply --env development deploy

apply infra コマンドで --env を指定すると、infrastructure ディレクトリの下の環境名のディレクトリの内容が適用されます。

いつか読むはずっと読まない:2019-02-22、タッチダウン

想像していたよりもガチな内容で楽しめました。曰く「相模原でカプセルのフタを開けるまでが遠足です。まだまだ長いです」。今からでも副読本にどうぞ。

現代萌衛星図鑑

現代萌衛星図鑑

現代萌衛星図鑑 第2集

現代萌衛星図鑑 第2集