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

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

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集

Prolog でメモをしながらフィボナッチ数を計算する

いつものように処理系は GNU Prolog です。

コード

fib.prolog を次のように用意します。

:- dynamic(fib/2).

fib(1, 1) :- !.
fib(2, 1) :- !.
fib(N, F) :-
  N1 is N - 1,
  N2 is N - 2,
  fib(N1, F1),
  fib(N2, F2),
  F is F1 + F2,
  asserta((fib(N, F) :- !)).

実行

GNU Prolog を起動します。

$ gprolog
GNU Prolog 1.4.5 (64 bits)
Compiled Jul 14 2018, 19:58:18 with clang
By Daniel Diaz
Copyright (C) 1999-2018 Daniel Diaz
| ?- 

コードを読み込みます。

| ?- ['fib.prolog'].

(1 ms) yes

計算します。

| ?- fib(5, F).

F = 5

yes

ここで clause/2 を使って fib/2 の定義の状態を確認します。

findall/3 を利用して任意の NF の組み合わせに対して fib/2 で定義されているものを集めています。

| ?- findall((N, F), clause(fib(N, F), _Body), NFS).

NFS = [(5,5),(4,3),(3,2),(1,1),(2,1),(_,_)]

yes

コードでは変数を受けて計算をするケース以外では N = 1, F = 1, N = 2, F = 1 という組み合わせしか定義していませんが、fib(5, F) の実行後は N = 5, F = 5, N = 4, F = 3, N = 3, F = 2 という定義が存在していることがわかります。

さらに fib(10, F) を計算して様子を確認します。

| ?- fib(10, F).

F = 55

yes
| ?- findall((N, F), clause(fib(N, F), _Body), NFS).

NFS = [(10,55),(9,34),(8,21),(7,13),(6,8),(5,5),(4,3),(3,2),(1,1),(2,1),(_,_)]

yes

N = 10, F= 55 までの定義が増えていることがわかります。

retractall/1 ですべての定義を取り消します。

| ?- retractall(fib(_, _)).

yes

この状態で確認すると、すべての定義が消えていることがわかります。

| ?- findall((N, F), clause(fib(N, F), _Body), NFS).

NFS = []

yes

fib/2 を呼び出しても受理されません。

| ?- fib(2, F).

no

再びコードを読み込み定義の内容を確認します。

| ?- ['fib.prolog'].                                

(1 ms) yes
| ?- findall((N, F), clause(fib(N, F), _Body), NFS).

NFS = [(1,1),(2,1),(_,_)]

yes

最初の状態に戻りました。

解説

dynamic/2

dynamic/2 ディレクティブで fib/2 を動的に定義することを宣言しています。dynamic/2 で指定しなくても述語を動的に定義することはできますが、そのばあい fib(1, 1) :- ! といった記述も動的に定義する必要があります。

asserta/1

asserta/1 で動的に述語を定義しています。 asserta((fib(N, F) :- !)) の内側の括弧は fib(N, F) :- ! の部分が一つの引数とわかるようにするためのもので、省略することはできません。 asserta/1 は一連の定義の先頭にあたらしい定義を追加します。 ですので fib(5, F) を実行した後は次のように記述されたのと同じ状態になっています。

fib(5,5) :- !.
fib(4,3) :- !.
fib(3,2) :- !.
fib(1,1) :- !.
fib(2,1) :- !.
fib(N, F) :-
  % 略

よく似た assertz/1 は末尾に定義を追加します。

いつか読むはずっと読まない:Prolog Programming for Artificial Intelligence

1986 年に発行された「Prolog Programming for Artificial Intelligence」を二分冊した邦訳本。 一冊目を昨年入手して読んでいますが、改めて Prolog のおもしろさに気付かされます。

Prologへの入門 (PrologとAI)

Prologへの入門 (PrologとAI)

AIプログラミング (PrologとAI)

AIプログラミング (PrologとAI)

二冊目は未だ入手できず。

Ectoを使ってデータベースを操作する (v2.2)

最初に。

昨年 2018 年 6 月に Ecto の使い方について書いていたのですが、書きかけのまま放置しているうちにメジャーバージョンが上がってしまい、最新の 3.0 ではここに書いてある内容が当てはまらなくなるという事態になりました。なにより sqlite_ecto2 アダプタが(その名の通り)Ecto のバージョン 2 向けのため、Ecto バージョン 3 では利用できません。

とはいえ、まだバージョン 2 が利用できなくなったわけでないですし、アダプタの対応などを除けば使い方はあまり変わらないので、覚書として公開しておきます。リンクのみバージョン 2 の最新 2.2.11 のドキュメントを指すように変更しました。

2 と 3 の違いについては 最新のドキュメントGitHubの CHANGELOG.md などを確認してください。

バージョン 3 についてもそのうち書くと思います。たぶん。

Ecto 2.2

Phoenix ではデータベースの操作のために標準で Ecto を利用するので、作成したプロジェクトは Ecto を利用するための設定が済んでいます。これを手作業で設定してみる試みです。

設定までの手順の話になりますのですので、Ecto の利用方法自体はドキュメントを読んでみてください。

印象としては、意外に簡単に利用することができました。が、意外にコードを書いた気分。

ドキュメント

公式ドキュメントです。ここを読めばだいたい使えるようになります。

手順

プロジェクトを作る

mix new で新しいプロジェクトを用意します。Ecto ではデータベースの操作のためのプロセスを起動することになるので、--sup オプションを指定しておきます。

$ mix new my_db --sup
$ cd my_db

依存パッケージに Ecto を追加する

mix.exs に Ecto を追加します。

  defp deps do
    [
      {:ecto, "~> 2.2"}
    ]
  end

パッケージを取得してコンパイルします。

$ mix do deps.get, deps.compile

Ecto を利用するための各種コマンドが追加されます。 mix ecto で利用できるコマンドの一覧が表示されます。

$ mix ecto
Ecto v2.2.10
A database wrapper and language integrated query for Elixir.

Available tasks:

mix ecto.create        # Creates the repository storage
mix ecto.drop          # Drops the repository storage
mix ecto.dump          # Dumps the repository database structure
mix ecto.gen.migration # Generates a new migration for the repo
mix ecto.gen.repo      # Generates a new repository
mix ecto.load          # Loads previously dumped database structure
mix ecto.migrate       # Runs the repository migrations
mix ecto.migrations    # Displays the repository migration status
mix ecto.rollback      # Rolls back the repository migrations

リポジトリを作成する

mix ecto.gen.repo コマンドでリポジトリを作成します。

$ mix ecto.gen.repo -r MyDb.Repo
* creating lib/my_db
* creating lib/my_db/repo.ex
* updating config/config.exs
Don't forget to add your new repo to your supervision tree
(typically in lib/my_db/application.ex):

    supervisor(MyDb.Repo, [])

And to add it to the list of ecto repositories in your
configuration files (so Ecto tasks work as expected):

    config :my_db,
      ecto_repos: [MyDb.Repo]

lib/my_db/repo.ex が作成され、config/config.exs にデータベースにアクセスするための設定が追加されます。 デフォルトでは PostgreSQL を利用する設定になっています。

config :my_db, MyDb.Repo,
  adapter: Ecto.Adapters.Postgres,
  database: "my_db_repo",
  username: "user",
  password: "pass",
  hostname: "localhost"

作成時のメッセージにあるように、config/config.exs に設定を追加します。

config :my_db,
  ecto_repos: [MyDb.Repo]

アプリケーションが起動した時に Ecto のプロセスも起動するように、 lib/my_db/application.ex を編集して、監視するプロセスに MyDb.Repo を追加します。

  def start(_type, _args) do
    children = [
      MyDb.Repo
    ]

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

アダプタを追加する

データベースを操作するために Ecto 以外にアダプタのパッケージが必要になります。 自動的に追加された設定では PostgreSQL を利用するようになっていますが、ここでは SQLite3 を使ってみます。データベースがファイルに保存されて、プロジェクトのディレクトリで完結するので後始末が楽なので。

Hexsqlite で検索すると、Ecto 用のアダプタとして sqlite_ecto2 がヒットします。これを利用します。

mix.exs を編集します。

  defp deps do
    [
      {:ecto, "~> 2.2"},
      {:sqlite_ecto2, "~> 2.2"}
    ]
  end

パッケージを取得します。

$ mix deps.get

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

config :my_db, MyDb.Repo,
  adapter: Sqlite.Ecto2,
  database: "my_db.sqlite3"

テーブルを追加する

mix ecto.gen.migration コマンドを利用して、テーブルを追加するマイグレーションファイルを作成します。

$ mix ecto.gen.migration books
Generated my_db app
* creating priv/repo/migrations
* creating priv/repo/migrations/20180619123954_books.exs

先頭に日付と時刻の数字の並びを持つマイグレーションファイルが作成されますので、ここにマイグレーションを記述します。 マイグレーションの記述方法は Ecto.Migration のドキュメントに記載されています。

ここではテーブル books の作成を記述しました。

defmodule MyDb.Repo.Migrations.Books do
  use Ecto.Migration

  def change do
    create table("books") do
      add :title, :string, null: false
      add :bought_at, :naive_datetime
      add :price, :integer
he
      timestamps()
    end
  end
end

マイグレーションを実行します。

$ mix ecto.migrate

スキーマファイルを作成する

テーブル books を Ecto で操作するためのスキーマファイル lib/my_db/book.ex を作成します。

スキーマの記述方法は Ecto.Schema のドキュメントに記載されています。

defmodule MyDb.Book do
  use Ecto.Schema

  schema "books" do
    field :title, :string
    field :bought_at, :naive_datetime
    field :price, :integer

    timestamps
  end
end

データベースを操作する

iex で操作してみます。

$ iex -S mix

books の全レコードを取得します。

iex(1)> MyDb.Repo.all(MyDb.Book)

21:54:48.558 [debug] QUERY OK source="books" db=41.6ms
SELECT b0."id", b0."title", b0."bought_at", b0."price", b0."inserted_at", b0."updated_at" FROM "books" AS b0 []
[]

まだレコードを作成していないので空のリストが返ります。

レコードを追加してみます。

iex(2)> MyDb.Repo.insert(%MyDb.Book{title: "ネコと鴎の王冠", price: 734, bought_at: ~N[2017-11-22 00:00:00]})

22:03:22.132 [debug] QUERY OK db=99.0ms queue=0.1ms
INSERT INTO "books" ("bought_at","price","title","inserted_at","updated_at") VALUES (?1,?2,?3,?4,?5) ;--RETURNING ON INSERT "books","id" [{{2017, 11, 22}, {0, 0, 0, 0}}, 734, "ネコと鴎の王冠", {{2018, 6, 19}, {13, 3, 22, 32306}}, {{2018, 6, 19}, {13, 3, 22, 32319}}]
{:ok,
 %MyDb.Book{
   __meta__: #Ecto.Schema.Metadata<:loaded, "books">,
   bought_at: ~N[2017-11-22 00:00:00],
   id: 1,
   inserted_at: ~N[2018-06-19 13:03:22.032306],
   price: 734,
   title: "ネコと鴎の王冠",
   updated_at: ~N[2018-06-19 13:03:22.032319]
 }}

ここで日時の指定には ~N sigil を利用してます。

もう一度全レコードを取得してみます。

iex(3)> MyDb.Repo.all(MyDb.Book)

22:05:54.874 [debug] QUERY OK source="books" db=1.1ms decode=0.1ms
SELECT b0."id", b0."title", b0."bought_at", b0."price", b0."inserted_at", b0."updated_at" FROM "books" AS b0 []
[
  %MyDb.Book{
    __meta__: #Ecto.Schema.Metadata<:loaded, "books">,
    bought_at: ~N[2017-11-22 00:00:00.000000],
    id: 1,
    inserted_at: ~N[2018-06-19 13:03:22.032306],
    price: 734,
    title: "ネコと鴎の王冠",
    updated_at: ~N[2018-06-19 13:03:22.032319]
  }
]

レコードをもう一つ登録。

iex(4)> MyDb.Repo.insert(%MyDb.Book{title: "時空のゆりかご", price: 1188, bought_at: ~N[2018-02-28 00:00:00]})

22:19:13.797 [debug] QUERY OK db=35.1ms
INSERT INTO "books" ("bought_at","price","title","inserted_at","updated_at") VALUES (?1,?2,?3,?4,?5) ;--RETURNING ON INSERT "books","id" [{{2018, 2, 28}, {0, 0, 0, 0}}, 1188, "時空のゆりかご", {{2018, 6, 19}, {13, 19, 13, 759710}}, {{2018, 6, 19}, {13, 19, 13, 759722}}]
{:ok,
 %MyDb.Book{
   __meta__: #Ecto.Schema.Metadata<:loaded, "books">,
   bought_at: ~N[2018-02-28 00:00:00],
   id: 2,
   inserted_at: ~N[2018-06-19 13:19:13.759710],
   price: 1188,
   title: "時空のゆりかご",
   updated_at: ~N[2018-06-19 13:19:13.759722]
 }}

全レコードを取得。

iex(5)> MyDb.Repo.all(MyDb.Book)

22:19:43.295 [debug] QUERY OK source="books" db=1.2ms decode=0.1ms
SELECT b0."id", b0."title", b0."bought_at", b0."price", b0."inserted_at", b0."updated_at" FROM "books" AS b0 []
[
  %MyDb.Book{
    __meta__: #Ecto.Schema.Metadata<:loaded, "books">,
    bought_at: ~N[2017-11-22 00:00:00.000000],
    id: 1,
    inserted_at: ~N[2018-06-19 13:03:22.032306],
    price: 734,
    title: "ネコと鴎の王冠",
    updated_at: ~N[2018-06-19 13:03:22.032319]
  },
  %MyDb.Book{
    __meta__: #Ecto.Schema.Metadata<:loaded, "books">,
    bought_at: ~N[2018-02-28 00:00:00.000000],
    id: 2,
    inserted_at: ~N[2018-06-19 13:19:13.759710],
    price: 1188,
    title: "時空のゆりかご",
    updated_at: ~N[2018-06-19 13:19:13.759722]
  }
]

レコード数を確認します。

iex(6)> MyDb.Book |> select([b], count(b.id)) |> MyDb.Repo.one()

22:20:12.655 [debug] QUERY OK source="books" db=1.0ms
SELECT count(b0."id") FROM "books" AS b0 []
2

だいたいこんな感じで。

いつか読むはずっと読まない:キツネと熊は何処

Markdown から EPUB を作る

Markdown から EPUB を生成する方法の備忘録です。

Markdown から EPUB に変換するなら、md2review + Re:VIEW という強力な組み合わせがあります。

…が、今回は gepub という gem を使って生成してみました。 ここにあげたコードは gist.github.com に全体をアップしています。

gem を用意する

今回利用する gem、ライブラリです。

  • redcarpet
    • Markdown から HTML への変換に利用します。
  • nokogiri
    • HTML から XHTML への変換と要素の編集に利用します。
  • securerandom
    • EPUB に設定する identifier の値を生成するために利用します。
  • gepub
    • EPUB の生成に利用します。
require 'redcarpet'
require 'nokogiri'
require 'securerandom'
require 'gepub'

Markdown を分割する

今回はレベル 1 の見出しを各章の始まりとして扱うようにしました。 そのためまず一つの Markdown を章単位に分割していきます。 なおこのコードでは、レベル 1 の見出しがないケースや、レベル 1 の見出しと見出しの間に文章が含まれないようなケースは考慮してません。

Chapter = Struct.new("Chapter", :title, :body)

def split_md_into_chapters(source)
  result = []
  title = nil
  begin
    body, next_title, rest = source.partition(%r{^# .*\n})
    result << Chapter.new(title, "# #{title}\n#{body}") unless body.empty?
    title, source = next_title[2..-2], rest
  end until source.empty?

  result
end

各章を XHTML に変換する

redcarpet を利用して Markdown から HTML に変換し、nokogiri を利用して HTML から XHTML に変換しています。 redcarpet にも XHTML 形式で出力する機能がありますが、EPUB で利用するには厳密に XML の仕様にそっている必要があるようです。 また EPUB のビューアによっては title 要素が含まれていないと正しく表示できないようです。ここではレベル 1 の見出しの内容をタイトルとして設定しています。

def convert_to_xhtml(chapter)
  markdown = Redcarpet::Markdown.new(Redcarpet::Render::HTML.new, autolink: true, tables: true, fenced_code_blocks: true, prettify: true)
  html = Nokogiri::HTML.parse( markdown.render(chapter.body))
  html.title = chapter.title
  html.to_xhtml
end

XHTML をまとめて EPUB で出力する

gepub を利用して EPUB を生成します。 ここで identifier の設定は必須になっていますが空文字列でも生成できました。ただしビューアによっては identifier が空文字列になっていると正しく表示できないようです。

画像ファイルを含めたい場合は book.ordered ブロックの外で book.add_item を利用して追加します。このとき指定するパスはテキスト内から参照しているパスと合わせる必要があります。 また各ファイルを格納するパスは book.add_item("#{index}.xhtml") のように直下に配置してしまうと、ビューアによってはうまく参照できないようで、コードのようにサブディレクトリに配置するのがよさそうです。

book.generate_epubEPUB をファイルに出力しています。 book.generate_epub_stream を利用するとファイルでなくストリームとして結果を取得できます。このとき結果として返るストリームはカーソルが終端に移動したままになっているようなので、seek(0) で先頭に移動してから読み出す必要があります。

def generate_epub_from_markdown(source)
  book = GEPUB::Book.new {|book|
    book.identifier = "md2epub-#{SecureRandom.uuid}"
    book.title      = 'md2epub - Markdown から EPUB を作る'

    book.ordered do
      split_md_into_chapters(source).each_with_index do |chapter, index|
        xhtml = convert_to_xhtml(chapter)

        book.add_item("text/#{index}.xhtml")
            .add_content(StringIO.new(xhtml))
            .toc_text(chapter.title)
      end
    end
  }

  book.generate_epub('md2epub.epub')
end

実行

簡単な Markdownepub に変換してみます。

generate_epub_from_markdown(<<~MARKDWON)
# Markdown を分割する

レベル 1 の見出しを一つの章として扱うように分割します。

# 各章を XHTML に変換する

gem [redcarpet](https://github.com/vmg/redcarpet) と [nokogiri](https://www.nokogiri.org) を利用して markdown から XHTML に変換します。

```ruby
markdown = Redcarpet::Markdown.new(Redcarpet::Render::HTML.new, autolink: true, tables: true)
html = Nokogiri::HTML.parse(markdown.render(source))
html.title = title
xhtml = html.to_xhtml
```

# XHTML をまとめて EPUB で出力する

gem [gepub](https://github.com/skoji/gepub) を利用して XHTML から EPUB に変換します。
MARKDWON

生成された EPUB ファイルを Mac 標準の Books で開くとこのように表示されます。

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

もう展示は終わってしまいましたが、とても興味深い内容でした。

企画展「標本づくりの技(ワザ)-職人たちが支える科博-」

へんなものみっけ! (1) (ビッグコミックス)

へんなものみっけ! (1) (ビッグコミックス)

へんなものみっけ! (2) (ビッグコミックス)

へんなものみっけ! (2) (ビッグコミックス)

へんなものみっけ! (3) (ビッグコミックス)

へんなものみっけ! (3) (ビッグコミックス)

偽クフ語解読器をPrologで書く。

偽クフ語とはなにか。

白と黒のとびら」第11章をご参照ください。

本当はコード上も●と○の列で表現したかったのですが、あつかいがめんどうだったので●を 1 に○を 0 に置き換えてコードしています。 処理系はいつものように GNU-Prolog です

解読器

% 偽クフ語解読器
% decode.prolog

% 規則

% 1. S -> U 011 U (U 'ならば' U)
s(LHS, RHS) :-
  append(U1, [0'0, 0'1, 0'1 | U2], LHS),
  u(U1, T_U1),
  u(U2, T_U2),
  append(T_U1, ['ならば' | T_U2], RHS).

% 2. S -> M 01 U (M 'は' U)
s(LHS, RHS) :-
  append(M, [0'0, 0'1 | U], LHS),
  m(M, T_M),
  u(U, T_U),
  append(T_M, ['は' | T_U], RHS).

% 3. U -> M V
u(LHS, RHS) :-
  append(M, V, LHS),
  m(M, T_M),
  v(V, T_V),
  append(T_M, T_V, RHS).

% 4. U -> M 1 V (M 'に' V)
u(LHS, RHS) :-
  append(M, [0'1 | V], LHS),
  m(M, T_M),
  v(V, T_V),
  append(T_M, ['に' | T_V], RHS).

% 5. U -> M 1 M V (M 'に' M V)
u(LHS, RHS) :-
  append(M1, [0'1 | M2V], LHS),
  append(M2, V, M2V),
  m(M1, T_M1),
  m(M2, T_M2),
  v(V, T_V),
  append(T_M2, T_V, T_M2V),
  append(T_M1, ['に' | T_M2V], RHS).

% 6. U -> U 111 U (U 'そして' U)
u(LHS, RHS) :-
  append(U1, [0'1, 0'1, 0'1 | U2], LHS),
  u(U1, T_U1),
  u(U2, T_U2),
  append(T_U1, ['そして' | T_U2], RHS).

% 7. M -> A N
m(LHS, RHS) :-
  append(A, N, LHS),
  a(A, T_A),
  n(N, T_N),
  append(T_A, T_N, RHS).

% 8. M -> N
m(N, T_N) :-
  n(N, T_N).

% 9. M -> M 010 M (M 'か' M)
m(LHS, RHS) :-
  append(M1, [0'0, 0'1, 0'0 | M2], LHS),
  m(M1, T_M1),
  m(M2, T_M2),
  append(T_M1, ['か' | T_M2], RHS).

% 10. V -> 動詞のいずれか
v("1001", ['見た']).
v("1010", ['変わる']).
v("10100", ['変える']).
v("0011", ['昇る']).
v("1100", ['降りる']).
v("1101", ['留まる']).

% 11. N -> 名詞のいずれか
n("11010", ['1月']).
n("101010", ['2月']).
n("111010", ['3月']).
n("1001010", ['4月']).
n("1011010", ['5月']).
n("1101010", ['6月']).
n("00", ['満月']).
n("11", ['新月']).
n("1101", ['時']).
n("11100", ['希望']).
n("000", ['白']).
n("010", ['黄色']).
n("001", ['薄緑色']).
n("111", ['黒']).
n("101", ['紫色']).
n("110", ['青']).
n("00000", ['無']).
n("0111", ['上']).
n("1000", ['下']).
n("1111", ['ここ']).
n("0100", ['人']).

% 12. A -> 形容詞のいずれか
a("00000", ['正しい']).
a("00010", ['白い']).
a("01010", ['黄色い']).
a("00110", ['薄緑色の']).
a("11110", ['黒い']).
a("10110", ['紫色の']).
a("11010", ['青い']).

% 「クレージュ・レザン兄弟遺稿集(下) レザンの詩集」
poem("文 1", "00010001001011010110100111011110011").
poem("文 2", "11010110101011010").
poem("文 3", "10110111001011011110011").
poem("文 4", "110101101101011010").
poem("文 5", "000100001010110111001011011110011").
poem("文 6", "11110111001011101110100111011110011").
poem("文 7", "11010111101011010").
poem("文 8", "001100001011110111001011011110011").
poem("文 9", "00010001001011001110100111100011100").
poem("文10", "110101100101011010").
poem("文11", "000100001000110001001011100011100").
poem("文12", "111101101010110111001011100011100").
poem("文13", "01010001001011011110011").
poem("文14", "1101011101011010").
poem("文15", "101101101000110001001011011110011").
poem("文16", "000001001011100011100").
poem("文17", "110101110101011010").
poem("文18", "00000010001111111101").

% 結果を表示するための述語
show_sentence((Title, T)) :-
  format("~s ~p~n", [Title, T]).

% 解読した結果をすべて表示する
main :-
  findall(
    (Title, T),
    (poem(Title, S), s(S, T)),
    TS
  ),
  maplist(show_sentence, TS).

実行

第12章にあるように、それぞれの文は一意に決定できないので一つの文に対して複数の解読結果を出力しています。

$ gprolog --consult-file decode.prolog --entry-goal main --query-goal halt
文 1 [白い,満月,見た,ならば,黄色,に,変える,そして,上,に,昇る]
文 1 [満月,か,満月,見た,ならば,黄色,に,変える,そして,上,に,昇る]
文 1 [白,に,白,見た,ならば,黄色,に,変える,そして,上,に,昇る]
文 2 [時,は,2月,に,変わる]
文 2 [青い,新月,は,黄色,に,変わる]
文 2 [新月,か,新月,は,黄色,に,変わる]
文 3 [紫色の,新月,見た,ならば,上,に,昇る]
文 4 [時,は,紫色の,紫色,変わる]
文 4 [時,は,5月,に,変わる]
文 5 [満月,か,満月,か,紫色の,新月,見た,ならば,上,に,昇る]
文 5 [白い,満月,か,紫色の,新月,見た,ならば,上,に,昇る]
文 5 [満月,か,満月,か,紫色の,新月,見た,ならば,上,に,昇る]
文 5 [白,に,白,か,紫色の,新月,見た,ならば,上,に,昇る]
文 6 [黒い,新月,見た,ならば,紫色,に,変える,そして,上,に,昇る]
文 7 [時,は,3月,に,変わる]
文 8 [薄緑色の,満月,か,黒い,新月,見た,ならば,上,に,昇る]
文 8 [満月,に,下,か,黒い,新月,見た,ならば,上,に,昇る]
文 8 [薄緑色,に,白,か,黒い,新月,見た,ならば,上,に,昇る]
文 8 [薄緑色の,満月,か,ここ,は,青,か,青,に,黒,昇る]
文 8 [薄緑色の,満月,か,ここ,は,青,か,時,に,新月,昇る]
文 9 [白い,満月,見た,ならば,薄緑色,に,変える,そして,下,に,降りる]
文 9 [白い,満月,見た,ならば,満月,に,青,見た,そして,白,に,降りる]
文 9 [満月,か,満月,見た,ならば,薄緑色,に,変える,そして,下,に,降りる]
文 9 [満月,か,満月,見た,ならば,満月,に,青,見た,そして,白,に,降りる]
文 9 [白,に,白,見た,ならば,薄緑色,に,変える,そして,下,に,降りる]
文 9 [白,に,白,見た,ならば,満月,に,青,見た,そして,白,に,降りる]
文10 [時,は,4月,に,変わる]
文10 [青い,青,は,黄色,に,変わる]
文10 [新月,か,青,は,黄色,に,変わる]
文11 [満月,か,満月,か,薄緑色の,満月,見た,ならば,下,に,降りる]
文11 [白い,満月,か,薄緑色の,満月,見た,ならば,下,に,降りる]
文11 [満月,か,満月,か,薄緑色の,満月,見た,ならば,下,に,降りる]
文11 [白,に,白,か,薄緑色の,満月,見た,ならば,下,に,降りる]
文11 [満月,か,白い,満月,に,下,見た,ならば,下,に,降りる]
文11 [満月,か,満月,か,満月,に,下,見た,ならば,下,に,降りる]
文11 [白い,満月,か,満月,に,下,見た,ならば,下,に,降りる]
文11 [満月,か,満月,か,満月,に,下,見た,ならば,下,に,降りる]
文11 [満月,か,白い,薄緑色,に,白,見た,ならば,下,に,降りる]
文11 [満月,か,満月,か,薄緑色,に,白,見た,ならば,下,に,降りる]
文11 [白い,満月,か,薄緑色,に,白,見た,ならば,下,に,降りる]
文11 [満月,か,満月,か,薄緑色,に,白,見た,ならば,下,に,降りる]
文12 [黒い,新月,か,紫色の,新月,見た,ならば,下,に,降りる]
文12 [黒い,時,か,時,に,見た,ならば,下,に,降りる]
文13 [黄色い,満月,見た,ならば,上,に,昇る]
文13 [黄色,に,白,見た,ならば,上,に,昇る]
文14 [時,は,1月,に,変わる]
文15 [紫色の,新月,か,薄緑色の,満月,見た,ならば,上,に,昇る]
文15 [紫色の,新月,か,満月,に,下,見た,ならば,上,に,昇る]
文15 [紫色の,新月,か,薄緑色,に,白,見た,ならば,上,に,昇る]
文16 [無,見た,ならば,下,に,降りる]
文17 [新月,は,上,か,紫色,変わる]
文17 [時,は,青い,紫色,変わる]
文17 [時,は,新月,か,紫色,変わる]
文17 [時,は,6月,に,変わる]
文17 [青い,黒,は,黄色,に,変わる]
文17 [新月,か,黒,は,黄色,に,変わる]
文18 [正しい,人,は,ここ,に,留まる]
文18 [正しい,人,は,新月,に,新月,留まる]
| ?- halt.

Prolog たのしい。

いつか読むはずっと読まない:0 と 1 の Prolog

書籍のページ で第1章が公開されていますので、興味のある方はどうぞ。

gen_event でイベントを通知する/イベントをハンドリングする

Logger の backend を書いたときに利用した gen_event について調べたので、その覚書。

gen_event とは

Erlang が標準で提供しているモジュールです。 イベントをハンドリングする仕組みを提供してくれます。

複数のハンドラを登録しておくと、イベントがそれらのハンドラに通知されます。

と、いうわけで。書いてみます。

プロジェクトを用意する

gen_event を試すプロジェクトを用意します。 アプリケーションの起動時に gen_event のプロセスを起動したいので --sup オプションをつけて supervision tree の雛形を生成しておきます。

$ mix new notification --sup

gen_event のプロセスを起動するコードを追加する

lib/notification/application.ex を編集します。 children の内容を編集して gen_event を起動する設定を記述します。

defmodule Notification.Application do
  @moduledoc false

  use Application

  def start(_type, _args) do
    children = [
      %{
        id: :gen_event,
        start: {
          :gen_event,
          :start_link,
          [{:local, Notification}]
        }
      }
    ]

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

start の値のタプルは、gen_event のプロセスを起動する関数の呼び出し

:gen_event.start_link({:local, Notification})

を表しています。

iex を起動して、Notification.Supervisor が監視しているプロセスの情報を取得すると、gen_event が起動していることがわかります。

$ iex -S mix
iex(1)> Supervisor.which_children(Notification.Supervisor)
[{:gen_event, #PID<0.136.0>, :worker, [:gen_event]}]

ハンドラを書く

ハンドラのファイル lib/notification/handler.ex を追加して gen_event のコールバックを実装したモジュールを記述していきます。

モジュールのふるまい @behaviour:gen_event を指定します。

defmodule Notification.Handler do
  @behaviour :gen_event
end

この状態でコンパイルすると必要なコールバック関数が実装されていないと警告が表示されます。

$ mix compile
Compiling 1 file (.ex)
warning: function handle_call/2 required by behaviour :gen_event is not implemented (in module Notification.Handler)
  lib/notification/handler.ex:1

warning: function handle_event/2 required by behaviour :gen_event is not implemented (in module Notification.Handler)
  lib/notification/handler.ex:1

warning: function init/1 required by behaviour :gen_event is not implemented (in module Notification.Handler)
  lib/notification/handler.ex:1

Generated notification app

handle_call/2, handle_event/2, init/1 の 3 つの関数が必須なことがわかります。 それ以外には、プロセスの終了時に呼び出される terminate/2 や、 gen_event 以外の要因のメッセージが発生したときに呼び出される handle_info/2 、コードが更新されたときに呼び出される code_change/3 があります。が、今回は最小限で実装します。

defmodule Notification.Handler do
  @behaviour :gen_event

  require Logger

  def init(args) do
    name = get_in(args, [:name])
    Logger.info("#{name} initialized")
    {:ok, %{name: name}}
  end

  def handle_call(request, state) do
    Logger.info("#{state.name} called with #{request}")
    {:ok, {:ok, request}, state}
  end

  def handle_event(event, state) do
    Logger.info("#{state.name} received #{event}")
    {:ok, state}
  end
end

ログを出力するだけの実装です。

ハンドラを登録する

iex でアプリケーションを起動します。

$ iex -S mix
iex(1)>

gen_event のプロセスは Notification という名前ですでに起動しているので、ハンドラを登録してみます。

iex(1)> :gen_event.add_handler(Notification, Notification.Handler, name: "Handler1")

22:14:30.726 [info]  Handler1 initialized
:ok

Handler1 という名前をつけたハンドラが登録されました。

ハンドラに通知する

この状態で通知を送ってみます。

iex(2)> :gen_event.notify(Notification, "Hi")
:ok

22:15:24.201 [info]  Handler1 received Hi

:gen_event.notify/2 で通知を送ると、handle_event/2 が呼び出されたことがわかります。

:gen_event.call/3 で呼び出すと、handle_call/2 が呼び出されます。 こちらの呼び出しはハンドラのモジュールを指定する必要があります。 同期呼び出しになるので、handle_call/2 が返した値が :gen_event.call/3 の戻り値になります。

:gen_event.call(Notification, Notification.Handler, "Hi")

22:19:38.486 [info]  Handler1 called with Hi
{:ok, "Hi"}

ハンドラのモジュールの関数を呼び出しているだけのようにも見えますが、ハンドラが登録されていない状態で呼び出すとエラーになります。ハンドラが登録されていないと呼び出せないことがわかります。

$ iex -S mix
iex(1)> :gen_event.call(Notification, Notification.Handler, "Hi")
{:error, :bad_module}

複数のハンドラを登録し通知する

iex を起動しなおして、ハンドラを 3 つ名前を変えて登録してみます。

$ iex -S mix
iex(1)> :gen_event.add_handler(Notification, Notification.Handler, name: "Handler1")

22:24:54.356 [info]  Handler1 initialized
:ok
iex(2)> :gen_event.add_handler(Notification, Notification.Handler, name: "Handler2")

22:24:56.649 [info]  Handler2 initialized
:ok
iex(3)> :gen_event.add_handler(Notification, Notification.Handler, name: "Handler3")

22:24:58.466 [info]  Handler3 initialized
:ok

通知を送ります。

iex(4)> :gen_event.notify(Notification, "Hello!")

22:25:47.489 [info]  Handler3 received Hello!
:ok

22:25:47.489 [info]  Handler2 received Hello!

22:25:47.489 [info]  Handler1 received Hello!

登録した 3 つのハンドラが呼び出されたことがわかります。ハンドラの実行は非同期ですので、ハンドラのログの出力と :gen_event.notify/2 の戻り値の表示が混ざって表示されています。

イベント源のコードを書く

ハンドラの登録や通知を簡単にするためのコードを書きます。

lib/notification.ex を編集して :gen_event の関数の呼び出しを隠す関数を書きます。:gen_event のプロセスはこのファイルで記述するモジュールの名前 Notification で登録しているので、__MODULE__ マクロで指定しています。

defmodule Notification do
  def add_handler(name) do
    :gen_event.add_handler(__MODULE__, Notification.Handler, name: name)
  end

  def notify(event) do
    :gen_event.notify(__MODULE__, event)
  end
end

実行します。

$ iex -S mix
iex(1)> Notification.add_handler("Handler1")

22:30:42.760 [info]  Handler1 initialized
:ok
iex(2)> Notification.add_handler("Handler2")

22:30:44.872 [info]  Handler2 initialized
:ok
iex(3)> Notification.add_handler("Handler3")

22:30:46.296 [info]  Handler3 initialized
:ok
iex(4)> Notification.notify("Hello!")

22:30:59.376 [info]  Handler3 received Hello!
:ok

22:30:59.376 [info]  Handler2 received Hello!

22:30:59.376 [info]  Handler1 received Hello!

いつか読むはずっと読まない:ソラリスの陽のもとに

ポーランド語原典からの翻訳版として 国書刊行会 が 2004 年に刊行した単行本を早川書房 が 2015 年に文庫化したもの。

有名な作品ですが、それまではロシア語訳版の翻訳だったんですね。ようやく手にしました。