行動すれば次の現実

ほどよくモダンなシステム開発を目指しています。メインテーマは生産性、Ruby、Javascriptです。

Hashを使って大量のActiveRecordをキャッシュに載せる方法 | Ruby on Rails

ループ処理の中で、あるテーブルに対してアクセスしなければならない場合、ループ毎にテーブルをfindするのは非効率かもしれません。事前に必要なテーブルデータを抽出しておき、そのキャッシュを使って処理するほうが効率的な場合があります。

しかし、このような実装方法をとると、取り扱うデータ量によってはメモリを逼迫する要因になり兼ねません。

そこで、なるべくメモリを消費せずに大量データをキャッシュさせる方法を考えてみようと思います。

ActiveRecordオブジェクトをHashにする

一般的な方法は対象オブジェクトをHashにする方法です。

keyを対象オブジェクトのid、valueを対象オブジェクトとしたHashにします。

この方法は実装がシンプルなので使われるケースが多いかと思います。

ただし、ActiveRecordオブジェクト自体がキャッシュに乗ることになるので、大量データを扱う場合は多くのメモリを消費してしまいます。

# 対象オブジェクトをHashにする
users = User.all.map {|d| [d.id, d] }.to_h

# または以下でも同様の結果が得られます。
users = User.all.index_by(&:id)

# 取得する場合
user = users[user_id]

ActiveRecordオブジェクトから特定の項目のみHashにする

データベースから取得した結果をmapで必要な項目のみ抽出して、Hashにする方法です。

サンプルコードのようにキャッシュに乗るのはid、 first_name、last_name、ageのみに限定されるように見えます。

項目を絞っているので、オブジェクト自体を取得するよりもメモリの消費を抑えることができます。

ただし、この方法だとallで取得したデータを一度キャッシュに乗せる必要があるので、一時的にメモリ消費量が大きくなります。 メモリ使用量は時間とともに徐々に落ち着いてきますが、メモリリークの引き金になるかもしれません。

users = User.all.map do |user 
  [user.id, 
  {
    first_name: user.first_name,
    last_name: user.last_name,
    age: user.age
  }]
end.to_h

ActiveRecordオブジェクトをpluckしてからHashにする

pluckを使用することでSELECT句レベルで項目が絞られた状態からHashにすることができます。 直接all.mapするよりも取得するデータ量が減るのでメモリ消費が抑えられます。

pluckすることにより可読性は低下しますが、パフォーマンスを重視したい場合はこの方法を取ると良いでしょう。

users = User.all.pluck(:id, :first_name, :last_name, :age).map do |user 
  [user[0], 
  {
    first_name: user[1],
    last_name: user[2],
    age: user[3]
  }]
end.to_h

Rubyで文字列がURL形式かどうか正規表現でチェックする | Rails

rubyで文字列がURL形式であるかチェックするには URI::DEFAULT_PARSER.make_regexp.matchを使用すれば良いです。

引数に渡された文字列がURI形式であるかチェックできます。

URI形式であればMatchDataオブジェクト、違う場合はnilが返却されます。

> URI::DEFAULT_PARSER.make_regexp.match('https://example.com')
=> #<MatchData "https://example.com" 1:"https" 2:nil 3:nil 4:"example.com" 5:nil 6:nil 7:nil 8:nil 9:nil>

> URI::DEFAULT_PARSER.make_regexp.match('example.com')
=> nil

これを応用することで以下のようにURL形式であるか判定することが可能です。

def url?(url_string)
  URI::DEFAULT_PARSER.make_regexp.match(url_string).present?
end

URL以外の文字列が含まれていても、regexp.matchで最初に一致した結果がMatchDataとして返却されます。 to_sすることで一致したURLを取得できます。

> match = URI::DEFAULT_PARSER.make_regexp.match('サイトURLはhttps://example.comです https://example.jp')
=> #<MatchData "https://example.com" 1:"https" 2:nil 3:nil 4:"example.com" 5:nil 6:nil 7:nil 8:nil 9:nil>

> match.to_s
=> "https://example.com"

とても便利なメソッドなのでぜひ使用してみてください。

HerokuのDBを有料プラン(hobby)に切り替える方法

Herokuのposgresql DB無料版から有料版(hobby)に移行する手順をまとめます。

1. 有料版DB(hobby)を作成する

Herokuの管理画面からAdd-onsを追加するか、下記コマンドを実行します。

heroku addons:create heroku-postgresql:hobby-basic your-app-name

2. 現状の構成を確認

環境変数DATABASE_URLはHobby-devに当たっていることが確認できます。

$ heroku pg:info -a your-app-name
=== DATABASE_URL
Plan:                  Hobby-dev
Status:                Available
Connections:           0/20
PG Version:            13.4
Created:               2021-10-19 11:24 UTC
Data Size:             9.0 MB/1.00 GB (In compliance)
Tables:                11
Rows:                  30/10000 (In compliance)
Fork/Follow:           Unsupported
Rollback:              Unsupported
Continuous Protection: Off
Add-on:                postgresql-aerodynamic-111111

=== HEROKU_POSTGRESQL_IVORY_URL
Plan:                  Hobby-basic
Status:                Available
Connections:           0/20
PG Version:            13.4
Created:               2021-10-19 11:31 UTC
Data Size:             8.9 MB/10.00 GB (In compliance)
Tables:                11
Rows:                  30/10000000 (In compliance)
Fork/Follow:           Unsupported
Rollback:              Unsupported
Continuous Protection: Off
Add-on:                postgresql-symmetrical-222222

DBをコピーする

DATABASE_URLのデータをHEROKU_POSTGRESQL_IVORY_URLにコピーします。

heroku pg:copy DATABASE_URL HEROKU_POSTGRESQL_IVORY_URL -a your-app-name

DBを切り替える

HEROKU_POSTGRESQL_IVORY_URLに切り替えます。

heroku pg:promote HEROKU_POSTGRESQL_IVORY_URL -a your-app-name

切り替え後の状態を確認

HEROKU_POSTGRESQL_IVORY_URLとDATABASE_URLの環境変数には同じ値に変更され、旧DBはHEROKU_POSTGRESQL_PUCE_URLに置き換わっていることが確認できます。

heroku pg:info -a your-app-name

=== HEROKU_POSTGRESQL_IVORY_URL, DATABASE_URL
Plan:                  Hobby-basic
Status:                Available
Connections:           0/20
PG Version:            13.4
Created:               2021-10-19 11:31 UTC
Data Size:             8.9 MB/10.00 GB (In compliance)
Tables:                11
Rows:                  30/10000000 (In compliance)
Fork/Follow:           Unsupported
Rollback:              Unsupported
Continuous Protection: Off
Add-on:                postgresql-symmetrical-222222

=== HEROKU_POSTGRESQL_PUCE_URL
Plan:                  Hobby-dev
Status:                Available
Connections:           0/20
PG Version:            13.4
Created:               2021-10-19 11:24 UTC
Data Size:             9.0 MB/1.00 GB (In compliance)
Tables:                11
Rows:                  30/10000 (In compliance)
Fork/Follow:           Unsupported
Rollback:              Unsupported
Continuous Protection: Off
Add-on:                postgresql-aerodynamic-111111

旧DBを削除する

HEROKU_POSTGRESQL_PUCE_URLは不要なので削除しておきます。

heroku addons:destroy HEROKU_POSTGRESQL_PUCE_URL -a your-app-name

別のAWSアカウント間でS3バケットにあるファイルを転送する方法

異なるAWSアカウント間(クロスアカウント)で、S3バケットにファイルを転送することがありました。

accountA        accountB
 S3bucket   ⇐    file

アカウントAのS3バケットに対して、アカウントBからファイル転送する場合を例として設定手順を説明します。 同様のことでお悩みの方はぜひ参考にしてみてください。

アカウントAのS3バケットを操作するIAMポリシーを作成する

まずはアカウントAでIAMポリシーを作成します。

以下のようにS3バケットに対する操作権限を付与したポリシーを定義します。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:ListBucket",
            ],
            "Resource": "arn:aws:s3:::accountA-bucketname"
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:GetObject",
                "s3:PutObject",
                "s3:DeleteObject"
            ],
            "Resource": "arn:aws:s3:::accountA-bucketname/*"
        }
    ]
}

アカウントAでIAMロールを作成する

1.IAMロールを作成

アカウントAのIAMロール作成画面を開きます。信頼されたエンティティの種類を選択で「別のアカウント」を選択し、アカウントBのアカウントIDを入力します。

f:id:furu07yu:20211016174540j:plain

2.IAMポリシーの紐付け

先程作成したIAMポリシーを選択してロールの作成を完了させます。

f:id:furu07yu:20211016174916j:plain
ロールにポリシーを紐付ける

このようにすることでアカウントBからアカウントAのIAMポリシーを使ってアカウントAのS3バケットを操作することが可能となります。 (これを信頼ポリシーと呼びます)

3.信頼ポリシーの編集

これまでの設定によりアカウントBのルートユーザに対して信頼ポリシーが設定されたことになります。 ルートユーザではない対象(Principal)に信頼ポリシーを設定することもできます。

下記はアカウントBのIAMロールに対して信頼ポリシーを設定する場合の手順です。

f:id:furu07yu:20211016180813j:plain

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::222222222222:role/accountB-roleName"
            },
            "Action": "sts:AssumeRole"
        }
    ]
}

まとめ

いかがだったでしょうか。 IAMロールを使用することで、簡単にクロスアカウントのファイル転送が実現できます。

IAMロールだけでアクセス制限の管理ができるので保守性も高く非常にオススメの方法です。

プログラム言語は常にバージョンアップしておいたほうが良い理由

オープンソース化によりプログラミング言語やフレームワーク、ライブラリは日々メンテナンスされており、バージョンアップしています。

新しい機能が追加されることで実装の選択肢が増えますので、顧客の課題解決の幅も広がります。 しかし現実にはサービスをリリースしてから一度もバージョンアップしていないというプロダクトが多く存在するように思います。

バージョンアップをするメリットと注意点を解説いたします。バージョンアップ対応を見直すきっかけになれば嬉しいです。

アプリのソースコードを直すよりも効果的!?

バージョンアップをすることで以下のメリットが生まれます。

  1. 新しい機能が使えるようになり、顧客へのサービス提供の幅が広がる
  2. 既存バグが修正されて、アプリの性能が向上する
  3. メモリの効率化によりパフォーマンスが向上することがある

2と3に関しては、アプリのソースコードをほとんど修正せずとも、これらのメリットを享受することが出来ますので、バージョンアップを避ける理由はありません。

場合によってはアプリのパフォーマンスチューニングをするよりも、バージョンアップのほうがパフォーマンス改善に繋がることもあります。 賢いチームほどバージョンアップを軽視せずに積極的にバージョンアップしてメリットを享受しているのです。

情報感度が上がる

また、バージョンアップを心がけると情報の感度が高まるというメリットもあります。 バージョンアップには、新しいトレンドや技術を取り入れることが多いので、バージョンアップ対応を介して情報のキャッチアップができます。

各種言語、フレームワーク等のSNSアカウントをフォローしてアップデート情報をキャッチアップしましょう。

バージョンアップで注意すべきこと

メリットばかりあるように思えるバージョンアップ対応ですが、注意すべき点がいくつかあります。

安定版のみ使用すること

基本的には安定版のみを使用するようにしましょう。 安定版は致命的な不具合などが解消されていているので、安心して使用することが出来ます。

マイナーバージョンアップは1つずつ

マイナーバージョンアップは1ずつ行うようにしましょう。 基本的にはマイナーバージョン番号を飛ばさないようにしたほうが無難です。

番号を飛ばしてバージョンアップすると変更箇所のキャッチアップが難しくなります。もし問題があった場合に、どのバージョンによる原因なのかが把握できない可能性があります。

メジャーバージョンアップは慎重に

メジャーバージョンアップは大幅な仕様変更を伴ないことが多いので、安易な気持ちで行ってはいけません。 大抵はバージョンアップ手順のドキュメントが用意されていますので、それを参考に行いましょう。

また、「ワーニングが出ていないこと」「ユニットテストが全てパスすること」「画面での動作確認が正常であること」などの確認を行った後に反映するようにしましょう。

バージョンアップはコツコツと

日々の開発タスクの中にバージョンアップ対応を盛り込んでおくことで、変更時の影響が少なくなります。

バージョンアップは一気にやるよりもコツコツやることをオススメいたします。

最低限知っておきたい!Railsのトランザクション実装例

Railsでトランザクションを使いたい方向けに実例を踏まえてわかりやすく解説をいたします。

RailsにおけるTransaction

Railsでトランザクションを使用する場合は、トランザクションの範囲をActiveRecord::Base.transactionブロックで囲む必要があります。

transactionブロック内で例外が発生した場合、ブロック内で行われたデータベース更新処理が全てロールバックされます。 Railsでtransactionを機能されるためには例外を発生させなければならないのです。

ActiveRecord::Base.transaction do
  user1.save!
  user2.save!
end

例外が発生すればロールバックされる

例外が発生すればロールバックされますので、例外を意識して実装する必要があります。

以下のサンプルコードをご覧ください。

def some_method
  # 省略...
  ActiveRecord::Base.transaction do # BEGIN
    user1.save!  # 実行される(ロールバックで戻る)
    raise 'error'  # ロールバック
    user2.save!  # 実行されない
  end
  foo # 実行されない
rescue => e
  bar # 実行される
end

raise 'error'により、user1.save!がロールバックされます。 user2.save!はそもそも実行されません。また、例外が発生した時点でfooも実行されないことになります。 rescueで拾っているbarは実行されます。

サンプルコードではsave!を使用していますが、saveメソッドを使用しても同様の結果になります。

transactionブロック内でrescueするとロールバックされないので注意!

transactionブロック内でrescueしてしまうとロールバックされませんので注意が必要です。 例えば下記を実行した場合、ロールバックされません。

def some_method
  # 省略...
  ActiveRecord::Base.transaction do # BEGIN
    user1.save!
    raise 'error'
    user2.save!
  rescue => e
    bar
  end
end

transactionメソッド内では与えられたブロックに対して例外が発生した場合は、rescueしてロールバックするという処理が実装されているのですが、transactionブロック内でrescueしてしまうとそれが実行されなくなってしまうためです。

rails/transaction.rb at 291a3d2ef29a3842d1156ada7526f4ee60dd2b59 · rails/rails · GitHub

ロールバック後に処理を継続したい場合

ロールバック後に処理を継続したいケースがあるかと思います。 例えばロールバック後に処理が失敗したことを特定のテーブルに書き出したいといったケースです。

その場合は、ロールバックする処理自体をメソッド化して例外を握りつぶせばロールバック後も処理を継続することが出来ます。

def do_transaction
  ActiveRecord::Base.transaction do
    user1.save!
    raise 'error'
  rescue StandardError
    false
  end
end

def fuga
  # 省略...
  if do_transaction
    # 成功時の処理
    foo
  else
    # ロールバック後の処理
    bar
  end
end

ルーティングをネストする際のresourcesとresourceの使い分け | Rails

Railsで開発したことがある方は、ルーティングをネストしようとした場合に、resourcesとresourceのどちらを使用すべきか迷ってしまったことがあるのではないでしょうか?(私もその一人です)

この記事ではresourcesとresourceの使い分けについて、親リソースをブログ記事(topicsテーブル)と見立てて、わかりやすく解説をいたします。

子リソースのCRUDを定義したい場合はresources

親リソースに子リソースが属している場合は「resources」を使用します。 例えば、ブログ記事(親リソース)に対して、コメント(子リソース)を付くようなケースが該当します。

「親:子」は「1:N」なのでCRUDの全てのアクションが定義されます。

routes.rb
resources :topics do
    resources :comments, module: 'topics'
end
routing一覧
    topic_comments GET    /topics/:topic_id/comments(.:format)            topics/comments#index
                   POST   /topics/:topic_id/comments(.:format)            topics/comments#create
 new_topic_comment GET    /topics/:topic_id/comments/new(.:format)        topics/comments#new
edit_topic_comment GET    /topics/:topic_id/comments/:id/edit(.:format)   topics/comments#edit
     topic_comment GET    /topics/:topic_id/comments/:id(.:format)        topics/comments#show
                   PATCH  /topics/:topic_id/comments/:id(.:format)        topics/comments#update
                   PUT    /topics/:topic_id/comments/:id(.:format)        topics/comments#update
                   DELETE /topics/:topic_id/comments/:id(.:format)        topics/comments#destroy

親リソースにCRUD以外の振る舞いを定義したい場合はresource

親リソースに対してCRUD以外の振る舞いを定義したい場合は「resource」を使用します。 例えば、ブログ記事(親リソース)に対して、更新予約(子リソース)をするようなケースが該当します。

あくまでも親リソースに対しての振る舞いなので子リソース側にindexアクションは定義されません。

routes.rb
resources :topics do
  resource :comments, module: 'topics'
end
routing一覧
new_topic_comments  GET    /topics/:topic_id/comments/new(.:format)             topics/comments#new
edit_topic_comments GET    /topics/:topic_id/comments/edit(.:format)            topics/comments#edit
     topic_comments GET    /topics/:topic_id/comments(.:format)                 topics/comments#show
                    PATCH  /topics/:topic_id/comments(.:format)                 topics/comments#update
                    PUT    /topics/:topic_id/comments(.:format)                 topics/comments#update
                    DELETE /topics/:topic_id/comments(.:format)                 topics/comments#destroy
                    POST   /topics/:topic_id/comments(.:format)                 topics/comments#create

ルーティング定義はセンスが大事

明確なルールの元、正しくルーティング定義をしなければ保守性の高いシステムは作れません。

ルールを統一すると、ルーティングを見ただけでどのようなシステムなのか把握できる上、コントローラーのスコープが明確になります。 なんとなくルーティングを定義するのではなく、美しくルーティングを定義できるようにセンスを日々磨いていくことが重要であると私は考えます。