自己紹介
エンジニアの荒木です。趣味は、飼っているうさぎの日々のお世話です。うさぎが元気に牧草を食んでいる音を聞くととても癒やされます。
普段の業務は Ruby on Rails を使ってのWeb開発をメインです。
最近興味がある技術はflutterです。
が、今回はRubyのgem作りの話をします。
いいたいこと
gem作りは、短期間で成果が出るのでおすすめ。
今回作ったgem
gemを作成し、うまれてはじめてRubyGemsに公開しました🎉
ソースはGitHubで公開しています
なんのgem?
月単位の範囲で加算と除算するgemです。
範囲で加算?除算?とおもわれるかもしれませんね。
イメージとしては、 Arrayの加算 もしくは Arrayの除算 を範囲で行うという感じです。
月単位の範囲で加算と除算はこのようになります。
- 2020年1月〜4月 + 2020年4月〜6月 = 2020年1月〜6月
- 2020年1月〜4月 - 2020年4月〜6月 = 2020年1月〜3月
実際に MonthRange
を使った場合で説明します。
- 月単位の範囲で加算する場合
2020年1月〜2020年4月
と 2020年3月〜2020年6月
の加算をすると、 2020年1月〜2020年6月
の範囲を得ることができます。
require 'date' require 'month_range' MonthRange::Service.add([Date.parse('2020-01-01'), Date.parse('2020-04-01')], [[Date.parse('2020-01-01'), Date.parse('2020-06-01')]]) => [[#<Date: 2020-01-01 ((2458850j,0s,0n),+0s,2299161j)>, #<Date: 2020-06-01 ((2459002j,0s,0n),+0s,2299161j)>]]
- 除算
2020年1月〜2020年4月
を 2020年3月〜2020年6月
から除算すると 2020年5月〜2020年6月
の範囲を得ることができます。
require 'date' require 'month_range' > MonthRange::Service.subtraction([Date.parse('2020-01-01'), Date.parse('2020-04-01')], [[Date.parse('2020-01-01'), Date.parse('2020-06-01')]]) => [[#<Date: 2020-05-01 ((2458971j,0s,0n),+0s,2299161j)>, #<Date: 2020-06-01 ((2459002j,0s,0n),+0s,2299161j)>]]
- 複数の範囲
上記は複数の範囲に対応します。
加算の場合 2020年1月〜2020年4月
と 2020年3月〜2020年6月
および 2019年12月〜2020年2月
の加算をすると、 2019年12月〜2020年6月
の範囲を得ることができます。
require 'date' require 'month_range' > MonthRange::Service.add([Date.parse('2020-01-01'), Date.parse('2020-04-01')], [[Date.parse('2020-03-01'), Date.parse('2020-06-01')], [Date.parse('2019-12-01'), Date.parse('2020-02-01')]]) => [[#<Date: 2019-12-01 ((2458819j,0s,0n),+0s,2299161j)>, #<Date: 2020-06-01 ((2459002j,0s,0n),+0s,2299161j)>]]
これらは除算でも可能ですが、事例は割愛します
- 終端が無限の場合
終端が無限の場合にも対応しています。無限は nil
を入力値とします。
例えば 2020年1月〜2020年4月
と 2020年3月〜無限
の加算をすると、 2020年1月〜無限
の範囲を得ることができます。
> MonthRange::Service.add([Date.parse('2020-01-01'), Date.parse('2020-04-01')], [[Date.parse('2020-01-01'), nil]]) => [[#<Date: 2020-01-01 ((2458850j,0s,0n),+0s,2299161j)>, nil]]
きっかけ
きっかけは、とある機能開発です。
たとえばこういう業務要件があり、月単位で予約状況を表示したいという要望がありました。
事例:部屋の予約システム 特定の部屋の月単位での予約状況を表示する機能。 月単位で予約とキャンセルが可能。
予約する が 加算、キャンセルする が 除算に該当します。
要望1
- 事前条件
- A部屋は
1月〜4月
、6月〜7月
、9月〜(無期限)
で予約されている
- A部屋は
- 入力
4月〜6月
で予約を追加する
- 期待する出力
- A部屋の予約状況は
1月〜7月
、9月〜(無期限)
と表示したい
- A部屋の予約状況は
要望2
- 事前条件
- A部屋の予約状況は
1月〜7月
、9月〜 (無期限)
- A部屋の予約状況は
- 入力
10月〜11月
で予約をキャンセルしたい
- 期待する出力
- A部屋の予約状況は
1月〜7月
、9月〜9月
、12月〜(無期限)
と表示したい
- A部屋の予約状況は
これを実現するための方策は大きく分けて3つ考えられました。
- 月単位の範囲で加算・除算できる汎用的なgemを探す
- 月単位の範囲で加算・除算できる汎用的なライブラリを自作する
- 月単位の範囲で事前条件の場合分けを丁寧にして処理する
まず、1について探しました。しかし、合致するgemはみつかりません。
2について検討しました。しかし、開発期間長く見込まれるためリスクがあります。
最終的には開発期間と機能のバランスが良い、3の方策ですすめることにして、結果、機能は無事リリースされました。
仕事としては、十分なのですが、なんだかもやっとする。理由は、今後も似たような要望が出てきそうだから。その時に、また同じように時間を使うのは、なんだかもったいない。
これを機会に、一般化してライブラリ化しておけば、次回は楽ができるはず。
と、いうことで、gem作りに着手しました。
まずやってみたこと
まずはgem作りの練習からはじめました。そもそも、gemをどうやって作るのかをキチンとしらなかったので。
gemを作った事例のブログをみつつ、過去に1度作った事はあります。しかし、写経のようなもので、個々の記述の意味も理解しないままでした。
今回は公開することを念頭においています。そのため、ドキュメントをきちんとたどりながら、今回の要件とは別の単機能のgemを作る練習をしました。
この過程で、bundlerのドキュメントを読みながらやればできるという確信がえられたので、本格的に month_range gemの開発を開始しました。
gemの公開で、bundlerを使うことは必須ではないですが、強くおすすめします。
理由は、bundlerのドキュメントも非常にわかりやすいから。
公開を念頭にしている場合は、ぜひ。
開発のスケジュール
なんとなく1ヶ月あればできそうだな、という気持ちだったので、特に明確な終わりは決めずにはじめました。
プレッシャーかけすぎると、楽しくないので。
結果、週末を4回ぐらい使い、実際1ヶ月でつくることができました。当初の読みはあたりました。(普段の業務もこれぐらい見積もりが当たれば嬉しいのですが)
- 1週目: 必要機能を洗い出して、一通り動くものを作る
- 2週目: refactoringをする。あまりに、コードが読みづらいので0から作り直す。
- 3週目: rspecでテストを書きまくる。refactoringをする。が、refactoringといいつつ、ほぼ、コード全体を書きかえることになる。
- 4週目: RubyGemsにリリースをする。しかし、リリース操作がちょっと煩雑に感じたので、CI/CDに手を出す。GitHub Actionsを導入する。→完成
こんな短期間で、全面書き直しを2回もできました。これ、一人開発ならでは、ですよね。チーム開発で、これはできない。
おかげで、設計もコーディングもとても楽しく、自分の好きなように技術を追求できました。
こだわったところ
責務をクラスに分割して、読みやすくする事を一番心がけました。
たとえば、「月単位の範囲に特定の月が含まれるか」を判定するという事例を通して、どのように責務を分割する設計を行ったかを説明します。
まずは、一番最初に考えた愚直な例です。
月単位の範囲を配列であらわしました。
[Date.parse('2020-01-01'), Date.parse('2020-04-01')]
ここで、月単位とは月初の(すなわち1日)と定義します。
そして、上記月範囲に ある月Date.parse('2020-03-01')
が含まれるかどうかを判定するために、下記のようにかきました。
range = [Date.parse('2020-01-01'), Date.parse('2020-04-01')] month = Date.parse('2020-03-01') def cover?(range, date) range.first <= date && date <= range.last end cover?(range, date) # trueならばrangeにdateが含まれる。
ぱっとみ、違和感のあるコードではありません。気になるとしたら、独自に cover?
メソッドを定義したので、テスト追加しなくちゃなーということぐらいでしょうか。
しかし、要件が加わった場合、例えば month
がユーザーからの入力である場合を考慮しなければならいとするとどうでしょうか?
ユーザーは必ずしも 1日始まり でデータを入力してくれないかもしれません。これは考慮する必要があります。
# 上記は省略 def cover?(range, date) raise if date.month_day != 1 range.first <= date && date <= range.last end
1日始まりを比較するif文を1つ足すことで対処することができました。これぐらいなら、可読性はどうってことないですかね。
では、さらに、月範囲において、 範囲に終わりがない という概念を導入しましょう。終わりがないということはデータ上 nil で表すとします。
そうなるとコードは複雑さを増します。
# 上記は省略 def cover?(range, date) raise if date.month_day != 1 if range.last.nil? return true else range.first <= date && date <= range.last end end
なんかややこしくなってきました。もちろん、まだ読むことはできますが、method内にif文が既に2つ出てきています。
2回あることは3回ある。
今後の拡張でif文が更に加えられ、つらくなりそうな臭いがぷんぷんします。
つらくなる理由は、 1日始まり や 範囲に終わりがない場合の期間比較 などの責務が、 cover?
1つにつめこまれているからです。
そこで、設計を全面的に見直し、最終的には、下記のクラスを導入し、責務の分離を行いました。
- 月単位として Dateを継承した
Month
クラス - 範囲としてRangeを継承した
MRange
クラス
責務は下記のように分離しました。
- 1日始まり かどうかを規定するのはMonthクラスの責務とする
- 範囲に終わりがない場合の期間比較 は、 範囲に終わりがない を
MonthRange::Month::Infinity
で表現し、 期間比較 をRangeを継承したMRangeの責務とする。 - cover?は Rangeの標準メソッドである
cover?
を使うようにして、独自設計はできるだけしない。(テストの工数を削減する)
cover? については、下記を参照ください。 docs.ruby-lang.org
再設計により、該当箇所のコードは下記のようになりました。
MonthRange::Month
class MonthRange::Month < Date class InvalidMonthFormat < MonthRange::Error end def self.create(date) return MonthRange::Month::Infinity.new if date.nil? raise InvalidMonthFormat unless date.mday == 1 # ここで 「1日はじまり」チェックする new(date.year, date.month, date.mday) end # 中略 class Infinity < Numeric # 範囲に終わりがない値を表現する # 中略 def <=>(other) # month < MonthRange::Month::Infinity.new を定義することで、range.cover?をそのまま使うことが可能。 case other when Infinity return 1 when MonthRange::Month return 1 end nil end
- MonthRange::MRange
class MonthRange::MRange < Range # Rangeを継承することで、cover? など便利なメソッドが使える
これらにより、責務を分離した結果、ある月範囲に Date.parse('2020-03-01')
が含まれるかどうかを判定するコードは以下の通りになりました。
month_start = MonthRange::Month.create('2020-01-0-1') month_end = MonthRange::Month.create('2020-04-0-1') month = MonthRange::Month.create('2020-03-0-1') m_range = MonthRange::MRange.new(month_start, month_end) m_range.cover?(month) # MRangeはRangeを継承しているので、Range標準のメソッドのcover? で範囲に含まれるかどうかを判定できる
どうでしょうか?
コード記述量は増えましたが、コードの意図がわかりやすくなったことがわかると思います。
読みやすくなっただけでなく、責務がMonthとMRangeクラスに分離されているので、今後の拡張はそれぞれのクラスに実装すれば良いので、保守も安心です!
得られたこと
個人でのgem作りは、思ったよりも得られるものが大きかったです。
一文で表現すると、「不得手なところを補い、得意なところを伸ばせる」ということです。
不得手なところを補える
個人でgemを作ると、ダメなところは全て自分の実力であり、不得手でもリリースするためには独力でなんとする必要があるからです。
たとえば、今まで直面しなかった課題、開発フローが存在している上でコードを書いているときには直面しなかった課題、を解決しないとリリースすることができません。
私の場合、個人開発で直面した自分のダメなところは、CI/CDでした。具体的にはgemのリリースのフローです。
品質を保証しつつgemを開発し続けるするためにはコードのlint, testを走らせることが重要です。今回のgemでは、具体的には rubocop
rspec
を常に実行する事です。
たかが、コマンド2つなのですが、gemを開発していると、これでもすごい面倒です。
なんとかこれを自動化するため、CI/CDを実装する方法を学びました。結果、GitHub actionsの使い方を知り、実装できるようになりました。
https://github.com/junara/month_range/tree/development/.github/workflows
GitHub actionsで定義しておくと、各actionが成功した場合こんな感じでバッジを表示できます。なんかうれしい。
得意なところを伸ばせる
また、自分ではできていると思っていが、まだまだだなというところも認識することができました。強制的に自分の実力に向き合うことができました。
普段、チームでコードを書いていると、他人のせいにして妥協してしまうことがあります。
たとえば、すでに書かれているコードがyyだから、こんなコードでもしかたない・・・という風にです。
しかし、個人でコードを書くとダメなコードは全て自分の実力の結果です。
公開するからにはいい物をみせたい。エンジニアの欲です。
開発スケジュールのところで書きましたが、今回のgemの開発では、2度ほど全面的に書き直しました。
普段の業務では2度も書き直すなんてことは、なかなかできません。今回、一つの要件に対して設計を複数おこなうと、コードが如実によくなることが体感できました。
短期間で設計力・コーディングの力を向上を実感できたのはとても良い経験でした。
所感
単機能のgem作りは、短期間(1ヶ月)で成果が出ます。
また、gemのお題を業務起因にしたことも、よかったです。モチベーションを高く維持するのに貢献してくれました。
gemを作る事は、技術を向上させるのに良いよ!というのはよく言われますが、ただ、そうはいっても脱落しがちですよね。
しかし、「今回作成するgemが完成すれば、いつか仕事で楽できるぞ!」と思えばどうでしょうか?
モチベーションが向上しますよね!
人間もうま🐴もうさぎ🐰も人参が重要です。
さいごに
今回作ったgemで自分の業務が楽になり、
そして、
この記事で、gem作りをはじめる仲間が一人でも増えると、もっと、かなり、嬉しいです。
一緒にgem作りを楽しみましょうー🎉