最初に
本記事でやりたかったことは active_hash を使ってもできるので、その前提でお読みいただけると。
記事を書いたときは active_hash
を知らず、コメントで教えていただきました。
背景
Ruby on Railsで開発をしていて、
更新性の低いデータ、DB管理する必要ないであろうデータの扱い方に迷ったので、
対応方法を残します。
対象データ
Webサービスのプラン情報についてです。
基本的にリリース後に追加はあっても変更する可能性は低いです。
追加に関してもあっても2年に一度とかとかになるかな..と考えています。
ですので、管理画面作って変更するまでもないし、
DBに入れるまでもないデータだなと思ってどのように実装するかを迷っていました。
対処方法
Modelとしては実装しつつも、
DBには保存せず静的なデータで定義をしました。
実際のコード
以下のModelクラスを実装です。
class Plan
attr_reader :name, :display_name, :amount, :member_count_limit, :document_export_count_limit,
:document_count_limit, :document_version_count_limit, :document_version_amount_limit,
:document_variable_count_limit, :enable_api
def self.all
[FREE, TEAM, BUSINESS]
end
def self.find_by_name(name)
all.find { |plan| plan.name == name }
end
def self.find_by_name!(name)
find_by_name(name) || raise(ArgumentError, "Plan with name '#{name}' not found")
end
private
def initialize(attributes = {})
attributes.each do |attr_name, attr_value|
instance_variable_set("@#{attr_name}", attr_value)
end
validate_attributes
end
def validate_attributes
instance_variables.each do |attribute|
attribute_name = attribute.to_s[1..].to_sym # @name => name
validate_presence_of(attribute_name, send(attribute_name))
end
# FIXME: nameのユニークチェックを行う
# uniquenessのバリデーションは、allに依存するため、initialize後に行う必要がある
# 良い実装が思いつかなかったので、一旦Unitテストで保証するのみとする
end
def validate_presence_of(attribute, value)
# valueがbooleanの場合は nil? で判定するとfalseがnilと判定されてしまうため、nil? で判定しない
return unless value.respond_to?(:empty?) ? value.empty? : value.nil?
raise ArgumentError, "#{attribute} is required"
end
FREE = Plan.new(
name: 'free',
display_name: 'フリー',
amount: 0,
member_count_limit: 2,
document_export_count_limit: 20,
document_count_limit: 10,
document_version_count_limit: 3,
document_version_amount_limit: 10.megabytes,
document_variable_count_limit: 20,
enable_api: false
)
TEAM = Plan.new(
name: 'team',
display_name: 'チーム',
amount: 500,
member_count_limit: 10,
document_export_count_limit: 200,
document_count_limit: 50,
document_version_count_limit: 10,
document_version_amount_limit: 20.megabytes,
document_variable_count_limit: 30,
enable_api: true
)
BUSINESS = Plan.new(
name: 'business',
display_name: 'ビジネス',
amount: 3000,
member_count_limit: 50,
document_export_count_limit: 1_000,
document_count_limit: 100,
document_version_count_limit: 20,
document_version_amount_limit: 50.megabytes,
document_variable_count_limit: 100,
enable_api: true
)
private_class_method :new
end
テストはこんな感じ。
RSpec.describe Plan, type: :model do
describe 'コンストラクタ' do
it '外部からのインスタンス生成ができないこと' do
expect { Plan.new }.to raise_error(NoMethodError)
end
end
describe '属性' do
it 'nameがユニークであること' do
expect(Plan.all.map(&:name)).to eq Plan.all.map(&:name).uniq
end
it '変更できないこと' do
expect { Plan::FREE.name = 'hoge' }.to raise_error(NoMethodError)
end
end
describe '.all' do
it '全てのプランを返すこと' do
expect(Plan.all).to match_array [Plan::FREE, Plan::TEAM, Plan::BUSINESS]
end
end
describe '.find_by_name' do
context '存在するプラン名を指定した場合' do
it '指定したプラン名に対応するプランオブジェクトを返すこと' do
expect(Plan.find_by_name('free')).to eq Plan::FREE
expect(Plan.find_by_name('team')).to eq Plan::TEAM
expect(Plan.find_by_name('business')).to eq Plan::BUSINESS
end
end
context '存在しないプラン名を指定した場合' do
it 'nilを返すこと' do
expect(Plan.find_by_name('nonexistent')).to be_nil
end
end
end
describe '.find_by_name!' do
context '存在するプラン名を指定した場合' do
it '指定したプラン名に対応するプランオブジェクトを返すこと' do
expect(Plan.find_by_name!('free')).to eq Plan::FREE
expect(Plan.find_by_name!('team')).to eq Plan::TEAM
expect(Plan.find_by_name!('business')).to eq Plan::BUSINESS
end
end
context '存在しないプラン名を指定した場合' do
it 'ArgumentErrorを発生させること' do
expect do
Plan.find_by_name!('nonexistent')
end.to raise_error ArgumentError, "Plan with name 'nonexistent' not found"
end
end
end
end
意識したポイント
他のクラスからの変更への対応
newをプライベートにして、属性もRead Onlyにしているため、
外部からプラン情報を差し替えたり変更することができない作りにしました。
ActiveRecordライクなインターフェース
RailsのModelといえば、そう、ActiveRecord。
なので、インターフェースを合わせることを少し意識しました。
ただ、同じ使い方ができるわけではないので注意が必要です。
迷ったポイント
設定を定義する場所
今回はモデル内で FREE
, TEAM
, BUSINESS
の設定情報を持ちました。
これを外部のファイルに出すかを迷いましたが、
少ない手数での実装 & 見通しの良さを優先しました。
属性のユニークチェック
コード内にFIXMEを埋め込んでいます。
複数の方法でユニークチェックは動作しましたが、
テストでカバーする方針に落ち着きました。
実装してみての感想
やはり、一般的なModelとは違う作りになってしまったので、Railsの恩恵をあまり受けられていません。
運用まで考えると、今回の設計(DB保存しない)がベターだと思います。
ただ、Railsを使うのであればDBにデータ保存して Easy に実装するのもアリだなと思いました。