giftee engineer blog

曜日はデータの定義に必要か?

2020-12-22

設計の不備を見つけてそれを解決するための検討をした話

こんにちは。 エンジニアのkomaと申します。 普段はあるクライアントの eGift システムの保守運用開発を中心に行っています。

今回の記事は、 設計の失敗をどう解決するかについてです。 まずは失敗のリカバリをどうするか先人の知恵を頼りにしてみましょう。

Denzel Curry

この動画はアメリカのラッパー Denzel Curry と Kenny Beats の共作アルバムのビデオです。 UNLOCKED という新しいアルバムが発表する前にリークされてしまったので、 それを取り返すために旅に出る、 というのがこの動画の内容です (一番好きなシーンは二人がギアのサイズでケンカするところです) 。

二人はグアダラハラのギターセンターで買ったよくわからない機械でファイルを取り返す旅に出ましたが、 我々にはそんなものはないので、 設計の何が問題だったかを分析する力と、 特定した問題を解決するために必要なデータと操作を定義する力 (語呂が悪いですが) で進んでいきたいと思います。

問題

現在私がメンテナンスしている eGift アプリケーションには以下のような画面があります。 ある eGift が利用できる時間帯を、 各曜日ごとに設定するための画面です。

2020 11 11 names of days ui

現在の実装

現在データベースに保存しているのは、 以下のようなデータです。 UI で見える情報をほぼそのままテーブルとして定義しています。

monday_start     -- 月曜日利用可能開始時間
monday_end       -- 月曜日利用可能終了時間
monday_available -- 月曜日利用可フラグ

-- ... 以下日曜分まで六回繰り返す

アプリケーションコードは上のデータと現在時刻の曜日と時間を使って、 start <= now < end が成り立つかどうかをチェックします。

あまり重要でないですが利用可フラグについて補足します。 このフラグが true の場合は、 その日が全く使用不能になります。 設定した時間幅が利用不可能になるわけではない です (そういう実装だったら今回の記事はいらなかったかもしれません 🥺)。

クライアントが (ほんとうに?) やりたかったこと

大体やりたいことはできそうなデータに見えますが、 クライアントは 日跨ぎ利用可能時間 を設定したくなってしまいました。 日跨ぎ利用可能時間 は、 たとえば 1/10 18:00 から 1/11 03:00 まで利用可能。 というような設定です。 3時という変な時間が出てくるのは営業時間という概念のせいですが、 ここでは特に必要のないことなので詳しく触れません。 とにかく今のデータから見ると日 (テーブルの列) を跨ぐような時間の設定がしたい! というユーザーが存在することがわかりました。

一見するとこの要望はいまでも満たせそうに見えます。 例えば以下のように設定すると、 月曜日の夜から火曜日の朝というデータを定義できます。

  • 月曜日 16:00 - 23:59
  • 火曜日 00:00 - 08:00

ただこれで対応できるのはこの1日だけで、 次の火曜日の夜の時間を定義できません (一曜日一区間という制約あるため)。

悲しいことですが、 結局この要望は時間的な制約もあって叶えられませんでした。

解決すべきこと

この要望に応えられなかったのは、 現在のデータ構造に以下のような暗黙的な前提があったせいだと思います。

利用開始/終了時間は同一曜日を持つ

クライアントが、 この機能を実装したときはこの前提があってもよいとおもっていたのか、 それとも最初から日跨ぎは考えていて、 その要件を拾いそびれたのかはわかりません。 今のデータを修正する方針で考えると、 利用可/不可のどちらの時間を持っているかを示すフラグを追加すればできそうです (実際に利用可不可を判定するコードがごちゃごちゃしそうですが) 。

ただ筆者はそもそも曜日なんて必要なくて、 一般的な制限ができる方法のほうがよいと思ったので、 今回解決すべきは以下にしたいと思います。

  • 曜日をデータの定義、 値に含めない
  • 曜日の設定に翻訳できる

二点目は主に UI の都合上必要だと思っています。 設定や表示の際はカレンダーとか曜日でユーザーとコミュニケーションをとらないと使い勝手が悪くなるからです。 具体的には、 新しい定義を元にした値の部分集合が曜日/カレンダーモデルになります。

解決案 (cycle-duration モデル)

今回の解決策は一定の長さの時間 duration が何回 cycle していくか、 というデータを定義するというものです。 曜日もサイクルなのですが、 7日という制約があるので、 それをもっと一般的にした定義に変えるという方針です。

データの定義

以下のようなデータを定義します。

const cycle = {
    // このサイクルを始める起点
    origin: "2019-01-01T00:00:00",
    // サイクルが終わる時間で、 ここでは一年にしました
    overall_duration: "365 days",

    // ここ以降が繰り返し部分の定義になります
    // interval は繰り返す部分の長さを表します
    interval: "1 day",
    // duration の element は interval の中で利用可能時間がどこにあるかを表します
    durations: [
      // ここでは正午から12時間
      {duration_offset: "12 hours", duration: "12 hours"}
    ]
}

これだけみててもよくわからないので、 実際に日跨ぎをやろうとするとどういうデータが必要か見てみます。

例 平日のみの日跨ぎ

以下のような平日の夜のみ利用可能キャンペーンを1ヶ月やりたいとします。 8時間ごとに区切っているのは表が大きくならないようにで、 データとしては1時間でも1秒でも問題はありません。

hour Mon. Tue. Wed. Thu. Fri. Sat. Sun.
00-08 - -
08-16 - - - - - - -
16-24 - -

これを表現すると以下のようになります。

const cycle = {
    origin:           "2020-11-16T00:00:00",
    overall_duration: "2 weeks",

    interval: "7 days",
    durations: [
      // 月曜日の夜 (offset には origin から何時間たっているかを設定します)
      {duration_offset: "16 hours", duration: "16 hours"},
      // 火曜日の夜
      {duration_offset: "40 hours", duration: "16 hours"},
      // 水曜日の夜
      {duration_offset: "64 hours", duration: "16 hours"},
      // 木曜日の夜
      {duration_offset: "88 hours", duration: "16 hours"},
      // 金曜日の夜
      {duration_offset: "112 hours", duration: "16 hours"},
    ]
}

利用可能稼働かの判定方法

インプットに必要なのは現在時刻 (now) のみです。 ステップは大まかに以下のようになります。

  1. now が origin より小さければ ng
  2. now が origin+overall_duration 以上ならば ng
  3. now が durations のどこにも入ってなければ ng

最後のステップは少しややこしいので具体的な方法は最後の実装例を見てください。

この解決方法ではできないこと

そんなものは論理的ないですが、 実装を適当にやるとバグってしまうことはあり得ます。

最大のものは うるう-something への対応です。 2月が28日で終わるような前提をコードに埋め込んでしまった場合にバグになるのは明らかです。 そうなってくるとカレンダー (java であれば java.util.GregorianCalendar class) を使う必要があるので、 曜日がいらないという話になってなくないか? という疑問が生まれてきます。 その点は次のセクションで述べます。

曜日を否定する?

前述の通り実装のことを考えると、 閏年とかでずれないような考慮が必要になります。

カレンダーを取り入れるのが曜日のを取り入れることになるのかどうかはよくわかりません (グレゴリウス暦の定義に曜日というのがある?) が、 今回の解決方法は 曜日を否定する わけではなくて、 曜日を前提にしない 方針なので、 結果的に durations の部分に何曜日が… というデータが保持されるかもしれません。

なので DDLに曜日はゆるさない だけで、 列の値として曜日が入るかどうかは問わない という方針だと言えると思います。 今はデータベースの に曜日がありますが、 これを否定したい、 ということです。

となると根本的な問題は、 start、 end という定義を用いることだけかもしれません。 幅とか範囲の定義を考えるときに (start, end) というタプルを考えるのではなく、 (start, duration) というように開始点とその長さを持つほうがやりたいことを表している場合が多いのではないか? とか、 タプル (start, end) には意図せず変な前提が入ってるからやめたほうがいいんでないか? というのを今回の結論としたいと思います。

補足

実は時間をサイクルで考えるというのは元ネタがあって、 Eric Evans がサイクルのモデルもあるよねという話を以下の動画で喋っています。

実装例

筆者はただ clojure が書きたいだけなので、 clojure で実装した例を載せます。 おまけなので興味がなければ飛ばしてもらっても記事の理解には差し障りありません。 どこかバグってたら教えてもらえるとありがたいです。

; この実装では最小単位を秒で扱っています

; 1時間が何秒か
(def hour-second (* 60 60))

; 1日が何秒か
(def day-second (* 24 hour-second))

; `java.time.LocalDateTime` を使って、 unix timestamp を出すためのヘルパー
(defn epoch-time [java-time]
  (.toEpochSecond java-time
                  (java.time.ZoneOffset/UTC)))

(defn outside-cycle? 
  "現在時刻 now が指定された開始終了時間 (cycle-origin, cycle-origin+overall-duration) の中に入ってる場合は true, それ以外は false を返します"
  [now {:keys [cycle-origin overall-duration]}]
  (< (+ cycle-origin overall-duration) now))

(defn current-pos [now {:keys [cycle-origin cycle]}]
  "現在時刻 now の cycle 内での相対位置を返します"
  (mod (- now cycle-origin) cycle))

(defn inside-duration? [now {:keys [durations] :as cycle}]
  "現在時刻 now が durations に設定されている期間に入る場合は true, それ以外は false を返します"
  (let [pos (current-pos now cycle)]
    (some (fn [{:keys [duration-offset duration]}]
            (and (<= duration-offset pos) (< pos (+ duration-offset duration))))
          durations)))

(defn valid? [n cycle]
  "now が cycle の定義から使用可能であれば true, それ以外は false を返します"
  (and (not (outside-cycle? n cycle))
       (some? (inside-duration? n cycle))))

; test data
(def cycle-origin (java.time.LocalDateTime/of 2019 12 1 0 0))

(def cycle-unit {:cycle-origin  (epoch-time cycle-origin)
                 :overall-duration  (- (epoch-time (.plusWeeks cycle-origin 2)) (epoch-time cycle-origin))
                 :cycle         (* 7 day-second)
                 :durations [{:duration-offset (* 13 hour-second)
                              :duration        (* 8 hour-second)}
                             {:duration-offset (* (+ 24 13) hour-second)
                              :duration        (* 8 hour-second)}]})

(def oks [
          ; start limit
          (epoch-time (java.time.LocalDateTime/of 2019 12 1 13 0))
          ; end limit
          (epoch-time (java.time.LocalDateTime/of 2019 12 2 13 0))
          (epoch-time (java.time.LocalDateTime/of 2019 12 8 13 0))
          (epoch-time (java.time.LocalDateTime/of 2019 12 2 20 59))])

(def ngs [
          ; outside of range (a bit early)
          (epoch-time (java.time.LocalDateTime/of 2019 12 1 12 59))
          ; outside of range (a bit late)
          (epoch-time (java.time.LocalDateTime/of 2019 12 2 21 0))
          ; outside of range (too early)
          (epoch-time (java.time.LocalDateTime/of 2019 1 1 0 0))
          ; outside of range (too late)
          (epoch-time (java.time.LocalDateTime/of 2021 12 1 21 0))

          (epoch-time (java.time.LocalDateTime/of 2020 12 2 13 0))
          (epoch-time (java.time.LocalDateTime/of 2020 2 1 12 59))])

; test

; (map (fn [n] (valid? n cycle-unit)) oks)
; => (true true true true)

; (map (fn [n] (valid? n cycle-unit)) ngs)
; => (false false false false false false)