Tech Racho エンジニアの「?」を「!」に。
  • 開発

Rails: rakeタスクをRubyオブジェクトで美しく保つ(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。


whitefusion.ioより

記事で取り上げられている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タスクがコードでぎっしりになってしまっていたので、これはリファクタリングが必要というサインであると私には思えました。

訳注: Heroku Review AppsでPRごとに動作環境を作成せよ - Qiita -- 参考

まずは改修前のコードから(デリケートな部分は少し変えてあります)。

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ブログです。弊社についての詳細や弊社の目指すものについては会社概要をご覧ください。

関連記事

[Rails 5] rakeタスクがrailsコマンドでもできるようになった

リファクタリングRuby: サブクラスをレジストリに置き換える(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。