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

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

「Specification は述語である」という話

勉強不足を晒すことになりますが。 仕事で "Specification" なるオブジェクトが出てきたときに、しばらくは漠然としたイメージしか持てませんでした。

当面は直接関わることがなかったのでそのままにしていたのですが、原典に当たったことですっきりしました。

仕様とは、あるオブジェクトが何らかの基準を満たしているかどうかを判定する述語である。

www.shoeisha.co.jp

なるほど、述語のオブジェクトということのようです。

その応用については Martin Fowler のサイトで公開されている論文に詳しく書かれています。

そこで、この論文に出てくる CompositeSpecification を Ruby で書いてみました。

module CompositeSpecification
  class AndSpecification
    include CompositeSpecification

    def initialize(left, right)
      @left = left
      @right = right
    end

    def satisfied_by?(object)
      @left.satisfied_by?(object) && @right.satisfied_by?(object)
    end
  end

  class OrSpecfication
    include CompositeSpecification

    def initialize(left, right)
      @left = left
      @right = right
    end

    def satisfied_by?(object)
      @left.satisfied_by?(object) || @right.satisfied_by?(object)
    end
  end

  class NotSpecification
    include CompositeSpecification

    def initialize(specification)
      @specification = specification
    end

    def satisfied_by?(object)
      !@specification.satisfied_by?(object)
    end
  end

  def and(another)
    AndSpecification.new(self, another)
  end

  def or(another)
    OrSpecfication.new(self, another)
  end

  def !
    NotSpecification.new(self)
  end
end

使い方の例です。

まず、 1 から 30 の数値のうち、仕様を満たす値のみを出力する関数を用意します。

def satisfy(spec)
  puts (1..30).select { |i| spec.satisfied_by?(i) }.join(' ')
end

次に 3 の倍数であることを FizzSpecification 、5 の倍数であることを BuzzSpecification と定義します。

require_relative './composite_specification'

class FizzSpecification
  include CompositeSpecification

  def satisfied_by?(n)
    (n % 3).zero?
  end
end

class BuzzSpecification
  include CompositeSpecification

  def satisfied_by?(n)
    (n % 5).zero?
  end
end

それぞれの Specification オブジェクトを適用してみます。

fizz_spec = FizzSpecification.new
satisfy(fizz_spec)
#=> 3 6 9 12 15 18 21 24 27 30

buzz_spec = BuzzSpecification.new
satisfy(buzz_spec)
#=> 5 10 15 20 25 30

CompositeSpecification を include したこれらの Specification は合成することができます。

FizzSpecification と BuzzSpecification を合成して、新しい Specification オブジェクトを作ります。

fizz_buzz_spec = fizz_spec.and(buzz_spec)
satisfy(fizz_buzz_spec)
#=> 15 30

どんどん合成できます。

satisfy(fizz_spec.or(buzz_spec))
#=> 3 5 6 9 10 12 15 18 20 21 24 25 27 30

satisfy(!fizz_spec.or(buzz_spec))
#=> 1 2 4 7 8 11 13 14 16 17 19 22 23 26 28 29

satisfy((!fizz_spec).and(!buzz_spec))
#=> 1 2 4 7 8 11 13 14 16 17 19 22 23 26 28 29

「エリック・エヴァンスのドメイン駆動設計」にはこうも書かれています。

論理プログラミングは、 「述語」と呼ばれる、独立した結合可能なルールオブジェクトの概念を提供するが、この概念をオブジェクトで完全に実装するのは面倒である。

なるほど。 それでは論理プログラミングなら簡単なのかも。

試してみます。

そんなわけで。 久々の Prolog プログラミングです。

fizz/1 と buzz/1 を定義して specification.prolog というファイル名で保存します。

fizz(N) :- Rem is N mod 3, Rem == 0.
buzz(N) :- Rem is N mod 5, Rem == 0.

REPL を起動します。 使うのはいつものように GNU Prolog です。

$ gprolog

プログラムを読み込みます。

| ?- [specification].       

1から30までの間で fizz/1 を満たす N をすべて見つけます。

| ?- findall(N, (between(1, 30, N), fizz(N)), Ns).
Ns = [3,6,9,12,15,18,21,24,27,30]

1から30までの間で buzz/1 を満たす N をすべて見つけます。

| ?- findall(N, (between(1, 30, N), buzz(N)), Ns).
Ns = [5,10,15,20,25,30]

Prolog ではコンマ( (',')/2 )は論理積を、セミコロン( (;)/2 )は論理和を表す演算子です。

ちなみに否定を表す演算子は (\+)/1 です。

ですので fizz_buzz/1 はこのように定義できます。

fizz_buzz(N) :- fizz(N), buzz(N).
| ?- findall(N, (between(1, 30, N), fizz_buzz(N)), Ns).
Ns = [15,30]

他にも:

fizz_or_buzz(N) :- fizz(N); buzz(N).
| ?- findall(N, (between(1, 30, N), fizz_or_buzz(N)), Ns).
Ns = [3,5,6,9,10,12,15,15,18,20,21,24,25,27,30,30]
not_fizz_or_buzz(N) :- \+fizz_or_buzz(N).
| ?- findall(N, (between(1, 30, N), not_fizz_or_buzz(N)), Ns).
Ns = [1,2,4,7,8,11,13,14,16,17,19,22,23,26,28,29]

確かに合成は簡単、…というかこれは論理プログラミングそのものですね、確かに。

合成の部分に関しては。 例えば次のように演算をオブジェクトして遅延評価したいときに顔をだす構造。

module Calculable
  class Add
    include Calculable

    def initialize(left, right)
      @left = left
      @right = right
    end

    def eval
      @left.eval + @right.eval
    end
  end

  class Sub
    include Calculable

    def initialize(left, right)
      @left = left
      @right = right
    end

    def eval
      @left.eval - @right.eval
    end
  end
  
  def add(another)
    Add.new(self, another)
  end

  def sub(another)
    Sub.new(self, another)
  end
end

class Int
  include Calculable

  def initialize(n)
    @n = n
  end

  def eval
    @n
  end
end

three = Int.new(3)
two = Int.new(2)

puts (three.add(two)).sub(three.sub(two)).eval
#=> 4

これは Ruby on Rails の ActiveRecord のクエリメソッドで、普段からお世話になっている仕組みですね。

述語の詳細をオブジェクトで表現しなければならい状況に遭遇しないと、なぜこれが有意義なのかわかりにくいですが、よくよく調べてみると確かにそういった状況があると納得し、その解決方法の一つが Specification なのだとようやく腑に落ちたのでした。

GoogleDriveに行と列の位置を指定して値を書き込むのが面倒なのでヘッダを指定して書き込めるWriterを書いてみた

ことの起こり

"google_drive" という Ruby gem があります。

rubygems.org

これを使うと Ruby のコードから Google Drive にアクセスして操作することができます。

仕事で Google Spreadsheet に書き込みに使っているのですが、既存のコードではセルへの書き込みがべた書きになっていて使い勝手が今ひとつ。

require 'google_drive'

# ご自身のアクセスキーは Google Cloud console でご用意ください
session = GoogleDrive::Session.from_service_account_key('paht/to/your_account_key.json')

# spreadsheet = session.create_spreadsheet('テストスプレッドシート') # 新しく作成する場合
spreadsheet = session.spreadsheet_by_title('テストスプレッドシート')

# worksheet = spreadsheet.add_worksheet('べた書きシート') # 新しく作成する場合
worksheet = spreadsheet.worksheet_by_title('べた書きシート')

worksheet[1, 1] = 'Alice'
worksheet[1, 2] = 'Bob'
worksheet[1, 3] = 'Charlie'

worksheet[2, 1] = 1
worksheet[2, 2] = 2
worksheet[2, 3] = 3

worksheet[3, 1] = 11
worksheet[3, 2] = 22
worksheet[3, 3] = 33

worksheet[4, 1] = 111
worksheet[4, 2] = 222
worksheet[4, 3] = 333

worksheet.save

これでは例えば Alice と Bob の間に David を追加したいとなったとき、Bob と Charlie の列は挿入する位置をすべてずらさなければなりません。

スプレッドシートはヘッダ行で列を特定できるのですから、挿入するときもヘッダで指定したいものです。

書き込みクラス

そんなわけで。 ヘッダで列を指定できるクラスを書いてみました。

コードは GitHub Gist にも置いてあります。

SheetWriter · GitHub

class SheetWriter
  class Row
    def initialize(writer, headers, index)
      @writer = writer
      @headers = headers
      @index = index
    end

    def []= (header, value)
      @writer[@index, @headers.index_of(header)] = value
    end
  end

  class Headers
    def initialize(writer, headers, initial_index)
      @writer = writer
      @indices = headers.zip(initial_index..).to_h
      @next_header_index = @indices.size + initial_index
      @initial_index = initial_index

      @indices.each { |header, index| writer[initial_index, index] = header }
    end

    def add(header)
      @writer[@initial_index, @next_header_index] = header
      @indices[header] = @next_header_index
      @next_header_index = @next_header_index.succ
    end

    def index_of(header)
      add(header) if !@indices.has_key?(header)

      @indices[header]
    end
  end

  def initialize(sheet, headers: [], initial_index: 1)
    @sheet = sheet
    @headers = Headers.new(self, headers, initial_index)
    @last_row_index = initial_index
  end

  def next_row
    @last_row_index = @last_row_index.succ
    Row.new(self, @headers, @last_row_index)
  end

  def []= (row_index, column_index, value)
    @sheet.append(row_index, column_index, value)
  end
end

使ってみる

require 'google_drive'
require_relative 'sheet_writer'

session = GoogleDrive::Session.from_service_account_key('paht/to/your_account_key.json')

# spreadsheet = session.create_spreadsheet('テストスプレッドシート') # 新しく作成する場合
spreadsheet = session.spreadsheet_by_title('テストスプレッドシート')

# worksheet = spreadsheet.add_worksheet('writerを使ったシート') # 新しく作成する場合
worksheet = spreadsheet.worksheet_by_title('writerを使ったシート')

# インタフェース変換のアダプタ
class Adapter
  def initialize(worksheet); 
    @worksheet = worksheet
  end

  def append(row_index, column_index, value)
    @worksheet[row_index, column_index] = value
  end
end

writer = SheetWriter.new(Adapter.new(worksheet))

row = writer.next_row

row['Alice'] = 1
row['Bob'] = 2
row['Charlie'] = 3

row = writer.next_row

row['Bob'] = 22
row['Charlie'] = 33
row['Alice'] = 11

row = writer.next_row

row['Charlie'] = 333
row['Alice'] = 111
row['Bob'] = 222

worksheet.save

無事スプレッドシートに書き込めました。

ちなみに。 worksheet オブジェクトは []= で操作できますが、より一般的なメソッド呼び出しで操作できるようにしたいため、あえて #append で操作するように定義しています。 そのため、インタフェースを変換するためのアダプタを使っています。

特異メソッドを使って操作するオブジェクトにメソッドを直接定義するという方法もありますが、オブジェクトごとに使えるメソッドが違うことを把握するのも面倒なので、使い所は選びそうです。

def worksheet.append(row_index, column_index, value)
  self[row_index, column_index] = value
end

writer = SheetWriter.new(worksheet)

David を追加する

「Alice と Bob の間に David を追加したい」ばあいの面倒がどのようになったか見てみます。

# ここまで上のコードと同じなので省略

# 先にヘッダの順序を指定
writer = SheetWriter.new(Adapter.new(worksheet), headers: %w(Alice David Bob Charlie))

row = writer.next_row

row['Alice'] = 1
row['Bob'] = 2
row['Charlie'] = 3
row['David'] = 4 # 追加

row = writer.next_row

row['Bob'] = 22
row['Charlie'] = 33
row['Alice'] = 11
row['David'] = 44 # 追加

row = writer.next_row

row['Charlie'] = 333
row['Alice'] = 111
row['Bob'] = 222
row['David'] = 444 # 追加

worksheet.save

ヘッダの順序をあらかじめ指定しておくことで、列の順序を気にすることなくヘッダで指定した位置に値を設定することができるようになりました。

配列に出力する

#append が定義されているオブジェクトなら何にでも出力できる利点を享受してみましょう。

たとえば配列には Array#append が定義されているので、そのまま出力対象のオブジェクトに指定できます。

require_relative 'sheet_writer'

my_sheet = []

# 配列の添字は 0 始まりなので initial_index に 0 を指定
writer = SheetWriter.new(my_sheet, initial_index: 0)

row = writer.next_row

row['Alice'] = 1
row['Bob'] = 2
row['Charlie'] = 3

row = writer.next_row

row['Bob'] = 22
row['Charlie'] = 33
row['Alice'] = 11

row = writer.next_row

row['Charlie'] = 333
row['Alice'] = 111
row['Bob'] = 222

pp my_sheet

結果。

[0, 0, "Alice", 1, 0, 1, 0, 1, "Bob", 1, 1, 2, 0, 2, "Charlie", 1, 2, 3, 2, 1, 22, 2, 2, 33, 2, 0, 11, 3, 2, 333, 3, 0, 111, 3, 1, 222]

Array#append は引数の複数の値をフラットに追加するので Enumerable#each_slice で少しみやすくします。 あわせて行と列の位置の順に並べ替えます。

pp my_sheet.each_slice(3).sort
[[0, 0, "Alice"], [0, 1, "Bob"], [0, 2, "Charlie"], [1, 0, 1], [1, 1, 2], [1, 2, 3], [2, 0, 11], [2, 1, 22], [2, 2, 33], [3, 0, 111], [3, 1, 222], [3, 2, 333]]

位置をヘッダで指定できるというだけでなく出力対象を出力操作から分離できるので、テストのときにも重宝するはずです。

実は不満なところ

インスタンス変数が多くなりました。 Row や Headers をうまく書くとそれらを減らせるのではないかという気がしています。 Ruby のブロックのしくみなどを使えばよいのかもしれません。 逆に無駄に技巧的になりすぎるだけかもしれません。

いまだ思案中。

Rubyでunfoldを書く

特定の値から出発して演算を繰り返し値の並びを出力する unfold 。 そういえば Ruby に unfold ってないんだっけ? というのが発端。

unfold とは

早い話が fold の逆です。

Elixir では Stream.unfold/2 が定義されています。

# 1 から始めて、前の値に 1 を加える
Stream.unfold(1, fn x -> {x, x + 1} end) |> Enum.take(10)
#=> [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
# 1 から始めて、前の値を 2 倍にする
Stream.unfold(1, fn x -> {x, x * 2} end) |> Enum.take(10)
#=> [1, 2, 4, 8, 16, 32, 64, 128, 256, 512]
# フィボナッチ数列
Stream.unfold({1, 1}, fn {n1, n2} -> {n1, {n2, n1 + n2}} end) |> Enum.take(10)
#=> [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]

Stream.unfold/2 自体は終端を定義しません。 Enum でなく Stream で定義されているのもそのためです。 Enum.take/2 などで必要な分を取り出す必要があります。

Ruby で unfold を書く(終端を条件で判断するばあい)

最初に思いつくのは、繰り返しの中で yield を使い、結果を集める方法。 しかし無制限にいつまでも繰り返すわけにはいかないので、終了条件を織り込まないとなりません。

実装するとこんな感じ。

def unfold(x)
  result = []
  loop do
    y, x = yield x
    break if x.nil?
    result.push y
  end
  result
end
# 1 から始めて、10 以下の範囲で前の値に 1 を加える
unfold(1) do |x|
  [x, x + 1] if x <= 10
end
#=> [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
# 1 から始めて、512 以下の範囲で前の値を 2 倍にする
unfold(1) do |x|
  [x, x * 2] if x <= 512
end
#=> [1, 2, 4, 8, 16, 32, 64, 128, 256, 512]
# 55 以下の値のフィボナッチ数列
unfold([1, 1]) do |n1, n2|
  [n1, [n2, n1 + n2]] if n1 <= 55
end
#=> [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]

Ruby で unfold を書く

しかし冒頭の Stream.unfold/2 の例のように決められた個数の要素を取り出すようなばあいにはこれでは不便です。

そこで「Ruby unfold」で検索すると、簡単にヒットするのがこちらのコード。

実のところ、これがほぼ正解っぽいです。

Enumerator.new のブロックの引数として渡される Enumerator::Yielder オブジェクトの存在も初めて知りました。

docs.ruby-lang.org

def unfold(x)
  Enumerator.new do |yielder|
    loop do
      y, x = yield x
      yielder << y
    end
  end
end
unfold(1) { |x| [x, x + 1] }.take(10)
#=> [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
unfold(1) { |x| [x, x * 2] }.take(10)
#=> [1, 2, 4, 8, 16, 32, 64, 128, 256, 512]
unfold([1, 1]) { |x, y| [x, [y, x + y]] }.take(10)
#=> [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]

この実装でしたら最初に書いた終端を条件で判断するケースにも対応できます。

unfold([1, 1]) { |x, y| [x, [y, x + y]] }.take_while { _1 < 100 }
#=> [1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89]

Elixir の Stream.unfold/2 も Enum.take_while/2 を使って同じように書けます。

Stream.unfold({1, 1}, fn {n1, n2} -> {n1, {n2, n1 + n2}} end) |> Enum.take_while(& &1 < 100)
#=> [1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89]

モジュールにしてみた

繰り返し利用できるようにモジュールを定義してみました。 が、今ひとつ。 定義の仕方がうまくないというのもありますが、初期値と操作の結びつきが強いので、任意の操作を受け取れるようにしても使い勝手がよくないということなのかもしれません。

module Unfoldable
  def unfold
    x = self
    Enumerator.new do |yielder|
      loop do
        y, x = yield x
        yielder << y
      end
    end
  end
end
Integer.include(Unfoldable)
1.unfold { |x| [x, x + 1] }.take(10)
#=> [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
a = [1, 1]
a.extend(Unfoldable)
a.unfold { |n1, n2| [n1, [n2, n1 + n2]] }.take(10)
#=> [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]

クエリをオブジェクトにして ActiveRecord から分離する

仕事では Ruby on Rails を主戦場としているわけですが。 最近、クエリメソッドについて考えています。

Rails のクエリメソッドといえば「スコープ」を定義するのが常套手段です。

# app/models/user.rb
class User < ApplicationRecord
  scope :created_at_between, -> (from, to) { where(created_at: from..to) }
end
User.created_at_between('2024-11-01', '2024-11-30')

ここで、クエリメソッドがクラスと一体になっていることが便利なのか否か、このところ考えをめぐらせています。

たとえば、同じクエリメソッドを別のクラスで利用したいばあいはどのように実装するのがよいのか。

Book.created_at_between('2024-11-01', '2024-11-30')

Ruby らしい解としては、モジュールに分離して必要なクラスが mixin するのがよさそうです。

# app/models/concerns/created_at_between.rb
module CreatedAtBetween
  def created_at_between(from, to)
    where(created_at: from..to)
  end
end
# app/models/book.rb
class Book < ApplicationRecord
  extend CreatedAtBetween
end

しかし。 混ぜ合わせるのでなく、もっともっと互いに独立した関係にできないか?

検索すれば、クエリオプジェクトというパタンがすぐにヒットします。

martinfowler.com

これを Ruby に当てはめてみようと思います。

実装は、たとえばこんな感じ。

# app/models/created_at_between.rb
class CreatedAtBetween
  def initialize(from, to)
    @from = from
    @to = to
  end

  def range
    @from..@to
  end

  def apply(query)
    query.where(created_at: range)
  end
end

こんな感じで使えます。

CreatedAtBetween.new('2024-11-01', '2024-11-30').apply(User)

#apply には ActiveRecord のクラスだけでなくリレーションも渡せます。

CreatedAtBetween.new('2024-11-01', '2024-11-30').apply(User.limit(3))

クエリの内容が適合すれば、ActiveRecord の種類は問いません。

CreatedAtBetween.new('2024-11-01', '2024-11-30').apply(Book)

別のクエリオブジェクトも作ってみましょう。

# app/models/order_by.rb
class OrderBy
  def initialize(key, direction)
    @key = key
    @direction = direction
  end

  def apply(query)
    query.order(@key => @direction)
  end
end
OrderBy.new(:age, :asc).apply(User)

#apply の結果もまたクエリなので、#reduce などで畳み込むこともできます。

criteria = [
  CreatedAtBetween.new('2024-11-01', '2024-11-30'),
  OrderBy.new(:age, :asc)
]

criteria.reduce(User) { |query, criterion| criterion.apply(query) }

複数のクエリオブジェクトを集約して、畳み込むためのインタフェースを提供するクラスも考えることができそうです。

# app/models/query.rb
class Query
  def initialize(criteria = [], criterion = nil)
    @criteria = [*criteria, *Array(criterion)]
  end

  def created_at_between(from, to)
    Query.new(@criteria, CreatedAtBetween.new(from, to))
  end

  def order_by(key, direction = 'asc')
    Query.new(@criteria, OrderBy.new(key, direction))
  end

  def apply(query)
    @criteria.reduce(query) do |query, criterion|
      criterion.apply(query)
    end
  end
end

メソッドの戻り値も同じクラスのオブジェクトなので、メソッドをチェインして呼び出せませす。

Query.new
  .created_at_between('2024-11-01', '2024-11-30')
  .order_by(:age, :asc)
  .apply(User)

メソッドを呼び出してもオブジェクトの状態は変わらず、新しいオブジェクトを作成して返すので、途中までのクエリを共用できます。

query = Query.new.created_at_between('2024-11-01', '2024-11-30')

query.order_by(:age, :asc).apply(User)
query.order_by(:title, :asc).apply(Book)

なんとなく ActiveRecord とクエリの分離ができましたが、このままだと ActiveRecord がただのデータの塊のようで Ruby っぽい感じがしません。

小さいメソッドを追加して外から見える呼び出しの方向を変えてみます。

# app/models/user.rb
class User < ApplicationRecord
  def self.match(query)
    query.apply(self)
  end
end
query = Query.new.created_at_between('2024-11-01', '2024-11-30').order_by(:age, :asc)

User.match(query)

ActiveRecord に操作が移って Ruby っぽくなったと思います。

もちろんリレーションからも利用できます。

User.where('name LIKE ?', '%A%').match(query)

さらにこのメソッドをスーパークラスで定義すれば、どの ActiveRecord からも利用できるようになるはずです。

class ApplicationRecord < ActiveRecord::Base
  primary_abstract_class

  def self.match(query)
    query.apply(self)
  end
end
query = Query.new
          .created_at_between('2024-11-01', '2024-11-30')
          .order_by(:created_at, :desc)

User.match(query)
Book.match(query)

悪くなさそうです。

調べればもっと洗練された実装が見つかると思うのですが、自身の理解の最初の一歩としては悪くない感じです。

Reqのテストを書く覚書

HTTP クライアントとして Req をよく利用するのですが。

hex.pm

こういった外部の環境と接続する操作はテストが面倒なもの。

その点において Req はテストのための仕組みをパッケージ自身が提供してくれています。 その仕組みの使い方と、ちょっとした工夫の覚え書きです。

新しいプロジェクトを作って順を追って説明します。

$ mix new my_app
$ cd my_app

Req を使う

まず Req の使い方のおさらいから。

mix.exs の依存パッケージに Req を追加し、パッケージを取得します。

  # ...

  defp deps do
    [
      {:req, "~> 0.5"}
    ]
  end

  # ...
$ mix deps.get

Req を使う関数を追加します。

  • lib/my_app.ex
defmodule MyApp do
  def get(url) do
    Req.request(url: url)
  end
end

追加した関数のテストを書きます。

  • test/my_app_test.exs
defmodule MyAppTest do
  use ExUnit.Case
  doctest MyApp

  describe "get/1" do
    test "get example.com" do
      assert {:ok, %Req.Response{status: 200, body: body}} = MyApp.get("https://example.com")
      assert body =~ ~r/Hello/
    end
  end
end

これでもテストとして実行できますが、実行するたびに指定した URL へのアクセスが発生します。 まずこれをスタブにします。

スタブを使う

Req はテストのために Req.Test というモジュールを用意しています。

hexdocs.pm

まずこのモジュールの利用を指定する設定をします。

plug: {Req.Test, MyApp} は、HTTP リクエストのアダプタとして Req.Test を MyApp という名前で指定することを表しています。 指定の詳細については Req.new/1 のオプションの説明を参照してください。

設定を config/config.exs に直接書いてもよいのですが、Config.import_config/1 を使った環境ごとに分離する定石に従うことにします。

  • config/test.exs
import Config

config :my_app,
  req_options: [
    plug: {Req.Test, MyApp}
  ]
  • config/config.exs
import Config

import_config "#{config_env()}.exs"

また dev と prod のために config/dev.exs と config/prod.exs も作成しておきます。 これらは空のファイルで大丈夫です。

次に MyApp.get/1 を編集して設定した内容を利用するように変更します。

  • lib/my_app.ex
defmodule MyApp do
  def get(url) do
    [url: url]
    |> Keyword.merge(Application.get_env(:my_app, :req_options, []))
    |> Req.request()
  end
end

テストもスタブを利用するように変更します。

スタブは Req.Test.stub/2 で設定します。 第 1 引数は設定で指定した名前です。 第 2 引数は、Phoenix でもおなじみの Plug.Conn の構造体を受け取り、レスポンスを返す関数です。

ここでは Req.Test.text/2 を使ってプレーンテキストを返していますが、他にも html/2 や json/2 といった関数が用意されています。

また Plug.Conn.put_status/2 などの Plug の関数を利用することも可能です。

  • test/my_app_test.exs
defmodule MyAppTest do
  use ExUnit.Case
  doctest MyApp

  describe "get/1" do
    test "get example.com" do
      Req.Test.stub(MyApp, fn conn ->
        Req.Test.text(conn, "Hello Req stub!")
      end)

      assert {:ok, %Req.Response{status: 200, body: body}} = MyApp.get("https://example.com")
      assert body =~ ~r/Hello/
    end
  end
end

最後に、依存パッケージに Plug を追加します。

hex.pm

今回はテストでだけ利用するので [only: :test] オプションを指定しています。

  • mix.exs
  # ...

  defp deps do
    [
      {:req, "~> 0.5"},
      {:plug, "~> 1.16", only: :test}
    ]
  end

  # ...

パッケージを取得してテストを実行します。 Req.Test.text/2 で指定したテキストが返されることが確認できると思います。

$ mix deps.get
$ mix test

setup を使う

スタブを設定するコードを ExUnit.Callbacks.setup/2 に移動して、繰り返し利用できるようにします。 加えてレスポンスのステータスとテキストもテストごとに設定できるように、 @tag で指定できるようにしています。

  • test/my_app_test.exs
defmodule MyAppTest do
  use ExUnit.Case
  doctest MyApp

  setup context do
    body = Map.get(context, :body, "")
    status = Map.get(context, :status, 200)

    Req.Test.stub(MyApp, fn conn ->
      conn
      |> Plug.Conn.put_status(status)
      |> Req.Test.text(body)
    end)
  end

  describe "get/1" do
    @tag body: "Hello Req stub!"
    test "get example.com" do
      assert {:ok, %Req.Response{status: 200, body: body}} = MyApp.get("https://example.com")
      assert body =~ ~r/Hello/
    end
  end
end

Stub module を使う

setup も繰り返し利用できるように、モジュールに分離してみます。

まず、モジュールを追加して setup に書いた内容を移動します。

  • test/support/my_app_stub.ex
defmodule MyApp.Stub do
  defmacro __using__(_) do
    quote do
      setup context do
        body = Map.get(context, :body, "")
        status = Map.get(context, :status, 200)

        Req.Test.stub(MyApp, fn conn ->
          conn
          |> Plug.Conn.put_status(status)
          |> Req.Test.text(body)
        end)
      end
    end
  end
end

今回はモジュールを use することで利用できるように __using__/1マクロを利用しましたが、他にもっとよい方法があるかもしれません。

テストでは setup を削除して、追加したモジュールを use します。

  • test/my_app_test.exs
defmodule MyAppTest do
  use ExUnit.Case
  use MyApp.Stub
  doctest MyApp

  describe "get/1" do
    @tag body: "Hello Req stub!"
    test "get example.com" do
      assert {:ok, %Req.Response{status: 200, body: body}} = MyApp.get("https://example.com")
      assert body =~ ~r/Hello/
    end
  end
end

最後に、追加したモジュールがテストのときにだけコンパイルされるようにする設定を追加します。

  • mix.exs
  # ...

  def project do
    [
      # ...
      elixirc_paths: elixirc_paths(Mix.env()),
      # ...
    ]
  end

  # ...

  defp elixirc_paths(:test), do: ["lib", "test/support"]
  defp elixirc_paths(_), do: ["lib"]

  # ...

これでスタブを定義したモジュールを use するだけで繰り返しスタブが利用できるようになりました。

Elixirの関数っぽい関数でない何かと、Prologの述語っぽい述語でない何か

Canada という小さな実装のライブラリがあります。

hex.pm

Ruby でいうところの CanCanCan のような権限判定のためのライブラリなのですが、とても興味深い実装をしています。

例えば user が article を read できるか判定するとき、

can?(user, read(article))

あるいは

user |> can?(read(article))

のような書き方をするのですが、このとき read/1 という関数は定義しません。 定義する必要がありませんというのがより正しいかもしれません。

何をやっているのか、その仕組みをなぞるコードを書いて確認してみましょう。

Elixir のばあい

まず、構造体 User と Article を定義します。

defmodule User do
  defstruct [:id, :role, :name]
end
defmodule Article do
  defstruct [:user_id, :title, :body]
end

次に判定のための関数 available?/3 を用意します。 この関数は User の値と Article の値、および atom で操作を受け取り、その組み合わせで操作の可否を返します。

ここでは任意の User は Article を read でき、role が editor である User あるいは所有者である User は Article を更新でき、所有者である User は Article を削除できる、としています。 それ以外の操作はできません。

User の種類 read write delete
任意の User 可 不可 不可
編集者 (role = editir) 可 可 不可
所有者 (User.id = Article.user_id) 可 可 可
defmodule Can do
  def available?(%User{}, :read, %Article{}), do: true
  def available?(%User{role: :editor}, :update, %Article{}), do: true
  def available?(%User{id: id}, :update, %Article{user_id: id}), do: true
  def available?(%User{id: id}, :delete, %Article{user_id: id}), do: true
  def available?(%User{}, _, %Article{}), do: false

  # 後半に続く

最後に、マクロ can?/2 を定義します。 ここで第 2 引数は「関数呼び出し」を受け取るようにします。

関数を呼び出した結果ではなく、関数呼び出しそのものを受け取るという点が要点です。

マクロでは関数呼び出しは関数名と引数に分解されます。

iex> quote do: read(foo)
{:read, [], [{:foo, [], Elixir}]}

マクロの引数に関数呼び出しを渡すと、この分解された形で受け取ることになるので、分解された関数名と引数を使って available?/3 を評価します。

  # 前半からの続き

  defmacro can?(user, {action, _, [article]}) do
    quote do
      available?(unquote(user), unquote(action), unquote(article))
    end
  end
end

マクロを有効にするために import して判定をしてみます。

import Can

# 任意の User
user = %User{id: 123}

# 編集者
editor = %User{id: 234, role: :editor}

# 所有者
owner = %User{id: 345}

article = %Article{user_id: 345}

user |> can?(read(article))     #=> true
user |> can?(update(article))   #=> false
user |> can?(delete(article))   #=> false

editor |> can?(read(article))   #=> true
editor |> can?(update(article)) #=> true
editor |> can?(delete(article)) #=> false

owner |> can?(read(article))    #=> true
owner |> can?(update(article))  #=> true
owner |> can?(delete(article))  #=> true

read/1 や update/1 や delete/1 といった関数の呼び出しが現れますが、それらを呼び出した結果でなく呼び出しそのものがマクロの引数となるため、関数の定義は存在しないという興味深い実装になっています。

Canada ではさらに available?/3 に相当する部分がプロトコルで実現されているために、任意の構造体に対して判定を定義することが可能になっています。

Prolog のばあい

同じようなことを Prolog でも書いてみました。

Elixir の母体である Erlang は最初は Prolog で書かれ Prolog の影響を受けていることは知られています。 実際 Prolog で何が起こるか見てみることで、似ているところ違うところを感じてみましょう。

次のコードを can.prolog と言うファイル名で保存します。

can(user(id:_, role:_), read(article(user_id:_, title:_, body:_))) :- !.
can(user(id:_, role:editor), update(article(user_id:_, title:_, body:_))) :- !.
can(user(id:ID, role:_), update(article(user_id:ID, title:_, body:_))) :- !.
can(user(id:ID, role:_), delete(article(user_id:ID, title:_, body:_))) :- !.

GNU Prolog を起動します。

$ gprolog

Prolog のプロンプトが表示されたら ['can.prolog']. と入力してコードを読み込みます。

| ?- ['can.prolog'].
yes

Elixir で書いた時と同じように、任意の User、編集者、所有者それぞれに対して read, update, delete が可能か判定させてみます。

| ?- can(user(id: 123, role: reader), read(article(user_id: 345, title: "Foo", body: "Bar"))).
yes
| ?- can(user(id: 123, role: reader), update(article(user_id: 345, title: "Foo", body: "Bar"))).
no
| ?- can(user(id: 123, role: reader), delete(article(user_id: 345, title: "Foo", body: "Bar"))).
no

| ?- can(user(id: 234, role: editor), read(article(user_id: 345, title: "Foo", body: "Bar"))).  
yes
| ?- can(user(id: 234, role: editor), update(article(user_id: 345 title: "Foo", body: "Bar"))).
yes
| ?- can(user(id: 234, role: editor), delete(article(user_id: 345, title: "Foo", body: "Bar"))).
no

| ?- can(user(id: 345, role: reader), read(article(user_id: 345, title: "Foo", body: "Bar"))).  
yes
| ?- can(user(id: 345, role: reader), update(article(user_id: 345, title: "Foo", body: "Bar"))).
yes
| ?- can(user(id: 345, role: reader), delete(article(user_id: 345, title: "Foo", body: "Bar"))).
yes

同じように判定することができました。

ここで read/1 や update/1 や delete/1 といった述語は定義していません。 加えて user/2 や article/3 も定義していません。 さらに言うと、Prolog には : という演算子は定義されていません。

Prolog は遅延評価であるため、明示的に評価するまで字面のまま扱われます。

そこで user(id: 123, role: reader) と言う記述は述語の定義の user(id:ID, role:_) にマッチし、変数 ID に 123 が束縛されます。 あとパタンマッチングによって can/2 の定義に適えば yes をそうでなければ no を返すと言うふるまいをします。

Elixir ではマクロという仕組みを使って「関数呼び出し」を引数として受け取れるようにしましたが、Prolog のばあいは逆に明示的に評価するまでは渡された引数の形のまま扱われるため、評価したときにどのような値が得られるかという定義がなくてもパタンマッチに利用できるという面白さがあります。

Elixirで国民の祝日サーバを作る

ただしサーバとは言っても

Web サーバ等ではなく、Elixir のサーバプロセスのことですのでその点はご了承を。

国民の祝日については、内閣府から情報が提供されています。

www8.cao.go.jp

また、翌年までの祝日の一覧は CSV 形式のデータで提供されています。

今回はこのデータを使って、日付から祝日を取得するサーバプロセスを作ってゆきます。

まず新しいプロジェクトを用意してください。

$ mix new holiday
$ cd holiday

ここから順番に機能を追加してゆきます。

祝日一覧をダウンロードする

HTTP クライアントには Req を利用します。

hex.pm

mix.exs に req を追加し、パッケージを取得します。

  defp deps do
    [
      {:req, "~> 0.5"}
    ]
  end
$ mix deps.get

IEx 上で CSV データをダウンロードできることを確認します。

$ iex -S mix
iex> Req.get("https://www8.cao.go.jp/chosei/shukujitsu/syukujitsu.csv")
{:ok,
 %Req.Response{
   status: 200,
   headers: %{
     ...
   },
   body: <<141, 145, 150, 175, 130, 204, 143, 106, 147, 250, 129, 69, 139, 120,
     147, 250, 140, 142, 147, 250, 44, 141, 145, 150, 175, 130, 204, 143, 106,
     147, 250, 129, 69, 139, 120, 147, 250, 150, 188, 143, 204, 13, 10, 49, 57,
     53, 53, 47, 49, 47, 49, 44, 140, 179, 147, 250, 13, 10, 49, 57, 53, 53, 47,
     49, 47, 49, 53, 44, 144, 172, 144, 108, 130, 204, 147, 250, 13, 10, 49, 57,
     53, 53, 47, 51, 47, 50, 49, 44, 143, 116, 149, 170, 130, 204, 147, 250, 13,
     10, 49, 57, 53, 53, 47, 52, 47, 50, 57, 44, 147, 86, 141, 99, 146, 97, 144,
     182, 147, 250, 13, 10, 49, 57, ...>>,
   trailers: %{},
   private: %{}
 }}

データは取得できましたがエンコーディングが UTF-8 でないため、具体的には SHIFT JIS であるために、このままでは Elixir の文字列として扱えません。

エンコーディングを変換する

UTF-8 に変換するたに iconv を利用します。

hex.pm

iconv は NIF を利用していますのでクロス環境で開発する場合は注意が必要です。

ちなみに iconv は Erlang のパッケージであるためモジュール名は :iconv になりますが、引数はバイナリで与えるため Elixir の関数と同じ感覚で利用することができます。

mix.exs に iconv を追加したら、パッケージを取得し IEx を起動します。

  defp deps do
    [
      {:req, "~> 0.5"},
      {:iconv, "~> 1.0"}
    ]
  end
$ mix deps.get
$ iex -S mix

ダウンロードしたデータを :iconv.convert/3 で変換します。

iex> {:ok, resp} = Req.get("https://www8.cao.go.jp/chosei/shukujitsu/syukujitsu.csv")
iex> :iconv.convert("cp932", "utf-8", resp.body)
"国民の祝日・休日月日,国民の祝日・休日名称\r\n1955/1/1,元日\r\n1955/1/15,成人の日\r\n1955/3/21,春分の日\r\n1955/4/29,天皇誕生日\r\n1955/5/3,憲法記念日\r\n1955/5/5,...

CSV をパースする

CSV のパースには NimbleCSV を利用します。

hex.pm

  defp deps do
    [
      {:req, "~> 0.5"},
      {:iconv, "~> 1.0"},
      {:nimble_csv, "~> 1.2"}
    ]
  end
$ mix deps.get
$ iex -S mix

実は。 ここで一つ問題が発生します。

iex> {:ok, resp} = Req.get("https://www8.cao.go.jp/chosei/shukujitsu/syukujitsu.csv")
{:ok,
 %Req.Response{
   status: 200,
   headers: %{
     ...
   },
   body: [
     [
       <<141, 145, 150, 175, 130, 204, 143, 106, 147, 250, 129, 69, 139, 120,
         147, 250, 140, 142, 147, 250>>,
       <<141, 145, 150, 175, 130, 204, 143, 106, 147, 250, 129, 69, 139, 120,
         147, 250, 150, 188, 143, 204>>
     ],
     ["1955/1/1", <<140, 179, 147, 250>>],
     ["1955/1/15", <<144, 172, 144, 108, 130, 204, 147, 250>>],
     ["1955/3/21", <<143, 116, 149, 170, 130, 204, 147, 250>>],
     ["1955/4/29", <<147, 86, 141, 99, 146, 97, 144, 182, 147, 250>>],
     ["1955/5/3", <<140, 155, 150, 64, 139, 76, 148, 79, 147, 250>>],
     ...

見ての通り Req.get/1 で取得したデータが CSV としてパース済みとなっています。

以前記事に書いたように、Req は NimbleCSV と一緒に利用したばあい、コンテンツの種類が CSV であると自動的にパースしてしまいます。

blog.emattsan.org

リストのリストに分解された各要素ごとにエンコーディングを変換することもできますが、手間を考えると取得したバイナリデータのエンコーディングを一括で変換してからパースするのがよさそうです。

自動的にパースされるのを防ぐには Req.get/2 の :decode_body オプションに false を指定します。

iex> {:ok, resp} = Req.get("https://www8.cao.go.jp/chosei/shukujitsu/syukujitsu.csv", decode_body: false)

また NimbleCSV は「CSV パーサを定義するパッケージ」であるため、あらかじめパーサを定義する必要があります。

iex> NimbleCSV.define(Holiday.Parser, [])

これで CSV パーサモジュール Holiday.Parser が利用できるようになりました。 第二引数のオプションでパーサのふるまいを指定することができますが、今回は特別なふるまいは不要ですので空で指定しています。

ダウンロードした CSV データを :iconv でエンコーディング変換し、定義したパーサ Holiday.Parser でパースします。

iex> Holiday.Parser.parse_string(:iconv.convert("cp932", "utf-8", resp.body))
[
  ["1955/1/1", "元日"],
  ["1955/1/15", "成人の日"],
  ["1955/3/21", "春分の日"],
  ...

無事、UTF-8 の文字列のリストのリストを得ることができました。

データを ETS に格納する

検索を簡単にするために、今回は ETS を利用することにします。

www.erlang.org

簡単に使い方をおさらいします。

まず、プロセスを起動します。

iex> table = :ets.new(:holiday, [])
#Reference<0.2088135362.3750625287.239169>

次にデータを投入します。 データはタプルである必要があります。 またタプルの一番最初の要素がキーになります。 ここでは Erlang 形式の日付データ(年月日の数値からなるタプル) {2024, 8, 11} がキーになります。

iex> :ets.insert(table, {{2024, 8, 11}, "山の日"})
true

検索してデータを取得します。

iex> :ets.select(table, [{{{2024, 8, 11}, :"$1"}, [], [:"$1"]}])
["山の日"]

ETS が敬遠される一番の原因が、この検索書式の面妖さではないかと思われるのですが。

今回も、関数形式から検索書式に変換してくれる :ets.fun2ms の助けを借りて切り抜けることにします。

ここで使った検索書式は次のようにして得ることができます。

iex> :ets.fun2ms(fn {{2024, 8, 11}, name} -> name end)
[{{{2024, 8, 11}, :"$1"}, [], [:"$1"]}]

では、データを投入してゆきます。

各祝日の日付は String.split/2 で分割し String.to_integer/1 で整数値に変換します。 それらと名前を合わせて一つのタプルの形式に変換し :ets.insert/2 で登録します。

iex> Holiday.Parser.parse_string(:iconv.convert("cp932", "utf-8", resp.body))
iex> |> Enum.each(fn [date, name] ->
...>   [year, month, day] =
...>     date
...>     |> String.split("/")
...>     |> Enum.map(&String.to_integer/1)
...>   :ets.insert(table, {{year, month, day}, name})
...> end)

いくつか検索してみます。

iex> :ets.select(table, [{{{2024, 8, 11}, :"$1"}, [], [:"$1"]}])
["山の日"]
iex> :ets.select(table, [{{{2024, 1, 1}, :"$1"}, [], [:"$1"]}])
["元日"]

うまく投入できたようです。

データを検索する

単純にキーになる年月日を指定して検索するだけでなく、もう少し複雑な検索もすることができます。

たとえば 2024 年 5 月の祝日をすべて取得してみます。

:ets.fun2ms/2 で検索の書式を調べます。

iex> :ets.fun2ms(fn {{2024, 5, d}, n} -> {{2024, 5, d}, n} end)
[{{{2024, 5, :"$1"}, :"$2"}, [], [{{{{2024, 5, :"$1"}}, :"$2"}}]}]

これを :ets.select/2 に指定して検索します。

iex> :ets.select(table, [{{{2024, 5, :"$1"}, :"$2"}, [], [{{{{2024, 5, :"$1"}}, :"$2"}}]}])
[
  {{2024, 5, 6}, "休日"},
  {{2024, 5, 3}, "憲法記念日"},
  {{2024, 5, 4}, "みどりの日"},
  {{2024, 5, 5}, "こどもの日"}
]

5 月の祝日の一覧を取得することができました。

…が。 順序が日付順になっていません。 ETS は無指定では順序を考慮しないことが原因です。

これは :ets.new/2 のオプションに :ordered_set を指定することで解決します。

iex> table = :ets.new(:hoiday, [:ordered_set])

これで準備が整いました。

サーバを作る

あとはここまでの要素をすべて一つにまとめるだけです。

定番の GenServer を使ってサーバに仕立てます。

defmodule Holiday do
  use GenServer

  NimbleCSV.define(Holiday.Parser, [])

  def start_link(opts \\ []) do
    GenServer.start_link(__MODULE__, opts)
  end

  def lookup(pid, year, month, day) do
    case GenServer.call(pid, {:lookup, year, month, day}) do
      [result] ->
        result

      [] ->
        nil
    end
  end

  def lookup(pid, year, month) do
    GenServer.call(pid, {:lookup, year, month, :"$3"})
  end

  def lookup(pid, year) do
    GenServer.call(pid, {:lookup, year, :"$2", :"$3"})
  end

  def init(_opts) do
    Process.send_after(self(), :init_table, 0)

    table = :ets.new(:holiday, [:ordered_set])

    {:ok, %{table: table}}
  end

  def handle_call({:lookup, year, month, day}, _from, state) do
    match_spec = [{{{year, month, day}, :"$4"}, [], [{{{{year, month, day}}, :"$4"}}]}]

    result =
      :ets.select(state.table, match_spec)
      |> Enum.map(fn {date, name} ->
        {Date.from_erl!(date), name}
      end)

    {:reply, result, state}
  end

  def handle_info(:init_table, state) do
    {:ok, resp} =
      "https://www8.cao.go.jp/chosei/shukujitsu/syukujitsu.csv"
      |> Req.get(decode_body: false)

    :iconv.convert("cp932", "utf-8", resp.body)
    |> Holiday.Parser.parse_string()
    |> Enum.each(fn [date, name] ->
      [year, month, day] =
        date
        |> String.split("/")
        |> Enum.map(&String.to_integer/1)

      :ets.insert(state.table, {{year, month, day}, name})
    end)

    {:noreply, state}
  end
end

インタフェースには、年のみ、年月のみ、年月日を指定できる Holiday.lookup/1,2,3 の三種類を用意しました。 また年月日を指定した場合はリストでなく見つかった要素そのものを返すようにしています。 加えて日付は Date 型に変換することにしました。

初期化時に Process.send_after/2 を使ってサーバ自身にメッセージを送り、非同期でダウンロードしています。 これにより初期化処理がブロックすることを防いでいます。

サーバを起動するとダウンロードが実行されるのでご注意ください。

動作を確認します。

$ iex -S mix

Holiday_start/0 でサーバを起動します。

iex> {:ok, pid} = Holiday.start_link()

年月日を指定してデータを取得します。 指定した日付が祝日でない場合は nil を返します。

iex> Holiday.lookup(pid, 2024, 8, 11)
{~D[2024-08-11], "山の日"}
iex> Holiday.lookup(pid, 2024, 8, 12)
{~D[2024-08-12], "休日"}
iex> Holiday.lookup(pid, 2024, 8, 13)
nil

年月のみを指定してデータを取得します。 祝日がない月が指定された場合は空のリストを返します。

iex> Holiday.lookup(pid, 2024, 8)
[{~D[2024-08-11], "山の日"}, {~D[2024-08-12], "休日"}]
iex> Holiday.lookup(pid, 2024, 6)
[]

年のみを指定してデータを取得します。

iex> Holiday.lookup(pid, 2024)
[
  {~D[2024-01-01], "元日"},
  {~D[2024-01-08], "成人の日"},
  {~D[2024-02-11], "建国記念の日"},
  {~D[2024-02-12], "休日"},
  {~D[2024-02-23], "天皇誕生日"},
  {~D[2024-03-20], "春分の日"},
  {~D[2024-04-29], "昭和の日"},
  {~D[2024-05-03], "憲法記念日"},
  {~D[2024-05-04], "みどりの日"},
  {~D[2024-05-05], "こどもの日"},
  {~D[2024-05-06], "休日"},
  {~D[2024-07-15], "海の日"},
  {~D[2024-08-11], "山の日"},
  {~D[2024-08-12], "休日"},
  {~D[2024-09-16], "敬老の日"},
  {~D[2024-09-22], "秋分の日"},
  {~D[2024-09-23], "休日"},
  {~D[2024-10-14], "スポーツの日"},
  {~D[2024-11-03], "文化の日"},
  {~D[2024-11-04], "休日"},
  {~D[2024-11-23], "勤労感謝の日"}
]

うまくいったようです。

いつか読むはずっと読まない:全史ならぬ前史

Homo sapiens が現れるまでにどのように分岐してきたのか。 他の生き物との生物としての距離が、日常的に感じるものとは、違っていたりいなかったり。