​ ​

RailsとPhoenixFrameworkでDBを共用する

Ruby On Rails (以下 rails )で作った DB を PhoenixFramework (以下 phoenix )から扱えるようにしました. 具体的には rails の schema.rb を ripper で構文解析し,必要な情報を読み込み phoenix の model を生成し,rails の DB へ phoenix からのアクセスを可能にしました.

試したバージョンは以下の通りです

/Users/d-kitamura/src% ruby -v
ruby 2.3.0p0 (2015-12-25 revision 53290) [x86_64-darwin15]
/Users/d-kitamura/src% rails -v
Rails 5.0.0
/Users/d-kitamura/src% elixir -v
Erlang/OTP 19 [erts-8.1] [source] [64-bit] [smp:4:4] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]

Elixir 1.3.4
/Users/d-kitamura/src% mix phoenix.new -v
Phoenix v1.2.1
  • ruby 2.3.0p0
  • Rails 5.0.0
  • Elixir 1.3.4
  • Phoenix v1.2.1

rails でアプリケーションを作成する

まずは Rails でアプリケーションを作ります.

今回は,ユーザー( User )が複数の投稿( Post )を持つテーブル構成のアプリケーションにしました. User has_many Posts ですね.

/Users/d-kitamura/src% rails new convert_schema_sandbox_rails --database postgresql
(snip...)
/Users/d-kitamura/src% cd convert_schema_sandbox_rails
/Users/d-kitamura/src/convert_schema_sandbox_rails% bin/rails generate model User name:string registered_at:datetime
Running via Spring preloader in process 5909
      invoke  active_record
      create    db/migrate/20161018065733_create_users.rb
      create    app/models/user.rb
      invoke    test_unit
      create      test/models/user_test.rb
      create      test/fixtures/users.yml
/Users/d-kitamura/src/convert_schema_sandbox_rails% bin/rails generate model Post user:references number:integer title:string body:text tags:string
Running via Spring preloader in process 10044
      invoke  active_record
      create    db/migrate/20161018073134_create_posts.rb
      create    app/models/post.rb
      invoke    test_unit
      create      test/models/post_test.rb
      create      test/fixtures/posts.yml
/Users/d-kitamura/src/convert_schema_sandbox_rails% emacs db/migrate/*create_posts.rb

Postgresql特有の配列型も試すため t.string :tagst.string :tags, array: true へと変更します.

class CreatePosts < ActiveRecord::Migration[5.0]
  def change
    create_table :posts do |t|
      t.references :user, foreign_key: true
      t.integer :number
      t.string :title
      t.text :body
      t.string :tags, array: true

      t.timestamps
    end
  end
end

それでは DB マイグレーションしましょう.

/Users/d-kitamura/src/convert_schema_sandbox_rails% bin/rails db:migrate
== 20161018070817 CreateUsers: migrating ======================================
-- create_table(:users)
   -> 0.0038s
== 20161018070817 CreateUsers: migrated (0.0039s) =============================

== 20161018073817 CreatePosts: migrating ======================================
-- create_table(:posts)
   -> 0.0072s
== 20161018073817 CreatePosts: migrated (0.0073s) =============================

Rails5 からは rake を使わなくとも rails で統一的にコマンドを扱えるようになったようですね.(今までどおり rake でも動作します)今回初めて試しました. rails を最初に触りはじめたときに rails db:migrate とコマンドを打ち間違え,何回も失敗したことを手が覚えているせいか,間違っていることをしている気がしてドキドキします.

思い出話でした.

rails アプリケーションへデータを登録する

rails console を利用してデータを登録しましょう.

/Users/d-kitamura/src/convert_schema_sandbox_rails% bin/rails console
Running via Spring preloader in process 12258
Loading development environment (Rails 5.0.0.1)
irb(main):001:0> kitamura = User.create!(name: "d-kitamura", registered_at: "2016-10-18 16:44:00+0900")
   (0.1ms)  BEGIN
  SQL (0.5ms)  INSERT INTO "users" ("name", "registered_at", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["name", "d-kitamura"], ["registered_at", 2016-10-18 07:44:00 UTC], ["created_at", 2016-10-18 08:00:03 UTC], ["updated_at", 2016-10-18 08:00:03 UTC]]
   (1.3ms)  COMMIT
=> #<User id: 1, name: "d-kitamura", registered_at: "2016-10-18 07:44:00", created_at: "2016-10-18 08:00:03", updated_at: "2016-10-18 08:00:03">
irb(main):002:0> first_post = Post.create!(user: kitamura, number: 1, title: "railsやってみた", body: "いい天気なのでrailsをやってみました",  tags: ["rails", "ruby"])
   (0.1ms)  BEGIN
  SQL (0.9ms)  INSERT INTO "posts" ("user_id", "number", "title", "body", "tags", "created_at", "updated_at") VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING "id"  [["user_id", 1], ["number", 1], ["title", "railsやってみた"], ["body", "いい天気なのでrailsをやってみました"], ["tags", "{rails,ruby}"], ["created_at", 2016-10-18 08:00:13 UTC], ["updated_at", 2016-10-18 08:00:13 UTC]]
   (1.2ms)  COMMIT
=> #<Post id: 1, user_id: 1, number: 1, title: "railsやってみた", body: "いい天気なのでrailsをやってみました", tags: ["rails", "ruby"], created_at: "2016-10-18 08:00:13", updated_at: "2016-10-18 08:00:13">

無事に rails から DB へデータを登録できましたね.

phoenix でアプリケーションを作成する

まずは phoenix でアプリケーションの雛形を作ります.

/Users/d-kitamura/src% mix phoenix.new convert_schema_sandbox_phoenix --database postgres
(snip...)
/Users/d-kitamura/src% cd convert_schema_sandbox_phoenix

rails console 相当のものを起動します. コンパイルするので初回だけずらずらと表示が出てきて,そのあと赤い文字が沢山表示されて驚くと思います. エラーが出たらあわてずにキーボードから C-c を 2 回押して中断してください.

/Users/d-kitamura/src/convert_schema_sandbox_phoenix% iex -S mix
(snip...)
Interactive Elixir (1.3.4) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> [error] Postgrex.Protocol (#PID<0.2992.0>) failed to connect: ** (Postgrex.Error) FATAL (invalid_authorization_specification): role "postgres" does not exist
BREAK: (a)bort (c)ontinue (p)roc info (i)nfo (l)oaded
       (v)ersion (k)ill (D)b-tables (d)istribution

エラーメッセージには role "postgres" does not exist とありますね. これは DB へと接続するユーザーが正しくなくて,接続に失敗しているときにでるメッセージです.

DB の接続設定を rails で作った DB を見るように書き換えましょう. 今回は rails と phoenix の development 環境へと繋ぐ設定を揃えます.

rails の DB 設定は convert_schema_sandbox_rails/config/database.yml にあります. 一部を抜粋します.

development:
  <<: *default
  database: convert_schema_sandbox_rails_development

  # The specified database role being used to connect to postgres.
  # To create additional roles in postgres see `$ createuser --help`.
  # When left blank, postgres will use the default role. This is
  # the same name as the operating system user that initialized the database.
  #username: convert_schema_sandbox_rails

rails の development 環境の DB 名は convert_schema_sandbox_rails_development にあります, ユーザー名とパスワードは無設定となっています. コメント部分によるとユーザー名が空の場合は OS のユーザー名と同じもので接続を試みるようです.

phoenix の development DB 設定は convert_schema_sandbox_phoenix/config/dev.exs にあります. 一部を抜粋します.

# Configure your database
config :convert_schema_sandbox_phoenix, ConvertSchemaSandboxPhoenix.Repo,
  adapter: Ecto.Adapters.Postgres,
  username: "postgres",
  password: "postgres",
  database: "convert_schema_sandbox_phoenix_dev",
  hostname: "localhost",
  pool_size: 10

こちらの DB 名( database )を rails のものと同じにします. また,ユーザー名( username ),パスワード( password )の行は利用しないので削除しました.

# Configure your database
config :convert_schema_sandbox_phoenix, ConvertSchemaSandboxPhoenix.Repo,
  adapter: Ecto.Adapters.Postgres,
  database: "convert_schema_sandbox_phoenix_dev",
  hostname: "localhost",
  pool_size: 10

上記の変更をした後,再度 phoenix で作成したアプリケーションから DB へと接続を試みると,先ほど表示されていたエラーは発生しなくなりました. rails で作成した DB へ phoenix から接続することができたようです.

/Users/d-kitamura/src/convert_schema_sandbox_phoenix% iex -S mix
Erlang/OTP 19 [erts-8.1] [source] [64-bit] [smp:4:4] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]

Compiling 12 files (.ex)
Generated convert_schema_sandbox_phoenix app
Interactive Elixir (1.3.4) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)>

phoenix で作成したアプリケーションから DB の値を利用する

さて,それでは phoenix で作成したアプリケーションから,rails 作成したアプリケーションで登録した DB の値を読み込みましょう.

phoenix で作成したアプリケーションから rails のアプリケーションで作成した DB を統合して利用する場合

  1. DB のテーブル定義から,phoenix の model の schema を生成する
  2. rails の schema.rb から,phoenix の model の schema を生成する

という方法が思いつきました.今回はなんとなく 2 番でやってみました.

convert_schema_sandbox_rails/db/schema.rb の内容は以下のようになっています. (見やすいように,コメント部分は除去しました)

ActiveRecord::Schema.define(version: 20161018073817) do

  enable_extension "plpgsql"

  create_table "posts", force: :cascade do |t|
    t.integer  "user_id"
    t.integer  "number"
    t.string   "title"
    t.text     "body"
    t.string   "tags",                    array: true
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["user_id"], name: "index_posts_on_user_id", using: :btree
  end

  create_table "users", force: :cascade do |t|
    t.string   "name"
    t.datetime "registered_at"
    t.datetime "created_at",    null: false
    t.datetime "updated_at",    null: false
  end

  add_foreign_key "posts", "users"
end

ふむふむ内部 DSL ですね.見た目は構造化されているものの,Array や HashMap ではないので直接読み込んで変換することはできなさそうです. そこで Ruby プログラムを解析するためのライブラリ ripper を利用して

  1. Ruby プログラムを Ripper.sexp で読み込んで S 式( Ruby の配列)へと変換する
  2. Ruby のS 式( Ruby の配列)から phoenix のコードを生成する

という手順を踏むことにしました.

まずは 1 をやります.

/Users/d-kitamura/src/convert_schema_sandbox_rails% bin/rails console
Running via Spring preloader in process 15333
Loading development environment (Rails 5.0.0.1)
irb(main):001:0> conf.echo = false
irb(main):002:0> require "ripper"
irb(main):003:0> path = Rails.root / "db" / "schema.rb"
irb(main):004:0> sexp = Ripper.sexp(path.read)
irb(main):005:0> pp sexp
[:program,
 [[:method_add_block,
   [:method_add_arg,
    [:call,
     [:const_path_ref,
      [:var_ref, [:@const, "ActiveRecord", [13, 0]]],
      [:@const, "Schema", [13, 14]]],
     :".",
     [:@ident, "define", [13, 21]]],
    [:arg_paren,
     [:args_add_block,
      [[:bare_assoc_hash,
        [[:assoc_new,
          [:@label, "version:", [13, 28]],
          [:@int, "20161018073817", [13, 37]]]]]],
      false]]],
   [:do_block,
    nil,
    [[:command,
      [:@ident, "enable_extension", [16, 2]],
      [:args_add_block,
       [[:string_literal,
         [:string_content, [:@tstring_content, "plpgsql", [16, 20]]]]],
       false]],
(snip...)

ずらずらと長い配列が表示されたと思います. これは schema.rb を,Ruby の配列を用いて構造を表したものです. schema.rb に書いてあった ActiveRecord::Schema.define(version: 20161018073817) のあたりの文字列が含まれているのがわかるでしょうか. ここでは省略しましたが,配列の下のほうにはテーブルの定義やカラムの定義のようなものも含まれています.

この配列から phoenix のコードを生成するのが 2 に相当します. REPL で色々試しながらコードを書いていくと,以下のコードになりました. 今回はテーブル定義に関係する create_table ブロックの中身だけを抜粋して利用しています.

require "ripper"
require "erb"
require "fileutils"

def extract_activerecord_define_block(sexp)
  sexp.dig(1, 0, 2, 2)
end

def create_table_block?(activerecord_define_block_element_sexp)
  activerecord_define_block_element_sexp.dig(1, 1, 1) == "create_table"
rescue
  false
end

def extract_table_name(create_table_block_sexp)
  create_table_block_sexp.dig(1, 2, 1, 0, 1, 1, 1)
end

def extract_table_columns(create_table_block_sexp)
  create_table_block_sexp.dig(2, 2)
end

def extract_column_type(table_column_sexp)
  table_column_sexp.dig(3, 1)
end

def extract_column_name(table_column_sexp)
  # t.index の値は ["user_id"] のように配列となる
  if table_column_sexp.dig(4, 1, 0, 0) == :array
    return table_column_sexp.dig(4, 1, 0, 1).map { |e| e.dig(1, 1, 1) }
  end

  table_column_sexp.dig(4, 1, 0, 1, 1, 1)
end

def extract_column_option(table_column_sexp)
  # column_option がない場合
  # table_column_sexp.dig(4, 1, 1, 1) は nil になる
  # その後の処理のために [] にしておく
  table_column_sexp.dig(4, 1, 1, 1) || []
end

def extract_option_key(column_option_sexp)
  # "null:" のように末尾に ":" がついた状態になるので除去する
  column_option_sexp.dig(1, 1).gsub(/:\z/, "")
end

def extract_option_value(column_option_sexp)
  if column_option_sexp.dig(2, 0) == :array
    return Array(column_option_sexp.dig(2, 1)).map { |e| e.dig(1, 1, 1) }
  end

  element = column_option_sexp.dig(2, 1)
  if element.class != Array
    return element
  end

  case element.dig(0)
  when :@kw
    element.dig(1)
  when :string_content
    element.dig(1, 1) || ""
  end
end

PROJECT_NAME = "ConvertSchemaSandboxPhoenix"

# Elixir のコード雛形
template = ERB.new(<<'__EOD__', nil, "-")
defmodule <%= PROJECT_NAME %>.<%= table_name.classify %> do
  use <%= PROJECT_NAME %>.Web, :model

  schema "<%= table_name %>" do
    <% table_columns.each do |c| %>
    field :<%= c[:column_name] -%>, <%= c[:column_type] -%>
    <% end %>

    timestamps inserted_at: :created_at
  end

  @doc """
  Builds a changeset based on the `struct` and `params`.
  """
  def changeset(struct, params \\ %{}) do
    struct
    |> cast(params, [<%= table_columns.map { |c| ":" << c[:column_name] }.join(", ") -%>])
    # |> validate_required([<%= table_columns.map { |c| ":" << c[:column_name] }.join(", ") -%>])
  end
end
__EOD__

path = Rails.root / "db" / "schema.rb"
sexp = Ripper.sexp(path.read)

schema = extract_activerecord_define_block(sexp)
           .select(&method(:create_table_block?))
           .map do |t|
             {
               table_name: extract_table_name(t),
               table_columns: extract_table_columns(t).map do |c|
                 {
                   column_name: extract_column_name(c),
                   column_type: extract_column_type(c),
                   column_option: Hash[extract_column_option(c).map { |o|
                                         [
                                           extract_option_key(o),
                                           extract_option_value(o)
                                         ]
                                       }]
                 }
               end
             }
           end

models_path = File.join("tmp", "models")
FileUtils.mkdir_p(models_path)

schema.each do |conf|
  table_name = conf[:table_name]
  table_columns = conf[:table_columns].reject { |c|
    c[:column_name] =~ /\A(created|updated)_at\z/ ||
      c[:column_type] == "index"
  }.map { |c|
    case c[:column_type]
    when "text" then c[:column_type] = ":string"
    when "datetime" then c[:column_type] = "Ecto.DateTime"
    else
      c[:column_type] = ":" + c[:column_type]
    end

    c
  }

  File.write(File.join(models_path, conf[:table_name].singularize) + ".ex",
             template.result(binding))
end

この Ruby コードをコピーして convert_schema_sandbox_rails/rip.rb へと置いてください. Ruby2.3 から導入された Array#dig を多用してます. こういった深い配列を持つデータにアクセスするのに便利ですね.

rip.rb を準備できたら,実行します. そうすると convert_schema_sandbox_rails/tmp/models へファイルが生成されます. こちらを phoenix の convert_schema_sandbox_phoenix/web/models へとコピーします.

/Users/d-kitamura/src/convert_schema_sandbox_rails% bin/rails runner rip.rb
Running via Spring preloader in process 17890
/Users/d-kitamura/src/convert_schema_sandbox_rails% ls tmp/models/
post.ex  user.ex
/Users/d-kitamura/src/convert_schema_sandbox_rails% cp tmp/models/* ../convert_schema_sandbox_phoenix/web/models/
/Users/d-kitamura/src/convert_schema_sandbox_rails% ls ../convert_schema_sandbox_phoenix/web/models/
post.ex  user.ex

convert_schema_sandbox_phoenix/web/models/post.ex を開いてみると,こんなコードになっているはずです.

defmodule ConvertSchemaSandboxPhoenix.Post do
  use ConvertSchemaSandboxPhoenix.Web, :model

  schema "posts" do

    field :user_id, :integer
    field :number, :integer
    field :title, :string
    field :body, :string
    field :tags, {:array, :string}

    timestamps inserted_at: :created_at
  end

  @doc """
  Builds a changeset based on the `struct` and `params`.
  """
  def changeset(struct, params \\ %{}) do
    struct
    |> cast(params, [:user_id, :number, :title, :body, :tags])
    # |> validate_required([:user_id, :number, :title, :body, :tags])
  end
end

これで準備は整ったので phoenix で作成したアプリケーションから rails で作成したアプリケーションで利用している DB へとアクセスしましょう.

/Users/d-kitamura/src/convert_schema_sandbox_phoenix% iex -S mix
Erlang/OTP 19 [erts-8.1] [source] [64-bit] [smp:4:4] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]

Interactive Elixir (1.3.4) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> alias ConvertSchemaSandboxPhoenix.{Repo, User, Post}
[ConvertSchemaSandboxPhoenix.Repo, ConvertSchemaSandboxPhoenix.User,
 ConvertSchemaSandboxPhoenix.Post]
iex(2)> Repo.all User
[debug] QUERY OK source="users" db=1.8ms decode=6.3ms
SELECT u0."id", u0."name", u0."registered_at", u0."created_at", u0."updated_at" FROM "users" AS u0 []
[%ConvertSchemaSandboxPhoenix.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">,
  created_at: #Ecto.DateTime<2016-10-18 08:00:03.501788>, id: 1,
  name: "d-kitamura", registered_at: #Ecto.DateTime<2016-10-18 07:44:00>,
  updated_at: #Ecto.DateTime<2016-10-18 08:00:03.501788>}]
iex(3)> Repo.all Post
[debug] QUERY OK source="posts" db=2.1ms
SELECT p0."id", p0."user_id", p0."number", p0."title", p0."body", p0."tags", p0."created_at", p0."updated_at" FROM "posts" AS p0 []
[%ConvertSchemaSandboxPhoenix.Post{__meta__: #Ecto.Schema.Metadata<:loaded, "posts">,
  body: "いい天気なのでrailsをやってみました",
  created_at: #Ecto.DateTime<2016-10-18 08:00:13.448647>, id: 1, number: 1,
  tags: ["rails", "ruby"], title: "railsやってみた",
  updated_at: #Ecto.DateTime<2016-10-18 08:00:13.448647>, user_id: 1}]

phoenix から rails で登録した DB のデータを問題なく取得できているようですね.挿入にも挑戦します.

iex(4)> Repo.insert(%Post{user_id: 1, number: 2, title: "phoenixやってみた", body: "寒かったのでphoenixに挑戦しました", tags: ["phoenixframework", "elixir"]})
[debug] QUERY OK db=5.6ms
INSERT INTO "posts" ("body","number","tags","title","user_id","created_at","updated_at") VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING "id" ["寒かったのでphoenixに挑 戦しました", 2, ["phoenixframework", "elixir"], "phoenixやってみた", 1, {{2016, 10, 18}, {10, 23, 37, 0}}, {{2016, 10, 18}, {10, 23, 37, 0}}]
{:ok,
 %ConvertSchemaSandboxPhoenix.Post{__meta__: #Ecto.Schema.Metadata<:loaded, "posts">,
  body: "寒かったのでphoenixに挑戦しました",
  created_at: #Ecto.DateTime<2016-10-18 10:23:37>, id: 2, number: 2,
  tags: ["phoenixframework", "elixir"], title: "phoenixやってみた",
  updated_at: #Ecto.DateTime<2016-10-18 10:23:37>, user_id: 1}}

さて,phoenix で DB へ挿入したものは rails アプリケーションからも正しく見えるでしょうか.

/Users/d-kitamura/src/convert_schema_sandbox_rails% bin/rails console
Running via Spring preloader in process 21211
Loading development environment (Rails 5.0.0.1)
irb(main):001:0> pp Post.all
  Post Load (1.1ms)  SELECT "posts".* FROM "posts"
[#<Post:0x007fc0c2e681f8
  id: 1,
  user_id: 1,
  number: 1,
  title: "railsやってみた",
  body: "いい天気なのでrailsをやってみました",
  tags: ["rails", "ruby"],
  created_at: Tue, 18 Oct 2016 08:00:13 UTC +00:00,
  updated_at: Tue, 18 Oct 2016 08:00:13 UTC +00:00>,
 #<Post:0x007fc0c2e5a2b0
  id: 2,
  user_id: 1,
  number: 2,
  title: "phoenixやってみた",
  body: "寒かったのでphoenixに挑戦しました",
  tags: ["phoenixframework", "elixir"],
  created_at: Tue, 18 Oct 2016 10:23:37 UTC +00:00,
  updated_at: Tue, 18 Oct 2016 10:23:37 UTC +00:00>]
=> #<ActiveRecord::Relation [#<Post id: 1, user_id: 1, number: 1, title: "rails やってみた", body: "いい天気なのでrailsをやってみました", tags: ["rails", "ruby"], created_at: "2016-10-18 08:00:13", updated_at: "2016-10-18 08:00:13">, #<Post id: 2, user_id: 1, number: 2, title: "phoenixやってみた", body: "寒かったのでphoenixに挑戦しました", tags: ["phoenixframework", "elixir"], created_at: "2016-10-18 10:23:37", updated_at: "2016-10-18 10:23:37">]>

大丈夫そうですね.完成です!

補遺

rails のデフォルトの timestamps は createdat, updatedat になっています. phoenix のデフォルトの timestamps は insertedat, updatedat になっています. phoenix の timestamps のカラム名を変更して,rails のものへあわせるには timestamps inserted_at: :created_at のように記述します. ecto/lib/ecto/schema.ex のドキュメントとコードを眺めて知りました.

phoenix で扱える postgresql の DB のカラムについては lib/ecto/adapters/postgres/connection.ex のあたりが参考になるかもしれません.

まとめ

rails で作った DB を,schema.rb を元に生成した elixir コードを用いて phoenix から表示できるようにしました. また,phoenix から挿入したレコードも rails で問題なく見られることを確認しました.

Farmnote では近い将来 phoenix を使う可能性があり,この記事はその調査を兼ねております. また rip.rb についての内容は記事の長さの関係上,説明を省略してしまいました.

【10/21開催・中途向け】ファームノート・スカイアーク会社説明会 in 札幌に私も参加します.

もしこちらに参加されると,この記事やその他技術的な内容について,あるいは近い将来の phoenix の使いどころについての雑談ができると思います. 軽い気持でご参加ください.お待ちしておりますー.

もちろんその他の場所でもお話ししましょう!

このエントリーをはてなブックマークに追加