アクトインディ開発者ブログ

子供とお出かけ情報「いこーよ」を運営する、アクトインディ株式会社の開発者ブログです

YJITでRailsを動かす in macOS

s4naです。

とうとうRuby 3.1にYJITが追加されましたね🎉🎉🎉

Shopifyさんのブログによると、既にRailsでも動作するという話がありました。

今回はmacOSでRailsを動かす方法について書いてみました。

shopify.engineering

作業するにあたって必要な環境

  • homebrew, ruby-build, rbenvインストール済み
  • rbenvにて何らかのバージョンのRubyがインストールしてあり、動作する状態

ruby-buildの更新

まずは開発中のRuby3.1をインストールするために、ruby-buildの更新をします。

brew upgrade ruby-build

rbenvでインストール

以下コマンドを実行して、 3.1.0-dev がインストール可能であることを確認します。

rbenv install --list-all | grep 3.1.0

実行結果が以下のように 3.1.0-dev が含まれていれば大丈夫です。🙆‍♂️ ※Ruby 3.1.0リリース以降は、以降の 3.1.0-dev という文字列を 3.1.0 と読み替えてください🙏

3.1.0-dev
jruby-9.3.1.0
rbx-3.100

3.1.0-dev をインストールする

rbenv install 3.1.0-dev

$rails newする

試しに最新のRailsだと失敗したので、Railsのmainブランチを使ってRails newします。

github.com

まずはローカルにRailsをcloneします。

git clone git@github.com:rails/rails.git

次に $rails new するディレクトリを作成します。

mkdir my-test-app
cd my-test-app

ディレクトリに移動したら、rbenvで別のRubyのバージョンが指定されている可能性もあるので、localで 3.1.0-dev を指定しておきます。

rbenv local 3.1.0-dev

以下コマンドで、別ディレクトリにcloneしたRailsを使って rails new できます。 ※以下コマンドの場合、一つ上のディレクトリに git@github.com:rails/rails.git がcloneされていることを想定しています。

RUBYOPT='--yjit' ../rails/railties/exe/rails new . --edge --dev

RUBYOPT については以下を参照してください。環境変数として設定することで、Rubyに標準的にオプションを渡すことができます。

docs.ruby-lang.org

RailsがYJITで動作しているか確認

最後にRailsがYJITで動作しているか確認します。

RUBYOPT='--yjit' bundle exec rails s

http://localhost:3000/ にアクセスすると以下のように、画面下部に +YJIT と表示されているのでYJITで動いていることがわかります。

f:id:s4na:20211027225046p:plain

Railsで #update のできるFormObjectを作る

最近FormObjectを作ろうと思って調べてみたところ、ネット上の記事だと #update ができなかったり、Ruby 3.0 だとそのまま動かなかったりしたので、需要があるかと思い作成しました。

まだまだ色々改善点があるので、元のリポジトリの方でもう少し改善していこうと思っています。

また、FormObject自体に関する説明は、こちらのサイト(Railsのデザインパターン: Formオブジェクト)によくまとまっているので、ご参照ください。🙏

モデル

class Post < ApplicationRecord
end

フォーム

class PostForm
  include ActiveModel::Model

  attr_accessor :title, :content

  validates :title, presence: true

  delegate :persisted?, to: :post

  def initialize(post = Post.new, **attributes)
    @post = post
    attributes = { title: post.title, content: post.content } if attributes.empty?
    super(attributes)
  end

  def save
    return if invalid?

    post.update!(title: title, content: content)
  rescue ActiveRecord::RecordInvalid
    false
  end

  def update(params)
    self.attributes = params
    save
  end

  def to_model
    post
  end

  private

  attr_reader :post
end

コントローラー

class PostsController < ApplicationController
  def index
    @posts = Post.all
  end

  def show
    @post = post
  end

  def new
    @post_form = PostForm.new
  end

  def edit
    @post_form = PostForm.new(post)
  end

  def create
    @post_form = PostForm.new(**post_params)

    if @post_form.save
      redirect_to posts_path, notice: 'The post has been created.'
    else
      render :new
    end
  end

  def update
    @post_form = PostForm.new(post, **post_params)

    if @post_form.save
      redirect_to post, notice: 'The post has been updated.'
    else
      render :edit
    end
  end

  def destroy
    post.destroy
    redirect_to posts_url, notice: 'Post was successfully destroyed.'
  end

  private

  def post_params
    params.require(:post).permit(:title, :content)
  end

  def post
    @post ||= Post.find(params[:id])
  end
end

ビュー

<%= form_with(model: post_form) do |f| %>
  <% if post_form.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(post_form.errors.count, "error") %> prohibited this post_form from being saved:</h2>

      <ul>
        <% post_form.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="field">
    <%= f.label :title %>
    <%= f.text_field :title %>
  </div>

  <div class="field">
    <%= f.label :content %>
    <%= f.text_area :content %>
  </div>

  <div class="actions">
    <%= f.submit %>
  </div>
<% end %>

スキーマ

ActiveRecord::Schema.define(version: 20_210_810_101_738) do
  create_table 'posts', force: :cascade do |t|
    t.string 'title'
    t.text 'content'
    t.datetime 'created_at', precision: 6, null: false
    t.datetime 'updated_at', precision: 6, null: false
  end
end

参考

作成するにあたり、参考にさせていただいた記事。

Sunspotを使いRailsでSolrの環境構築を行う方法

はじめに

あけましておめでとうございます。
s4naです。
今回は、最近勉強し始めたSolrについて書いていきます。

SunspotとはSolrという検索エンジンをRuby上で使うためのライブラリです。

github.com

中でもSunspot::Rails を使うとSolrをインストールせずともSolrの開発環境が構築できます。

動作環境

本記事内容は以下のの環境で動作確認できています。

  • macOS Catalina
  • Homebrew: 2.7.5
  • Ruby: 2.7.1
  • Bundler: 2.1.4
  • Ruby on Rails: 6.1.1

作業内容

Javaのインストール

SolrはJavaで動いているためJavaをインストールする必要があります。 Solr/Java Requirements によるとSolrが対応しているバージョンがJava1.8なので、1.8をインストールします。

Java1.8というのは、Java 8とも呼ばれます。
同じバージョンを指す名前が2種類?あることついては歴史的経緯があるみたいです。
詳しくは 👉 Java SE versions history

ということで、AdoptOpenJDKの8をインストールします。

OpenJDKではなく、AdoptOpenJDKなことに注意してください。
※なぜOpenJDKでインストールできないのかについては以下の記事をご確認ください。

MacのBrewで複数バージョンのJavaを利用する + jEnv Java 8のインストール

brew install --cask adoptopenjdk8

pathを通す .zshrc に追加(bashを使っているのであれば、 .bashrc )

export JAVA_HOME=`/usr/libexec/java_home -v "1.8"`
PATH=${JAVA_HOME}/bin:${PATH}

RailsにGemを入れる

gem 'sunspot_rails'

group :development, :test do
  gem 'sunspot_solr'
end
  • sunspot_rails: RailsとSunspotの連携を良い感じでしてくれるGem。
  • sunspot_solr : これを入れると開発環境でSolr自体をインストールしないで済むGem。 ※公式Documentにもありますが、本番環境では使用できません。

Sunspot::RailsをRailsにインストール

bundle exec rails generate sunspot_rails:install

ModelにSeachableを追加する

SolrにindexしたいModelに searchable do ~ end を追加しましょう。
それだけだとわからないので、以下に例を書いておきます。

https://github.com/sunspot/sunspot#setting-up-objects

例:Post Modelにbodyというカラムがあった時

まずは動かすことが目的なので、とりあえず1カラムindexを追加してみましょう。

class Post < ApplicationRecord
  searchable do
    text :body
  end

Solrの起動を確認

bundle exec rake sunspot:solr:start

http://localhost:8982/solr/#/ でアクセスすることができれば、Solrがローカルで起動しています。

Rails Consoleで Reindex

Rails ConsoleでReindexしてみます。
Solrにデータが読み込まれるはずです。

$bundle exec rails c でRails Consoleは起動されます。

Post.reindex

Rails ConsoleでSolrの情報を検索

Solrに読み込まれた情報を検索してみます。

pp Post.search { fulltext 'hoge' }

おまけ: 起動しなかった場合

bundle exec rake sunspot:solr:run コマンドで起動時のエラーが確認できるらしいです。
作業していて大変助けられました🙏

Error Tips

私が遭遇したエラー内容と対処方法を記述しています。(検索してひっかかる人もいらっしゃるかもしれないので)

Unrecognized VM option xxx

Unrecognized VM option 'UseConcMarkSweepGC'
Error: Could not create the Java Virtual Machine.
Error: A fatal exception has occurred. Program will exit.

誤ったJavaのバージョンをインストールしている可能性があります。

※古いCGのオプションを指定しているというエラーなので、Gem内部に記述されているJavaVMの起動オプションをいじったら起動しますが(私は一度やってみて起動することを確認しましたが)、サポートしていないバージョンなので、他にもエラーが出てきたりしてやっかいなので、サポートされてるJavaのバージョンをインストールすることをおすすめします。

RSolr::Error::ConnectionRefused

なんらかの原因でSolrが起動していません。

こちらも上記と同様で、誤ったJavaのバージョンをインストールしている可能性があります。

rake aborted!
RSolr::Error::ConnectionRefused: Connection refused - {:data=>"{\"delete\":{\"query\":\"type:User\"}}", :headers=>{"Content-Type"=>"application/json"}, :method=>:post, :params=>{:wt=>:json}, :query=>"wt=json", :path=>"update", :uri=>#<URI::HTTP http://localhost:8982/solr/development/update?wt=json>}
(eval):2:in `post'
/Users/user_name/.rbenv/versions/2.7.1/bin/bundle:23:in `load'
/Users/user_name/.rbenv/versions/2.7.1/bin/bundle:23:in `<main>'

Caused by:
Faraday::ConnectionFailed: Failed to open TCP connection to localhost:8982 (Connection refused - connect(2) for "localhost" port 8982)
(eval):2:in `post'
/Users/user_name/.rbenv/versions/2.7.1/bin/bundle:23:in `load'
/Users/user_name/.rbenv/versions/2.7.1/bin/bundle:23:in `<main>'

Caused by:
Errno::ECONNREFUSED: Failed to open TCP connection to localhost:8982 (Connection refused - connect(2) for "localhost" port 8982)
(eval):2:in `post'
/Users/user_name/.rbenv/versions/2.7.1/bin/bundle:23:in `load'
/Users/user_name/.rbenv/versions/2.7.1/bin/bundle:23:in `<main>'

Caused by:
Errno::ECONNREFUSED: Connection refused - connect(2) for "localhost" port 8982
(eval):2:in `post'
/Users/user_name/.rbenv/versions/2.7.1/bin/bundle:23:in `load'
/Users/user_name/.rbenv/versions/2.7.1/bin/bundle:23:in `<main>'
Tasks: TOP => sunspot:solr:reindex => sunspot:reindex
(See full trace by running task with --trace)

公式のドキュメント

github.com

github.com

github.com

参考にさせていただいた記事

blog.piyo.tech

qiita.com

www.iterontech.com

ローカル環境でもAPMが見たい!

こんにちは!!こんにちは!! moriyamaです。

みなさん、APMってご存知でしょうか?
Application Performance Managementの略で、直訳すると「アプリケーション性能管理」ですね。
広く知られているサービスとして、Amazon CloudWatchNew Relicなどが挙げられます。
こういったサービスを使って、各リソースの使用状況や監視を行うわけですね。


ローカルで動かしたい!

上記のような各リソース監視は、本番運用しているサービスを対象にして、導入する場合がほとんどだと思います。
リリース前後でレスポンス悪化を検知したり、アプリケーションを構成する要素のパフォーマンス調査したりしますね。

そこでふと思ったのです。
ローカルでもAPMが見たい!」と。

もっと言えば、
リクエストに対するどの処理が遅い/重いのかを特定して、リファクタリングしたい!
と考えていました。

本番運用中のサービスで、

  • 『改善すべき箇所を特定したが、具体的にどう修正するかの目処が立っていない』
  • 『リファクタリングしたが、高負荷な処理になっていないか確認したい』

といった場合に改修前後で比較できると色々楽だと思ったため、
Elastic APMで構築してみました。


Railsへ組み込み

今回は下記構成で実装しています。

.
├ app/
├ elastic/
| └ apm-server.yml
├ docker-compose.yml
└ Gemfile

■Gemfile

Elastic APMのgemを導入します。

gem 'elastic-apm'

■ docker-compose.yml

実装当時はElastic系を7.7.1で実装しました。

elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:7.7.1
    environment:
      # シングルノード設定
      - discovery.type=single-node
      - bootstrap.memory_lock=true
      - cluster.name=docker-cluster
      - cluster.routing.allocation.disk.threshold_enabled=false
      - ES_JAVA_OPTS=-Xms1g -Xmx1g
    volumes:
      - es_data:/usr/share/elasticsearch
    ports:
      - 9200:9200
    ulimits:
      memlock:
        soft: -1
        hard: -1

  apm:
    image: docker.elastic.co/apm/apm-server:7.7.1
    volumes:
      # APM用の設定ファイル
      - ./elastic/apm-server.yml:/usr/share/apm-server/apm-server.yml
    depends_on:
      - elasticsearch
      - kibana
    ports:
      - 8200:8200

  kibana:
    image: docker.elastic.co/kibana/kibana:7.7.1
    depends_on:
      - elasticsearch
    environment:
      ELASTICSEARCH_URL: http://elasticsearch:9200
      ELASTICSEARCH_HOSTS: http://elasticsearch:9200
    ports:
      - 5601:5601
  
  # railsアプリケーションに設定追加
  rails:
    # APM接続用URIを環境変数で設定
    environment:
      ELASTIC_APM_SERVER_URL: http://apm:8200
    # APMコンテナへの接続設定
    depends_on:
      - apm

■ elastic/apm-server.yml

apm-server:
  host: "0.0.0.0:8200"


  kibana:
    enabled: true
    host: kibana:5601

  rum:
    enabled: true

output:
  elasticsearch:
    hosts: ["elasticsearch:9200"]

setup:
  kibana:
    host: kibana:5601

  template:
    settings:
      index:
        number_of_replicas: 0

確認方法

  • 起動したローカルのKibana(http://localhost:5601/)にブラウザからアクセスして、赤枠の方の「APM」をクリックして遷移します。
    f:id:setsuna82001:20210112164832p:plain

  • Kibanaの細かい操作は省きますが、下記のようにリクエストに伴う処理の詳細が確認できます。
    f:id:setsuna82001:20210112164821p:plain

  • SQLの項目をクリックすると、実際に発行されたSQLの中身を確認できます。 f:id:setsuna82001:20210112164827p:plain

やったね!


最後に

Elasticsearchにかなりメモリを食われるのでローカルで構築せずに
ステージングとか試験的環境下でやった方がいいと思います。

CodeBuildのレポート機能を使ってRSpecの結果を見る

morishitaです。

前回のエントリで、CodeBuild 上で RSpec を実行する環境について紹介しました。

tech.actindi.net

その中で RSpec の結果を CodeBuild のレポートで確認できるようにしてみたらなかなか良かったのでそれについて紹介します。

CodeBuild のテストレポート機能とは

1年ほど前に CodeBuild に追加された機能で、CodeBuild で実行したテスティングフレームワークの実行結果を管理し、見やすく表示する機能です。

例えば、複数回のテストの実行時間やエラー数をグラフにして表示できます。それを見れば、テストケースが増えてきたとか、実行時間がどれくらいとか傾向がわかります。

f:id:HeRo:20201222183141p:plain
テスト結果トレンド

また、1回のテスト実行結果を次のように表示できます。

f:id:HeRo:20201222183235p:plain
1回のテスト結果

主に Java 系のテスティングフレームワークをサポートしていますが、RSpec でも利用可能です。

RSpec の結果を表示できるようにする

大前提として CodeBuild 上で RSpec を実行しているということが必要となります。
その上でこのレポート機能を利用するためにすることは次の2つです。

  1. sj26/rspec_junit_formatter 導入して RSpec の結果を JUnit 形式でも出力する
  2. buildspec.yml にレポート設定を追加する

これらをもう少し詳しく説明します。

RSpec の結果を JUnit 形式でも出力する

rspec_junit_formatterは RSpec の結果フォーマッタの1つです。RSpec の結果を JUnitXML 形式で出力できます。 JUnitXML は CodeBuild のレポート機能でサポートされているので表示できるようになるというわけです。
RSpec では複数の形式で結果出力可能なので、他の形式での出力も必要なら、そちらはそのままにして JUnitXML 形式での出力も追加すればいいです。

Rails アプリであれば、まず Gemfilerspec_junit_formatter を追加します。

Gemfile

group :test do
  gem 'rspec'
  gem 'rspec_junit_formatter' # <= 追加
end

そして、spec/spec_helper.rb に次の設定を追加すれば出力されるようになります。

spec/spec_helper.rb

RSpec.configure do |config|
  config.add_formatter('RspecJunitFormatter', 'junit_format/rspec_xml')
end

出力先は任意ですが、できれば結果ファイルだけを保存する専用ディレクトリに分けたほうがいいかと思います。そのほうが buildspec.yml のレポート設定で結果ファイルを指定する際に楽です。

buildspec.yml にレポート設定を追加する

出力した RSpec の結果をレポート機能に読み込ませるために buildspec.yml には次の設定を追加します。

reports:
  rspec:
    files:
      - 'junit_format/*'
    file-format: JUNITXML

files: で結果ファイルを指定するのですが、ファイル名まで指定する必要はありません。junit_format/* のように glob パターンで指定できます。こうすると結果ファイルを出力するディレクトリ以下全部を対象とするようなざっくりした指定が可能です。

結果ファイルが複数でも大丈夫

テストケースが増えてくるとtest-queueなどを使って並行実行してテスト全体の実行時間を短縮することもあるでしょう。並行実行するとテスト結果は各スレッドごとにバラバラに出力されたりします。
CodeBuild のレポート機能は出力結果が複数ファイルに分かれていてもまとめて読み込んでくれます。
前述のように glob パターンにマッチする結果ファイルを全部読み込んでレポートを作成してくれるので自前でマージする必要はありません。

結果の確認

1 回のテストの結果は次のように表示されます。

f:id:HeRo:20201222183358p:plain
テスト結果

テストの総数やエラーの数などがドーナツグラフで表示されます。
レポート時間は複数スレッドで並行実行しているので全スレッドの合計値になっているのか実際よりかなり大きな数字が表示されてしまっています。実際には十数分しかかかっていません。
グラフの下には各テストケースの一覧が表示されます。どのテストで失敗したのかがわかります。
更に、失敗したテストケースを開くと次のような詳細が表示されます。ちょっとモザイクが多いのでわかりにくそうに見えますが、どのように失敗したのかわかります。コード修正に必要な情報はここで得られます。

f:id:HeRo:20201222183442p:plain
failしたテストの結果

CodeBuild のレポート機能を使う前はアーティファクトに含まれるログファイルを開いて確認していました。それに比べるとずっと確認しやすくなりました。

SimpleCov にも対応しているけれど…

CodeBuild のレポート機能はテストレポートだけでなくコードカバレッジレポートもできます。

Ruby でテストのコードカバレッジを計測する場合、simplecovが使われるのではないかと思います。
SimpleCov はデフォルトで JSON 形式の結果ファイル .resultset.json を出力します。CodeBuild のレポート機能はこのファイルをサポートしています。したがって gem や設定の追加は不要です。

CodeBuild の設定ファイル buildspec.xml には次の設定を追加します。

reports:
  simplecov:
    files:
      - 'coverage/.resultset.json'
    file-format: SIMPLECOV

で、レポートはこんな感じです。

f:id:HeRo:20201222183545p:plain
カバレッジレポート

ラインカバレッジとブランチカバレッジが円グラフで表示され、ファイルごとのカバレッジも一覧で表示されます。

残念ながらラインカバレッジは正しく表示されません。
おそらく複数スレッドで並行実行しているためです。
SimpleCov は複数スレッドで実行しても自動的にマージして .resultset.json 1 ファイルに結果をまとめてくれるのですが、一部ファイルの結果が重複して含まれてしまっています。仕方のない仕様なのか、不具合なのか、または設定方法がどこか間違っているのか不明ですが正しくありません(simplecov-htmlsimplecov-jsonでは重複なく正しく結果が出ています)。
なので総コード行数が実際より大きくなって、結果カバレッジが下がっています。実際にはこれよりカバレッジは良いです。
多分、複数スレッドで実行していなければ正しい結果が表示できると思います。

一方で、ブランチカバレッジのほうは正しく結果が表示されています(同じ .resultset.json を読んで表示しているはずですが simplecov-html と同じ結果が出ています)。
ブランチカバレッジはぼっち演算子もブランチ扱いとなります。
ひょっとして nil になるかもと予防的に使っているところも多くテストで全部カバーするのはなかなか難しいですが、もう少し改善できるかなという気がします。

ラインカバレッジが正しくない問題を解決しようと Cobertura XML 形式で結果を出力できるsimplecov-cobertura を試してみました。CodeBuild のカバレッジレポートは Cobertura XML 形式もサポートしているのでもしかすると解決できるのではと思ったのです。
ラインカバレッジを正しく表示できました。が、今度はブランチカバレッジが表示できない…。完全な解決策にはなりませんでした。

ただ、正しく結果が表示できたとしてもカバレッジレポートの方はちょっと機能不足を感じます。
まあ、カバレッジが急に下がってないかを確認するには役立ちます。
しかし、やはりコード上でどこが通っていないのかが見れなくては改善に役立てられません。simplecov-html相当の表示ができるようになるといいのですが…。

まとめ

CodeBuild のレポート機能を Rails アプリケーションで使ってみました。
RSpec のテストレポートは便利です。一方、カバレッジレポートにはコード上でカバレッジを表示する機能が追加されればいいのになぁと思いました。
これが揃えばサードパーティサービスを組み合わせることなくテスト実行からレポーティングまでオールインワンな魅力ある環境となると思います。

最後に

アクトインディではテストを改善し、プロダクトの品質を向上するのが大好きなエンジニアを募集しています。 actindi.net

参考

失敗事例の共有。テストが実行できていないのにCI/CDが通ってリリースしてしまった話

はじめまして
アクトインディでWebエンジニア職として採用されて4ヶ月目。
つい最近試用期間が終了して、正式に正社員になったばかりの s4na です。

今回は私の失敗事例を共有させていただきます。

はじまりはリリース後のふとした違和感でした。

最初にその違和感に気づいたのは、チームのメンバーでした。
(本当に感謝しております 🙏 )

f:id:s4na:20200910133907p:plain

f:id:s4na:20200910134118p:plain

たしかに、テストのカバレッジが50%以上も落ちてしまっていました。

・・・私のリリースから。

何かやってしまったみたいです 😢

リリース時のCIのログを見てみると、テストでActive Modelのロードに失敗していました。

An error occurred while loading ./spec/controllers/xxx/xxx/xxx_controller_spec.rb.
Failure/Error: ActiveRecord::Migration.maintain_test_schema!

ActiveRecord::PendingMigrationError:


  Migrations are pending. To resolve this issue, run:

マイグレーションに失敗しているみたいです。

でも、なぜか、本番データベースでのマイグレーションは成功している

本番のデータベースを見てもマイグレーションに成功している。
データの中身を見てみても、カラムは追加されている

どういうことだ??? 🤔

原因判明!!

トラブルなことを聞きつけて、チームメンバーと一緒に通話しながらPRを調査していると、スキーマ定義の日時がおかしいことが判明しました。
(一緒に調べてくださったメンバーに感謝 🙏 )

今回私がリリースする前に、コンフリクトが発生したのですが、その際に古い日時(自分が作成した日時)を優先してしまっていました。そして、そのあとステージングで検証していませんでした。
ステージングで検証していたと思っていたのですが、やったと思っていたのはコンフリクト解消する前だったみたいです・・・

なぜ古い日時(自分が作成した日時)を設定するとテストが実行されなかったのか

マイグレーションファイルとスキーマ定義の日時がずれていると、テストが失敗するためです。

RailsのActiveRecordではDBの状態を管理するために、マイグレーションという仕組みを導入しています。
マイグレーションでは3つの日時を管理しています。

  1. それぞれのマイグレーションファイルの作成日時
  2. スキーマの日時 👈 ここが今回おかしかったところです。
  3. データベース内に取り込んだマイグレーションファイルの作成日時

マイグレーションファイルではどこまでの変更を取り込めば良いかというのを「2.スキーマの日時」で管理しています。
今回そこが一番新しい日時になっていなかったので、不整合となり一部のテストが実行されていませんでした。

詳しくは 👉 Railsガイド Active Record マイグレーション

ではなぜCI/CDが落ちて止まらなかったのか?

まだ再現できていないので未確定なのですが、テストがエラーで落ちなかった原因は、テストを実行する処理内でマイグレーション時のエラーの考慮が漏れていたからだと思われます。

アクトインディではテストの実行を分散処理のライブラリをカスタムして使っていて、その中で「1. テストに向けて準備、2. テストの分散実行、3. サマリーの表示」を行っています。

通常、「1. テストに向けての準備」で失敗するとエラー終了します。
しかしマイグレーションのズレを検知するのは「2. テストの分散実行」内で spec_helper.rbActiveRecord::Migration.maintain_test_schema! をロードしたタイミングになっていました。
ここで失敗すると、そのまま「3. サマリー表示」がされてしまい、正常終了していたようです。

今回の失敗を通して学んだこと

失敗は精神的にダメージを受けるので良くないことではあります。
ですが、普段調べないようなところまでコードを読むという利点があるので、良いきっかけにしていきたいと思いました。

また、引き続き調査を行っていきたいと思います。

時刻の扱いでミスしたので懺悔を

こんにちは!エンジニアのkanekoです。

私はタイムゾーンの考慮に毎回苦戦しています。

そして、最近やらかしが発覚したので反省を書きます。

プロローグ:サービスのデフォルトタイムゾーン

私はいこーよで買えるWEBチケットの商品取り扱いプロダクトの開発を担当しています。

このプロダクトでのタイムゾーン はこのようになっています。

タイムゾーン
アプリケーション UTC
データベース UTC
サーバー JST

アプリケーションもUTCです。これが私は毎回辛い。ここは日本。ここは日本なんだ。

第1章:土日祝日だけ監視したい

いこーよのユーザーさんは、週末にいく先のチケットをお出かけ先が決まったら購入し利用される方が多い傾向があります。 一方、会社として基本は暦通りの営業日なので土日はおやすみです。*1

そこで、サービス開始*2から間も無く、「会社が休みの土日祝日だけデータをチェックする」というバッチジョブを作ることになりました。 そこにアサインしたのはこの私。

私「何時に、どれくらいの頻度でやります?」
ディレクター「深夜帯はアクティブな人が少ないし朝6:00から3時間おきにしよう」
私「わかりました!任せてください!」

そして、データをチェックしておかしければ通知を飛ばすようにしこのようなコードを書きました。

require 'holiday_jp'
(略)
def call
  current_time = Time.zone.now
  today = current_time.to_date
  return unless weekend_or_holiday?(today)
(略)
end

private

def weekend_or_holiday?(today)
  today.saturday? || today.sunday? || HolidayJp.holiday?(today)
end

勘が良い方ならお分かりですね。

第2章:そして発覚の時

ある土曜日の朝9時、このバッチによる通知が飛んできました。 その時は結構な自体だったので、対応可能なメンバーで

「商品落とします」
「私はこれを確認します」
「影響範囲を調べます」
「関係者に連絡します」

という感じで対処を最優先に動きました。

後日、振り返りのミーティングがありました。
冷静なエンジニア「状況を踏まえると朝6時に通知来てないのおかしくないですか?」
私(えっ)
人々「本当だ。後3時間早く気づけたかもしれない」

第3章:一体なぜこんなことに

土曜の朝6時にサーバー自体はJSTなのでジョブ自体は実行されていました。 しかし、アプリケーションはUTCなので、current_time = Time.zone.now の戻り値はUTCの時刻です。

2020/02/01 6:00 に実行(土曜日)したとすると

require 'holiday_jp'
(略)
def call
  current_time = Time.zone.now # => 2020-01-31 21:00, 
  today = current_time.to_date # => 2020-01-31
  return unless weekend_or_holiday?(today) # ここでreturnして終了!!!!!
(略)
end

private

def weekend_or_holiday?(today) # => false
  today.saturday? || today.sunday? || HolidayJp.holiday?(today)
end

となります。

ちなみにテストは書いていましたが、テストではこの事象には気づけませんでした。

プロローグ: それでも生きていく

「改めて振り返るととてもとてもとても単純なことなのに、どうして気づけなかったの・・・」と思うばかり。

私がタイムゾーン に弱いばっかりに検知が遅くなってしまったことは本当に申し訳なく思います。 でもこれを機にいくつかの記事を参考に読んだり、考え方を質問したりして、少ーし成長できたような気もします。 今後は同じ原因で何かの過ちをうみ出さぬよう、時の扱いには細心の注意を払ってコードを書いくぞと心に固く誓っている次第です。

後書き

以上、私の個人的な失敗と誓いの表明でした!

今回は、社内の人が「エンジニアじゃなくてもわかるように書いてよ」って以前言っていたのを思い出して仕立ててみました。 いかがだったでしょうか。

さて、アクトインディでは共に働く仲間を大募集しています。 共に時を超えていきたい方はぜひご連絡ください!

actindi.net

*1:休日にベントを開催するなどしているので、平日以外に出勤している社員もいます。敬意を込めてご紹介

*2:2019/5/22にリリースしました

天気予報APIを使って天気を表示してみた

新米Webエンジニアのhiroさんです。

ツーリングでもおでかけでも天気の情報は欠かせません。
というわけで自分のアプリに天気予報APIから現在の天気を取得して画面に表示してみます。

要件

天気情報提供サービスの OpenWeatherMap の無料枠を使って実装します。

OpenWeatherMap

  • 天気は画像で表現
  • 画像を画面ヘッダのアプリケーション名の横に表示
  • APIから取得した情報は都道府県単位に weather_forecasts テーブルに保持
  • 前回取得した日時から10分が過ぎている場合はAPIから天気を再取得して weather_forecasts に保存
  • 前回取得した日時から10分以内の場合は weather_forecasts テーブルから天気を取得
  • 都道府県はユーザ情報から取得
  • API Key はgem(dotenv-rails)を使って環境変数に格納・取得
  • APIレスポンスの要素を条件に天気画像を表示(画像は いらすとや さんから)

こちらの情報を参考にレスポンス要素の中から ID, Main, Description を使って画像表示の条件を固める。上から順番に表示条件に合致した画像を表示する。

表示条件 画像(9種類)
Description:
clear sky
or
few clouds
f:id:envgp:20200125130520p:plain
clear_sky.png
Description:
scattered clouds
f:id:envgp:20200125131506p:plain
scattered_cloud.png
Description:
broken clouds
or
overcast clouds
f:id:envgp:20200125131715p:plain
cloud.png
ID:500~504
f:id:envgp:20200125134150p:plain
light_rain.png
ID:511~531
f:id:envgp:20200125134630p:plain
rain.png
Descriptionに"thunderstorm"と"rain"を含む
f:id:envgp:20200125134831p:plain
thunderstorm_rain.png
Descriptionに"thunderstorm"のみ含む
f:id:envgp:20200125222308p:plain
thunderstorm.png
Main:Snow
f:id:envgp:20200125135022p:plain
snow.png
Main:Drizzle
f:id:envgp:20200125135205p:plain
drizzle.png
上記以外 表示なし

ブラウザでAPIレスポンスが返ってくるかテスト

千葉県の情報を取得してみます。

https://api.openweathermap.org/data/2.5/weather?id=2113014&units=metric&appid=XX

データはデフォルトのJSON形式で返ってきました。問題ないようです。 このときの千葉県は曇りがち。

{
  "coord": {
    "lon": 140.12,
    "lat": 35.61
  },
  "weather": [
    {
      "id": 803,
      "main": "Clouds",
      "description": "broken clouds",
      "icon": "04n"
    }
  ],
  "base": "stations",
  "main": {
    "temp": 8.24,
    "feels_like": 4.36,
    "temp_min": 5.56,
    "temp_max": 10.56,
    "pressure": 1028,
    "humidity": 54
  },
  "visibility": 10000,
  "wind": {
    "speed": 2.6,
    "deg": 70
  },
  "clouds": {
    "all": 75
  },
  "dt": 1579953000,
  "sys": {
    "type": 1,
    "id": 7955,
    "country": "JP",
    "sunrise": 1579902283,
    "sunset": 1579939091
  },
  "timezone": 32400,
  "id": 2113014,
  "name": "Chiba",
  "cod": 200
}

実装

API Key を環境変数に格納

  • OpenWeatherMap にユーザ登録して API Key を取得
  • gem 'dotenv-rails'をインストール
  • applicationディレクトリトップに .env ファイルを作成
    OPEN_WEATHER_API_KEY=XXXXXXX...
  • rails console にて ENV['OPEN_WEATHER_API_KEY']で取得できること確認
  • .gitignore に/.envを追加し、gitの追跡ファイルから除外

画像ファイルを保存

app/assets/images/weathers に画像ファイルを格納
clear_sky.png
cloud.png
drizzle.png
light_rain.png
rain.png
scattered_cloud.png
snow.png
thunderstorm.png
thunderstorm_rain.png

テーブル周り

  • prefectures テーブルを新規作成

$ bundle exec rails g model prefecture

class CreatePrefectures < ActiveRecord::Migration[5.2]
  def change
    create_table :prefectures do |t|
      t.string :name, null: false,  comment: "県名"
      t.integer "geonames_id", null: false, comment: "GeoNamesのid"

      t.timestamps
    end
  end
end
  • weather_forecasts テーブルを新規作成

$ bundle exec rails g model weather_forecast

class CreateWeatherForecasts < ActiveRecord::Migration[5.2]
  def change
    create_table :weather_forecasts do |t|
      t.references :prefecture, foreign_key: true, null: false
      t.integer :weather_id, null: false, comment: "天気の状態id"
      t.string :main, null: false, comment: "天気の状態"
      t.string :description, null: false, comment: "天気の詳細"
      t.datetime :acquired_at, null: false, comment: "APIで取得した日時"

      t.timestamps
    end
  end
end
  • user テーブルに prefecture_id を追加

$ bundle exec rails g migration AddPrefectureIdToUsers

class AddPrefectureIdToUsers < ActiveRecord::Migration[5.2]
  def up
    add_reference :users, :prefecture, foreign_key: true
  end
  
  def down
    remove_reference :users, :prefecture
  end
end
  • マイグレーションを実行
    $ bundle exec rails db:migrate

  • モデルの関連付けを宣言

class Prefecture < ApplicationRecord
  has_many :users
  has_many :weather_forecasts
end

class User < ApplicationRecord
  belongs_to :prefecture, optional: true
end

class WeatherForecast < ApplicationRecord
  belongs_to :prefecture
end
  • 初期データ(seed)を作成
    とりあえず以下の1都2県を登録
Prefecture.create!(
  [
    {
      name: '東京都',
      geonames_id: '1850147',
    },
    {
      name: '高知県',
      geonames_id: '1859133',
    },
    {
      name: '沖縄県',
      geonames_id: '1854345',
    },
  ]
)

user.prefecture_id に各県を設定

User.create!(
  [
    {
      name: '太郎(管理者)',
      prefecture_id: '1',
    },
    {
      name: '山田 太郎',
      prefecture_id: '2',
    },
    {
      name: '鈴木 一郎',
      prefecture_id: '3',
    },
  ]
)
  • bundle exec rails db:resetでエラー発生
rails aborted!
ActiveRecord::InvalidForeignKey: PG::ForeignKeyViolation: ERROR:  insert or update on table "users" violates foreign key constraint "fk_rails_15bb13b679"
DETAIL:  Key (prefecture_id)=(1) is not present in table "prefectures".

User よりあとに Prefecture を持ってきていたのが原因でした。 Userの前に Prefecture を入れて解決。

天気の状態を返すサービスオブジェクト(forecast.rb)を作成

他にもいろいろ実装しましたが、核となるこのクラスだけ記載します。 API通信失敗時のエラー処理は見にくくなるため省略しています。

工夫したポイント

  • 表示条件の判定を各メソッドに切り出してわかりやすくした。
  • 表示条件の判定メソッド呼び出しを部分を工夫した。#weather_condition
  • Time.zone.now から forecast.acquired_at を引いて時間差を算出。
    diff = Time.zone.now - forecast.acquired_at

APIドキュメントを参照しながら initialize メソッドでリクエストパラメータを設定。

  • 'id': @prefecture.geonames_id,はcity ID で県を指定しています。
  • 'units': 'metric'は摂氏指定です。

app/services/weather/forecast.rb

module Weather
  class Forecast
    
    attr_accessor :prefecture, :data, :weather_id, :main, :description
    
    OPENWEATHERMAP_URL = 'https://api.openweathermap.org/data/2.5/weather'

    CONDITIONS = [
      'clear_sky',
      'scattered_cloud',
      'cloud',
      'light_rain',
      'rain',
      'thunderstorm_rain',
      'thunderstorm',
      'snow',
      'drizzle',
    ]
    
    USE_API_TIME_SEC = 600

    def initialize(prefecture_id)
      @prefecture = Prefecture.find(prefecture_id)
      @data =
      {
        'id': @prefecture.geonames_id,
        'units': 'metric',
        'appid': ENV['OPEN_WEATHER_API_KEY'],
      }
    end

    def weather_condition
      forecast = WeatherForecast.find_by(prefecture_id: @prefecture.id)

      if forecast.present?
        diff = Time.zone.now - forecast.acquired_at
        if diff.to_i >= USE_API_TIME_SEC
          forecast = weather_forecast_update(api_request())
        end
      else
        forecast = weather_forecast_update(api_request())
      end

      @weather_id = forecast.weather_id
      @main = forecast.main
      @description = forecast.description

      CONDITIONS.each_with_index do |condition, index|
        break condition if send("#{condition}?")
        break nil if CONDITIONS.length == index + 1
      end
    end

    private

    def api_request
      query=@data.to_query
      uri = URI("#{OPENWEATHERMAP_URL}?" + query)
      http = Net::HTTP.new(uri.host, uri.port)
      http.use_ssl = true
      response = Net::HTTP.get_response(uri)
      JSON.parse(response.body, symbolize_names: true)
    end

    def weather_forecast_update(res_data)
      weather = res_data[:weather][0]

      obj = {
        prefecture_id: @prefecture.id,
        weather_id: weather[:id].to_i,
        main: weather[:main],
        description: weather[:description],
        acquired_at: Time.zone.now,
      }

      WeatherForecast.find_or_initialize_by(prefecture_id: obj[:prefecture_id]).update_attributes(obj)
      WeatherForecast.find_by(prefecture_id: obj[:prefecture_id])
    end

    def clear_sky?
      description == 'clear sky' || description == 'few clouds'
    end

    def scattered_cloud?
      description == 'scattered clouds'
    end

    def cloud?
      description == 'broken clouds' || description == 'overcast clouds'
    end

    def light_rain?
      (500..504).include?(weather_id)
    end

    def rain?
      (511..531).include?(weather_id)
    end

    def thunderstorm_rain?
      /thunderstorm/ === description && /rain/ === description
    end

    def thunderstorm?
      /thunderstorm/ === description && !(/rain/ === description)
    end

    def snow?
      main == 'Snow'
    end

    def drizzle?
      main == 'Drizzle'
    end
  end
end

検証

画面に画像が表示されました。
f:id:envgp:20200126233158p:plain

  • logを見てもAPIリクエストに成功しています。
INFO -- : HTTP_LOG:opening connection to api.openweathermap.org:443...
INFO -- : HTTP_LOG:
INFO -- : HTTP_LOG:opened
INFO -- : HTTP_LOG:
INFO -- : HTTP_LOG:starting SSL for api.openweathermap.org:443...
INFO -- : HTTP_LOG:
INFO -- : HTTP_LOG:SSL established, protocol: TLSv1.2, cipher: ECDHE-RSA-AES256-GCM-SHA384
INFO -- : HTTP_LOG:
INFO -- : HTTP_LOG:<-
INFO -- : HTTP_LOG:"GET /data/2.5/weather?appid=XXXXXXXXXXXXXX&id=1850147&units=metric HTTP/1.1\r\nAccept-Encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3\r\nAccept: */*\r\nUser-Agent: Ruby\r\nHost: api.openweathermap.org\r\n\r\n"
INFO -- : HTTP_LOG:
INFO -- : HTTP_LOG:-> "HTTP/1.1 200 OK\r\n"
INFO -- : HTTP_LOG:-> "Server: openresty\r\n"
INFO -- : HTTP_LOG:-> "Date: Sun, 26 Jan 2020 14:06:43 GMT\r\n"
INFO -- : HTTP_LOG:-> "Content-Type: application/json; charset=utf-8\r\n"
INFO -- : HTTP_LOG:-> "Content-Length: 446\r\n"
INFO -- : HTTP_LOG:-> "Connection: keep-alive\r\n"
INFO -- : HTTP_LOG:-> "X-Cache-Key: /data/2.5/weather?id=1850147&units=metric\r\n"
INFO -- : HTTP_LOG:-> "Access-Control-Allow-Origin: *\r\n"
INFO -- : HTTP_LOG:-> "Access-Control-Allow-Credentials: true\r\n"
INFO -- : HTTP_LOG:-> "Access-Control-Allow-Methods: GET, POST\r\n"
INFO -- : HTTP_LOG:-> "\r\n"
INFO -- : HTTP_LOG:reading 446 bytes...
INFO -- : HTTP_LOG:-> "{\"coord\":{\"lon\":139.69,\"lat\":35.69},\"weather\":[{\"id\":804,\"main\":\"Clouds\",\"description\":\"overcast clouds\",\"icon\":\"04n\"}],\"base\":\"stations\",\"main\":{\"temp\":4.15,\"feels_like\":1.13,\"temp_min\":0,\"temp_max\":7.78,\"pressure\":1025,\"humidity\":90},\"wind\":{\"speed\":2.08,\"deg\":354},\"clouds\":{\"all\":100},\"dt\":1580046764,\"sys\":{\"type\":3,\"id\":2001249,\"country\":\"JP\",\"sunrise\":1579988763,\"sunset\":1580025647},\"timezone\":32400,\"id\":1850147,\"name\":\"Tokyo\",\"cod\":200}"
INFO -- : HTTP_LOG:read 446 bytes
INFO -- : HTTP_LOG:Conn keep-alive
INFO -- : HTTP_LOG:
  • weather_forecasts テーブルに1件データが登録されています。
pry(main)> WeatherForecast.all
=>   WeatherForecast Load (0.7ms)  SELECT "weather_forecasts".* FROM "weather_forecasts"
[#<WeatherForecast:0x0000555a4ce00d70
  id: 9,
  prefecture_id: 1,
  weather_id: 804,
  main: "Clouds",
  description: "overcast clouds",
  acquired_at: Sun, 26 Jan 2020 21:11:46 JST +09:00,
  created_at: Sun, 26 Jan 2020 21:11:46 JST +09:00,
  updated_at: Sun, 26 Jan 2020 21:11:46 JST +09:00>]
  • 10分待って再度画面を更新してみます。 APIリクエストが走り、WeatherForecast テーブルが更新されました。
pry(main)> WeatherForecast.all
=>   WeatherForecast Load (0.7ms)  SELECT "weather_forecasts".* FROM "weather_forecasts"
[#<WeatherForecast:0x000055b59a546780
  id: 9,
  prefecture_id: 1,
  weather_id: 804,
  main: "Clouds",
  description: "overcast clouds",
  acquired_at: Sun, 26 Jan 2020 21:23:04 JST +09:00,
  created_at: Sun, 26 Jan 2020 21:11:46 JST +09:00,
  updated_at: Sun, 26 Jan 2020 21:23:04 JST +09:00>]
  • 10分以内に再度画面を更新するとAPIリクエストは走らず、WeatherForecast テーブルは更新されていません。問題ないようです。
[3] pry(main)> WeatherForecast.all
=>   WeatherForecast Load (0.8ms)  SELECT "weather_forecasts".* FROM "weather_forecasts"
[#<WeatherForecast:0x000055b59a59e430
  id: 9,
  prefecture_id: 1,
  weather_id: 804,
  main: "Clouds",
  description: "overcast clouds",
  acquired_at: Sun, 26 Jan 2020 21:23:04 JST +09:00,
  created_at: Sun, 26 Jan 2020 21:11:46 JST +09:00,
  updated_at: Sun, 26 Jan 2020 21:23:04 JST +09:00>

結果

このように天気の画像を表示できました👏

f:id:envgp:20200126214126p:plain
f:id:envgp:20200126222008p:plain
f:id:envgp:20200126214028p:plain
f:id:envgp:20200126222108p:plain
f:id:envgp:20200126214225p:plain
f:id:envgp:20200126214353p:plain
f:id:envgp:20200126221623p:plain

所感

今回はじめてAPIを叩いて処理するコードを実装しました。なかなか楽しいです😄
次はテストを書いていきたいです💪
調べてみると日本の快晴日は全国平均でも年間一ヶ月ほどしかありません。休日となるともっと少なくなります。快晴休日は予定を変更しておでかけしてみてはいかがでしょうか。

Railsのバグでは?マイグレーションでMySQLのTIMESTAMP型にNULL 値を許可すると異常系がある

こんにちは、キエンです。

このエントリはアクトインディアドベントカレンダー2019の6日目です。 adventar.org

さっそく、Railsのマイグレーションで一つ問題を気づいています。Railsのバグか、それとも僕がどこか勘違いしていますが、一旦共有させていただきます。どなたか疑問があれば、教えていただけば嬉しいです。

問題

先日、既存のTIMESTAMP型のカラムをマイグレーションでただコメント属性を追記しようと思いましたが、エラーが発生されました。

  • 既存テーブル作成マイグレーション
class CreateArticles < ActiveRecord::Migration[6.0]
  def change
    create_table :articles do |t|
      t.string :name
      t.timestamp :published_at

      t.timestamps
    end
  end
end
  • published_atカラムにコメントを追記するマイグレーション
class ChangePublishedAtCommentOnArticles < ActiveRecord::Migration[6.0]
  def change
    change_column :articles, :published_at, :timestamp, comment: 'comment'
  end
end
  • コメントを追記するマイグレーションを実行するとエラーが発生される
$ bundle exec rake db:migrate
Mysql2::Error: Invalid default value for 'published_at'

調査

環境バージョン

Rails MySQL
6.0.1 5.7

※Rails 5.xも同じ現象を確認しました。

スキーマとカラム変更SQLの確認

既存テーブルのスキーマ

mysql> SHOW CREATE TABLE articles\G
*************************** 1. row ***************************
       Table: articles
Create Table: CREATE TABLE `articles` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) DEFAULT NULL,
  `published_at` timestamp NULL DEFAULT NULL,
  `created_at` datetime(6) NOT NULL,
  `updated_at` datetime(6) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
1 row in set (0.00 sec)

スキーマを見ると、現在published_atはTIMESTAMP型にNULL 値を許可していますね。

ここで軽くMySQLのTIMESTAMP型のNULL制約とデフォルト値を説明します。デフォルトでは、TIMESTAMP型はNOT NULLであり、NULL値を含めることはできません。 TIMESTAMP型にNULL値を許可するため、明示的にNULL属性を追加する必要です。

MySQL :: MySQL 5.7 Reference Manual :: 11.3.5 Automatic Initialization and Updating for TIMESTAMP and DATETIME

CREATE TABLE t1 (
  ts1 TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,     -- default 0
  ts2 TIMESTAMP NULL ON UPDATE CURRENT_TIMESTAMP -- default NULL
);

RailsのマイグレーションでNULLのoptionがTRUE、また指定しない場合、NULL値が許可されています。TIMESTAMP型の場合、NULL値を許可するため、Rails側にNULL属性を追加してくれると思っています。

# timestamp型の場合
t.timestamp :published_at`published_at` timestamp NULL DEFAULT NULL

カラム変更のSQL

change_column :articles, :published_at, :timestamp, comment: 'comment'ALTER TABLE `articles` CHANGE `published_at` `published_at` timestamp DEFAULT NULL COMMENT 'comment'

published_atの変更SQLがおかしいですね。timestamp DEFAULT NULLの原因でMysql2::Error: Invalid default value for 'published_at'エラーが出ていると思います。

NULL属性がないので、published_atのデフォルトの値は0のに、DEFAULT NULLで定義するとinvalidエラーが発生されているからです。 正確なSQLはこちらだと思います。

- ALTER TABLE `articles` CHANGE `published_at` `published_at` timestamp DEFAULT NULL COMMENT 'comment'
+ ALTER TABLE `articles` CHANGE `published_at` `published_at` timestamp NULL DEFAULT NULL COMMENT 'comment'

結論、Railsのスキーマ生成処理に何か問題があるでしょう。次、Railsのスキーマ生成処理を確認しましょう。

Railsのスキーマ生成処理確認

schema_creation.rb

https://github.com/rails/rails/blob/6-0-stable/activerecord/lib/active_record/connection_adapters/mysql/schema_creation.rb#L28

# TIMESTAMP NULLになるかこちらの処理で決める
def add_column_options!(sql, options)
  # By default, TIMESTAMP columns are NOT NULL, cannot contain NULL values,
  # and assigning NULL assigns the current timestamp. To permit a TIMESTAMP
  # column to contain NULL, explicitly declare it with the NULL attribute.
  # See https://dev.mysql.com/doc/refman/5.7/en/timestamp-initialization.html
  if /\Atimestamp\b/.match?(options[:column].sql_type) && !options[:primary_key]
    sql << " NULL" unless options[:null] == false || options_include_default?(options)
  end
end

schema_statements.rb

https://github.com/rails/rails/blob/6-0-stable/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb#L1204

def options_include_default?(options)
  options.include?(:default) && !(options[:null] == false && options[:default].nil?)
end

上記のschema_creation.rbとschema_statements.rbの処理を見ると、NULL属性が付かれるため、マイグレーションで以下のすべて条件を満たす必要です。

  • TIMESTAMP型
  • プライマリーキーではない
  • NULL: TRUE
  • DEFAULTを指定しない

今回コメントを追記するマイグレーションは上記の条件をすべて満たしましたが、なんでNULL属性が付かないですね。

abstract_mysql_adapter.rb

カラム変更の場合、スキーマ生成前に、以下のoptions調整ってメッソドが実行されています。

https://github.com/rails/rails/blob/6-0-stable/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb#L635

def change_column_for_alter(table_name, column_name, type, options = {})
  column = column_for(table_name, column_name)
  type ||= column.sql_type

  unless options.key?(:default)
    options[:default] = column.default
  end

  unless options.key?(:null)
    options[:null] = column.null
  end

  unless options.key?(:comment)
    options[:comment] = column.comment
  end

  td = create_table_definition(table_name)
  cd = td.new_column_definition(column.name, type, options)
  schema_creation.accept(ChangeColumnDefinition.new(cd, column.name))
end

マイグレーションでDEFAULTを指定しない場合既存のカラムのDEFAULTが設定されます。なので、DEFAULTを指定しない 条件を満たさなくになってしまいます。なるほど、動きが理解できました。

ちなみに、マイグレーションでTIMESTAMP型にDEFAULT: NULLを指定してもNULL属性が付かられないです。この条件で問題があると思います。 DEFAULTを指定しないの条件からDEFAULTを指定しないまたはNULLを指定に変更すれば方が良いかと考えておきます。

# schema_creation.rb
sql << " NULL" unless options[:null] == false || options_include_default?(options)
↓
sql << " NULL" unless options[:null] == false || options[:default].present?

Railsのソースを上書きする

TIMESTAMP型は2038年の問題も含めて、TIMESTAMP型を使用するのはよろしくなくて、DATETIME型にした方が良いと思いますが、絶対にTIMESTAMP型のまましないとだめ、かつこの記事の問題を解決したければ、以下の方針があるだと考えておきます。

MySQLで timestamp型 を使うのはNG!2038年問題の対処法 | PisukeCode - Web開発まとめ

class_evalを使用方法

class_evalを使用して、add_column_optionsメソッドを上書きします。

ActiveRecord::ConnectionAdapters::MySQL::SchemaCreation.class_eval do
  private
  
  def add_column_options!(sql, options)
    # By default, TIMESTAMP columns are NOT NULL, cannot contain NULL values,
    # and assigning NULL assigns the current timestamp. To permit a TIMESTAMP
    # column to contain NULL, explicitly declare it with the NULL attribute.
    # See https://dev.mysql.com/doc/refman/5.7/en/timestamp-initialization.html
    if /\Atimestamp\b/.match?(options[:column].sql_type) && !options[:primary_key]
      sql << " NULL" unless options[:null] == false || options[:default].present?
    end

    ...
  end
end

DockerイメージでRailsのソースを修正

Dockerファイルに以下の行を追加すれば良いと思います。

RUN sed -i '34s/options_include_default?(options)/options[:default].present?/' \
    /usr/local/bundle/gems/activerecord-6.0.1/lib/active_record/connection_adapters/mysql/schema_creation.rb

結果

Railsのソースを上書きしてから、無事にマイグレーションでカラムを変更できました。

# マイグレーションで生成したSQL
ALTER TABLE `articles` CHANGE `published_at` `published_at` timestamp NULL DEFAULT NULL COMMENT 'comment'

# コメントが追加できました。
mysql> SHOW CREATE TABLE articles\G
*************************** 1. row ***************************
       Table: articles
Create Table: CREATE TABLE `articles` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) DEFAULT NULL,
  `published_at` timestamp NULL DEFAULT NULL COMMENT 'comment',
  `created_at` datetime(6) NOT NULL,
  `updated_at` datetime(6) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
1 row in set (0.00 sec)

最後に

いかがでしょうか。Railsのソースを読んだら、詳しく処理の仕組みを理解できれば良いですね。 さて、アクトインディではエンジニアを募集していますね、ぜひご応募してください。 actindi.net

PostgreSQLにmoney型というのがあるそうですよという話

こんにちは。kanekoです。 私はいこーよの中でもWEBチケット販売に関するプロダクトのチームにいます。社内での呼び方はいくつかありますがここではプロダクトをticket、チームをチケットチームとします。

この記事の結論

結論を述べますとDBがPostgreSQLのrailsアプリでmoney型のカラムをもつテーブルを作成してあれこれ試してみたところよく分からないこともあったが楽しかった。という感じです。(ざっくり)

money型との出会い

ticketではDBにPostgreSQLを採用しています。

チケットチームは今年動き始めたチームで、アクトインディでは初のECサービスの開発をしています。 私自身も初めてなことがたくさんあり毎日が勉強です。そんな中「DBのお金(料金、手数料ect)の扱いって何が正解なのかしら??」とふと検索したことがきっかけで、つい最近money型があると知りました。

www.postgresql.jp

これってRailsで使えるのかな?とRuby on Rails APIで検索するとありました!

api.rubyonrails.org

お試し

なんだかドキュメントを読んでもわかるような?わからないような?という感触だったので、サンプルアプリで試してみることにしました。アプリの名前はshopで、itemsテーブルを作成します。 itemの持つpriceをmoney型にします。

$ rails new shop -d postgresql
$ rails db:create
$ rails db:migrate
$ rails g scaffold Item name price:money
$ rails db:migrate

これでitemを扱うshopアプリが完成しました!

money型の値

生成されたschema.rbを見てみるとこのようになっていました。

  create_table "items", force: :cascade do |t|
    t.string "name"
    t.money "price", scale: 2
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
  end

たしかにpriceはmoney型です。scale: 2とあるので小数点以下2桁まで桁数を持っています。

画面で入力してitemを作ってみます(画像左)。すると小数点以下2桁あるので、ここでは画像右のように表示されます。

f:id:neko314:20191114181630p:plain:w200 f:id:neko314:20191114182231p:plain:w200

DBで確認

これだけだとただの数値との違いがよくわからないのでDBで確認してみることにしました。rails dbを起動します。

# Select * FROM Items;
 id |  name  |  price  |         created_at         |        updated_at         
----+--------+---------+----------------------------+---------------------------
  2 | pencil | $200.00 | 2019-11-14 09:07:43.859319 | 2019-11-14 09:08:25.83563

$付きでpriceが表示されています!

でもticketでは全商品JPYで商品を扱っています。$扱いだとちょっと悲しい。 PostgreSQLのドキュメントには出力形式は通常は"典型的な"通貨書式となりますが、ロケールによって異なります。とあるので、まず試しにapplication.rbにjaを指定しました。

# application.rb
config.i18n.default_locale = :ja

その後railsを再起動しrails dbでもう一度確認してみます。

# Select * FROM Items;
 id |  name  |  price  |         created_at         |        updated_at         
----+--------+---------+----------------------------+---------------------------
  2 | pencil | $200.00 | 2019-11-14 09:07:43.859319 | 2019-11-14 09:08:25.83563

何も変わってない!!!!(そうは思っていました)

PostgeSQLのロケールとは

あまり考えたことがなかったのですが、そういえばどうなっているのでしょう?検索した結果以下のページを参照してみました。

lets.postgresql.jp

www.postgresql.jp

設定の確認方法の項目にあるコマンドで設定を確認してみます。するとen_USとなっていました。

# SELECT name, setting, context FROM pg_settings WHERE name LIKE 'lc%';
    name     |   setting   |  context  
-------------+-------------+-----------
 lc_collate  | en_US.UTF-8 | internal
 lc_ctype    | en_US.UTF-8 | internal
 lc_messages | en_US.UTF-8 | superuser
 lc_monetary | en_US.UTF-8 | user
 lc_numeric  | en_US.UTF-8 | user
 lc_time     | en_US.UTF-8 | user

lc_monetary 通貨の書式です。money 型の出力方式や解釈に影響します。

とのことなので、ここを日本に指定したら良さそうです。

# set lc_monetary="ja_JP.UTF-8";
SET
#  SELECT name, setting, context FROM pg_settings WHERE name LIKE 'lc%';
    name     |   setting   |  context  
-------------+-------------+-----------
 lc_collate  | en_US.UTF-8 | internal
 lc_ctype    | en_US.UTF-8 | internal
 lc_messages | en_US.UTF-8 | superuser
 lc_monetary | ja_JP.UTF-8 | user
 lc_numeric  | en_US.UTF-8 | user
 lc_time     | en_US.UTF-8 | user
# Select * FROM Items;
 id |  name  |  price  |         created_at         |        updated_at         
----+--------+---------+----------------------------+---------------------------
  2 | pencil | ¥20,000 | 2019-11-14 09:07:43.859319 | 2019-11-14 09:08:25.83563

¥になりました!!!

ただ、このコマンドだとセッション中の値の更新をしているだけなので根本的にはconfigファイルを更新する必要がありそうです。

よく見たら20,000円というめちゃくちゃ高級な鉛筆になってます! PostgreSQL上で表示の際に為替が考慮されいるようなのですが、追い切れなかったので割愛・・・

おまけ

railsには便利メソッドがたくさんありますが、number_to_currencyを使うと引数やアプリケーションの設定で指定したロケールの通貨で表示できてとても便利!

 -<%= @item.price %>
 +<%= number_to_currency(@item.price) %>

f:id:neko314:20191115122414p:plain:w200

感想

上述してないものもありますが、わかったことと感想を列挙します。

  • 使ったことのない型を試してみるのは純粋に楽しかったです。
  • PostgreSQL上で$200が¥20,000と表示されるのを¥200と表示されるように設定できたら、DBでデータ検索したい時にはわかりやすくて良さそうだと感じました。
  • DBのロケールは1つしか指定できないので、複数のロケールの対応は無理なようです。色々な貨幣通貨が混在する可能性がある時はこの型は使わない方が良いかもしれません。
  • DBのできることをもっと知っていきたいと思いました。私は飲み込みが遅い方なので何度ももやっていくうちに少しずつわかっていくタイプなのですが、少しずつでもいいので「こういう時にはこうやってデータを扱うといい」というのを血肉として身につけていきたいです。

最後に

以上、kanekoが業務から湧いた趣味と興味でやってみたという記事でした。最後まで読んでくださってありがとうございました。

アクトインディでは一緒に働くエンジニアを大募集しています。興味のある方はいつでもご連絡ください。 actindi.net