使い慣れたRailsのプロジェクトを拡張したいのですが、その都度技術スタックを増やす必要があり、この点をどうにかクリアしたいと考えています。連載「Rails2Phoenix」になります、今回はフレームワークをElixir製のPhoenix Frameworkへと変更を試みました。
というわけで、現在つかっているRailsをPhoenixに変更することにしました。方針は以下の通りで、今回はRailsから移行中のPhoenix UmbrellaプロジェクトをHerokuにデプロイする流れをとりあげます。
方針
phoenix/base
をベースに基本的にドキュメント通り。
まず、こんな感じでPhoenixの骨組みをつくります。Phoenix関連のファイル apps/
, deps/
, config/config.exs
, mix.exs
, mix.lock
が追加されます。
> cd rails_project
> mix new . --umbrella
> (cd ./apps && mix phx.new phoenix_app)
次に、既存のRailsでつくられたスキーマをPhoenixに移植します。Ripperをつかうとはかどります。ちなみに手動でスキーマをつくりたい場合は、CLI mix phx.gen.schema --no-migration Blog.Post blog_posts title:string
で作成します。
# lib/tasks/convert_to_phoenix.rake
# こちらはスキーマ移植タスクをPhoenix1.3用に改めたもの
require 'ripper'
require 'erb'
require 'fileutils'
namespace :db do
namespace :schema do
desc 'Convert schema from Rails to Phoenix'
task convert_to_phoenix: :environment do
ConvertSchemaForPhoenixService.call
end
end
end
class ConvertSchemaForPhoenixService
class << self
def call
FileUtils.mkdir_p(File.join('tmp', 'models'))
extract_activerecord_define_block(
Ripper.sexp(
Rails.root
.join('db', 'schema.rb')
.read
)
).select(&method(:create_table_block?))
.map(&method(:configuration))
.each do |conf|
project_name = 'PhoenixApp'
table_name = conf[:table_name]
table_columns = conf[:table_columns].reject(&method(:reject_condition))
.map do |c|
case c[:column_type]
when 'text' then c[:column_type] = ':string'
when 'datetime' then c[:column_type] = ':naive_datetime'
when 'inet' then c[:column_type] = 'EctoNetwork.INET'
else c[:column_type] = ":#{c[:column_type]}"
end
c
end
File.write(
File.join('tmp', 'models', "#{conf[:table_name].singularize}.ex"),
template.result(binding)
)
end
end
private
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)
# Return value of `t.index` is array like ['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)
# If is not `column_option`, then `table_column_sexp.dig(4, 1, 1,
# 1)` method return nil. Set blank array ([]) for avoiding nil.
table_column_sexp.dig(4, 1, 1, 1) || []
end
def extract_option_key(column_option_sexp)
# Remove colon for avoiding `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 then element.dig(1)
when :string_content then element.dig(1, 1) || ''
end
end
def template
ERB.new(<<'__EOD__', nil, '-')
defmodule <%= project_name %>.<%= table_name.classify %> do
use Ecto.Schema
import Ecto.Changeset
alias <%= project_name %>.<%= table_name.classify %>
schema "<%= table_name %>" do<% table_columns.each do |c| %>
field :<%= c[:column_name] -%>, <%= c[:column_type] -%>
<% end %>
timestamps inserted_at: :created_at
end
@doc false
def changeset(%<%= table_name.classify %>{} = <%= table_name.singularize %>, attrs) do
<%= table_name.singularize %>
|> cast(attrs, [<%= table_columns.map { |c| ":" << c[:column_name] }.join(", ") -%>])
# |> validate_required([<%= table_columns.map { |c| ":" << c[:column_name] }.join(", ") -%>])
end
end
__EOD__
end
def configuration(table)
{
table_name: extract_table_name(table),
table_columns: extract_table_columns(table).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
def reject_condition(column)
column[:column_name] =~ /\A(created|updated)_at\z/ || column[:column_type] == 'index'
end
end
end
> rails db:schema:convert_to_phoenix
最後に、既存DBへはこんな感じで接続します。
# rails_project/apps/phoenix_app/config/dev.exs
config :phoenix_app, PhoenixApp.Repo,
adapter: Ecto.Adapters.Postgres,
url: System.get_env("DATABASE_URL"),
pool_size: 10,
ssl: true
> (cd ./apps/phoenix_app/assets && npm install)
> mix deps.get
> mix phx.server
さて、既存のCI(Wercker)も更新しましょう。今回はPhoenix関連ブランチが更新された場合にのみ、関連パイプラインを走らせるように下記のように変更しました。
BEFORE
AFTER
# wercker.yml
deploy-phoenix-prod-heroku:
steps:
- add-ssh-key:
host: github.com
keyname: GITHUB
- add-to-known_hosts:
hostname: github.com
fingerprint: 16:27:ac:a5:76:28:2d:36:63:1b:56:4d:eb:df:a6:48
- heroku-deploy:
key: $HEROKU_KEY
user: $HEROKU_USER
app-name: $HEROKU_APP_NAME
install-toolbelt: true
after-steps:
- wantedly/pretty-slack-notify:
webhook_url: ${SLACK_WEBHOOK_URL}
channel: general
基本ドキュメントの説明通りです。Phoenix Umbrellaプロジェクトの注意点としては、ディレクトリの差異くらいでそれ以外は同じです。つまり、これ rails_project/config/prod.exs
をこう rails_project/apps/phoenix_app/config/prod.exs
変更します。
1. Herokuアプリにビルドパックを適用
> heroku create --buildpack https://github.com/HashNuke/heroku-buildpack-elixir.git
> heroku buildpacks:add https://github.com/gjaldon/heroku-buildpack-phoenix-static.git
2. 起動設定を準備
# rails_project/elixir_buildpack.config
erlang_version=19.1
elixir_version=1.4.2
always_rebuild=false
pre_compile="pwd"
post_compile="pwd"
runtime_path=/app
config_vars_to_export=(DATABASE_URL)
config_vars_to_export=(DATABASE_POOL_SIZE)
# rails_project/phoenix_static_buildpack.config
phoenix_relative_path=apps/phoenix_app
# rails_project/Procfile
web: MIX_ENV=prod mix phx.server
3. 環境変数を適用
データベース関連。
# rails_project/apps/phoenix_app/config/prod.exs
config :phoenix_app, PhoenixApp.Repo,
adapter: Ecto.Adapters.Postgres,
url: System.get_env("DATABASE_URL"),
pool_size: String.to_integer(System.get_env("DATABASE_POOL_SIZE") || 10),
ssl: true
heroku config:set DATABASE_URL=foo
heroku config:set DATABASE_POOL_SIZE=bar
クレデンシャル関連。
> heroku config:set HEROKU_API_KEY=$(heroku auth:token)
> heroku config:set SECRET_KEY_BASE=$(mix phx.gen.secret)
大枠は想定通りすんなり進めることが出来ましたが、課題もいくつか出てきました。まずは認証機能。こちらは次回のテーマで取り上げようと思いますが、Railsの認証ライブラリほど充実していないので自前でいくつか用意する必要がありそうです。次にビジネスロジック。これは元のRailsの実装が悪かったので致し方ないのですが、移植するのに時間がかかりそうです。先にRails側を整理してから進めた方が良いかもしれません。
テクノロジーの進化は、絶え間ない変化の中で私たちの日常を塗り替えてきました。時には経済的な危機が、新たな可能性を切り拓く契機となることもあります。そこで、過去のリセッション期に生まれたテクノロジーの足
ATKerneyの課題解決パターン は、課題の本質を見極め、効果的な戦略的構造化を通じて解決策を導き出す手法にフォーカスしています。この冒険の旅は、解決者と協力者たちが心を一つにし、課題に立ち向かう様
私はいわゆる就職氷河期世代です。周囲から時折漏れ聞こえる不平のような言葉がありますが、それを単なる不平として片付けるのはもったいない気がします。できれば、その中に新しい視点を見つけ、次のチャンスへ繋げ