概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: Use Ruby Objects to Keep Your Rake Tasks Clean — INTERSECT
- 原文公開日: 2018/03/02
- 著者: Jared White
- サイト: Intersect -- Whitefusion社の技術ブログです。
記事で取り上げられているHerokuのReview Apps機能、よさそうですね。
Rails: rakeタスクをRubyオブジェクトで美しく保つ(翻訳)
DHHの「Writing Software Well」シリーズの精神に則り、実際に動いているproductionコードを用いてデモいたします。
私はDHHことDavid Heinemeier HanssonのYouTube動画シリーズ「On Writing Software Well」から大いに刺激を受けました。十分に時間をかけて現実世界のproductionコードをひと通り駆け抜けて、コードをそのように書いた理由やそこで発生するトレードオフ、そしてコードをさらに改善する方法についてみっちり議論するところを見るのは、掛け値なしにとても嬉しいことです。
今回は、インフラストラクチャの付属部品を最小限かつきれいに保つ方法について説明したいと思います。Railsで、コードがすぐに肥大化して構造化も満足に行われず、素直にテストできなくなりがちな部分と言えば、rakeタスクもそのひとつです。
それでは、最近私が顧客のプロジェクトで実際にリファクタリングしたrakeタスクを見ていくことにしましょう。私たちのところではHerokuのReview Apps機能を使っています。Review Appsは、GitHub上のどのプルリクからでも新しいアプリを動かせます。QAスペシャリストやプロダクトマネージャーはこれを使って特定のfeature branchの機能を本番とは別にチェックできるのでとても便利です。しかし、そこで実行するデプロイ後rakeタスクでは、正しいサブドメイン/SSL証明書、データの検索用インデックスなどを設定していたのですが、これがだんだんつらくなってきたのです。rakeタスクがコードでぎっしりになってしまっていたので、これはリファクタリングが必要というサインであると私には思えました。
まずは改修前のコードから(デリケートな部分は少し変えてあります)。
namespace :heroku do
desc "Run as the postdeploy script in heroku"
task :setup do
heroku_app_name = ENV['HEROKU_APP_NAME']
begin
new_domain = "#{ENV['HEROKU_APP_NAME']}.domain.com"
# set up Heroku domain (or use existing one on a redeploy)
heroku_domains = heroku.domain.list(heroku_app_name)
domain_info = heroku_domains.find{|item| item['hostname'] == new_domain}
if domain_info.nil?
domain_info = heroku.domain.create(heroku_app_name, hostname: new_domain)
end
key = ENV['CLOUDFLARE_API_KEY']
email = ENV['CLOUDFLARE_API_EMAIL']
connection = Cloudflare.connect(key: key, email: email)
zone = connection.zones.find_by_name("domain.com")
# delete old dns records
zone.dns_records.all.select{|item| item.record[:name] == new_domain}.each do |dns_record|
dns_record.delete
end
response = zone.dns_records.post({
type: "CNAME",
name: new_domain,
content: domain_info['cname'],
ttl: 240,
proxied: false
}.to_json, content_type: 'application/json')
# install SSL cert
s3 = AWS::S3.new
bucket = s3.buckets['theres_a_hole_in_the_bucket']
crt_data = bucket.objects['__domain_com.crt'].read
key_data = bucket.objects['__domain_com.key'].read
if heroku.ssl_endpoint.list(heroku_app_name).length == 0
heroku.ssl_endpoint.create(heroku_app_name, certificate_chain: crt_data, private_key: key_data)
end
sh "rake heroku:start_indexing"
rescue => e
output = "** ERROR IN HEROKU RAKE **\n"
output << "#{e.inspect}\n"
output << e.backtrace.join("\n")
puts output
ensure
heroku.app.update(heroku_app_name, maintenance: false)
end
puts "Postdeploy script complete"
end
def heroku
@heroku ||= PlatformAPI.connect_oauth(ENV['HEROKU_PLATFORM_KEY'])
end
end
ぐぬぬ、これを読みとおすのは骨が折れます。この時点のタスクがかなり長いのはもちろんのこと、実行するコードのブロック同士にもある種の依存関係が生じていて、軽く読み流すぐらいでは見極めが困難です。
それでは、このコードをどのようにリファクタリングしたかを説明しましょう。まずlib
フォルダの下にHerokuReviewAppPostDeploy
という新しいクラスを作成し、各ブロックを個別のメソッドとしてこのクラスに切り出しました。この新しいオブジェクト内でも同じようなことを行っている(GitHubリポジトリへの接続やプルリクのブランチ名取得など)のにお気づきでしょうか。これによって、Jiraのチケット番号をレビュー対象アプリのサブドメインに置いています。リファクタリングの途中で要件が正しく見えてきたので、コードがさらなる肥大化を回避できたのはありがたいことでした。
完全なクラスは次のとおりです。
class HerokuReviewAppPostDeploy
attr_accessor :heroku_app_name, :heroku_api
def initialize(heroku_app_name)
self.heroku_app_name = heroku_app_name
self.heroku_api = PlatformAPI.connect_oauth(ENV['HEROKU_PLATFORM_KEY'])
end
def turn_on_maintenance_mode
heroku_api.app.update(heroku_app_name, maintenance: true)
end
def turn_off_maintenance_mode
heroku_api.app.update(heroku_app_name, maintenance: false)
end
def determine_subdomain
new_subdomain = heroku_app_name
pull_request_number = begin
heroku_app_name.match(/pr-([0-9]+)/)[1]
rescue NoMethodError; nil; end
unless pull_request_number.nil?
github_info = HTTParty.get('https://api.github.com/repos/organization/reponame/pulls/' + pull_request_number, basic_auth: {username: 'janedoe', password: ENV["GITHUB_API_KEY"]}).parsed_response
if github_info["head"]
branch = github_info["head"]["ref"]
jira_id = begin
branch.match(/WXYZ-([0-9]+)/)[1]
rescue NoMethodError; nil; end
unless jira_id.nil?
new_subdomain = "#{heroku_app_name.match(/^([a-z]+)/)[1]}-wxyz-#{jira_id}"
end
end
end
new_subdomain
end
def determine_domain
"#{determine_subdomain}.domain.com"
end
def setup_domain_on_heroku(new_domain)
# set up Heroku domain (or use existing one on a redeploy)
heroku_domains = heroku_api.domain.list(heroku_app_name)
domain_info = heroku_domains.find{|item| item['hostname'] == new_domain}
if domain_info.nil?
heroku_api.domain.create(heroku_app_name, hostname: new_domain)
else
domain_info
end
end
def setup_domain_on_cloudflare(new_domain, heroku_domain_info)
key = ENV['CLOUDFLARE_API_KEY']
email = ENV['CLOUDFLARE_API_EMAIL']
connection = Cloudflare.connect(key: key, email: email)
zone = connection.zones.find_by_name("domain.com")
zone.dns_records.all.select{|item| item.record[:name] == new_domain}.each do |dns_record|
dns_record.delete
end
response = zone.dns_records.post({
type: "CNAME",
name: new_domain,
content: heroku_domain_info['cname'],
ttl: 240,
proxied: false
}.to_json, content_type: 'application/json')
end
def setup_ssl_cert_on_heroku
# install SSL cert
s3 = AWS::S3.new
bucket = s3.buckets['theres_a_hole_in_the_bucket']
crt_data = bucket.objects['__domain_com.crt'].read
key_data = bucket.objects['__domain_com.key'].read
if heroku_api.ssl_endpoint.list(heroku_app_name).length == 0
heroku_api.ssl_endpoint.create(heroku_app_name, certificate_chain: crt_data, private_key: key_data)
end
end
end
この新しいアプローチのおかげで、オブジェクトを使って細かな機能を単一目的のメソッドに切り分けられるようになりましたが、一部のメソッドでは他のメソッドで生成したデータを必要としているので、そうした変数をメソッドの引数として含めることができます(たとえば setup_domain_on_heroku
に明示的にnew_domain
を渡すなど)。
リファクタリング後のrakeタスクはどんな姿になったでしょうか?とても、とてもよくなりました。
namespace :heroku do
desc "Run as the postdeploy script in heroku"
task :setup do
heroku_app_name = ENV['HEROKU_APP_NAME']
post_deploy = HerokuReviewAppPostDeploy.new(heroku_app_name)
begin
post_deploy.turn_on_maintenance_mode
new_domain = post_deploy.determine_domain
heroku_domain_info = post_deploy.setup_domain_on_heroku(new_domain)
post_deploy.setup_domain_on_cloudflare(new_domain, heroku_domain_info)
post_deploy.setup_ssl_cert_on_heroku
Rake::Task['db:migrate'].invoke
sh "rake heroku:start_indexing"
rescue => e
output = "** ERROR IN HEROKU RAKE **\n"
output << "#{e.inspect}\n"
output << e.backtrace.join("\n")
puts output
ensure
post_deploy.turn_off_maintenance_mode
end
puts "Postdeploy script complete"
end
end
レビューするアプリの設定を完了する処理を追うのに必要な個別の手順を追いやすくなり、あるメソッドから返される変数を設定して別のメソッドに渡すようにしたことで手順同士の依存関係がクリアになりました。さらにHerokuReviewAppPostDeploy
のメソッド名を、何を行っているのかが正確にわかるように素直に命名したことで、コードの説明に必要だったコメントを大きく削減できました。
この「単独のオブジェクトへの切り出し」テクニックは、アプリの他の部分での肥大化したコードにも適用できます。バックグラウンドジョブもよい例でしょう。私はSidekiqのワーカーを最小限に保っておくのが好きなので、多くの場合、単一のモデル上で単一のメソッドを呼び出せば事足りるようにしています。
本記事が、皆さまがproductionで実際に動かしているコードベースを改善するアイデアのヒントになれば幸いです。本シリーズの今後の記事にもどうぞご期待ください(元記事末尾のINTERSECTニュースレターでぜひご登録をお願いします)。
お知らせ
Intersectは、Whitefusion社が提供するJekyllベースのオープンなWebブログです。弊社についての詳細や弊社の目指すものについては会社概要をご覧ください。