Common Lispと時間とタイムゾーン

Common Lispと時間とタイムゾーン

この記事はLispアドベントカレンダーの4日目の記事です。

あらまし

 Common Lispで日時を扱う場合、ANSI仕様には日時のための関数がいくつか定められていますが極めて基本的なものしか存在しません。日時から文字列への変換とその逆、日付の比較や計算といったものは自前で実装する必要があります。が、そんなのいちいちやっていられない。そんなあなたのためのライブラリが日時ライブラリlocal-timeです。

Common Lisp標準仕様における日時

 まずは、Common Lispが日時についてどのような機能を提供しているか確認しましょう。この節の内容はCommon Lisp Hyperspecの時刻の章を参考資料として、要約する形で書いています。

 Common Lispの仕様では、日時のデータとして以下のものを提供しています:

  • Decoded time
  • Universal time
  • Internal time
  • Second

 各個を簡単に説明します。

Decoded time

 これは九つの値のリストです。日時の内部表現としては通常UNIX timeのように、ある基準日時からの秒数という形がとられることが多いと思いますが、それを人間が扱いやすい形にしたものです。リストには次の内容が含まれます:「秒」「分」「時」「日」「月」「年」「曜日」「日中」「サマータイムの影響下か否か」「タイムゾーンオフセット」。get-decoded-time関数で現在時刻を取得したり、後述のuniversal timeから変換して得ることができます。

Universal time

 これは整数値で、GMTにおける1900年1月1日の0:00からの秒数です。なので精度は秒です。Decoded timeと違うのは、日時の計算(三時間後とか前日零時など)や比較(この日時は今日か、など)が行いやすいことです。ただし、秒と所望の単位との変換にそれなりの労力を必要とします。

Internal time

 これも整数値ですが、こちらは秒以下の精度がほしいときに使うものです。計算機内部のタイマーや、可能ならばHPETを利用して値を取得するようですので、精度は環境に依存します。秒にいくつの「単位」が詰まっているか、要するに精度ですが、internal-time-units-per-secondで確認できます。

Second

 これは整数値で、秒を表します。sleep関数の引数となるものです。


 以上四つがCommon LispANSI仕様で公式に定義されている日時表現です。プリミティブすぎるので、実際に実用する際には抽象化を施す必要がありそうです。そして、その抽象化をしてくれるライブラリがあるのです。それが次の節で述べる、local-timeというライブラリです。

日時ライブラリ: local-time

 ANSI仕様で提供された日時関数群では、たとえば以下のようなことをするのにひと手間ふた手間かけなければなりません:

  • 日時を所望のフォーマットの文字列に変換する
  • 文字列で表現された日時を日時データに変換する
  • ある日時の三日後、二年前、といった日時を計算する
  • タイムゾーンAsia/Tokyoといった文字列で指定し、計算や比較のときに考慮する

 そんな手間を一挙に引き受けてくれるのが、ライブラリlocal-timeです。

 とりあえず現在時刻を取得しましょう。こんなふうにすると、時刻がlocal-time:timestampクラスのインスタンスが返ってきます:

CL-USER> (local-time:now)
@2018-12-02T12:28:33.527163+09:00

 よくある処理の例として、ある時点においてセッションが有効かどうかの判定を行うとします。セッションは4時間で無効になるとしましょうか。それはこんなふうな処理になります。

;; 4時間以内に作成されたセッションは有効
CL-USER> (defun session-available-p (session-created)
           (local-time:timestamp<= (local-time:timestamp- (local-time:now)
                                                          4 :hour)
                                   session-created))

SESSION-AVAILABLE-P

;; 有効なセッション
CL-USER> (defparameter session-created (local-time:now))
SESSION-CREATED
CL-USER> (session-available-p session-created)
T

;; 5時間前につくられたセッション
CL-USER> (defparameter unavailable-session
           ;; decoded-timeの各値を指定してtimestampを生成
           (local-time:encode-timestamp 0 0 0 0 2 12 2018))
UNAVAILABLE-SESSION
CL-USER> (session-available-p unavailable-session)
NIL

 時差についてはどのように扱えばよいのでしょうか。まず、local-timeはシステムのタイムゾーン設定を読みとって、その内容をlocal-time:*default-timezone*にセットしています。たとえばぼくのマシン(Asia/Tokyo)で見てみるとこんな感じです。

CL-USER> local-time:*default-timezone*
#<LOCAL-TIME::TIMEZONE LMT JDT JST JST>

 たとえばこれを外部サーバのRDBMSUS/Easternなので揃えたいという場合、以下のようにしてタイムゾーン情報をロードしたあとに、タイムゾーン情報を名前で指定して設定する、ということをします。

;; タイムゾーン情報を読み込み
CL-USER> (local-time:reread-timezone-repository)
; No value
CL-USER> (local-time:find-timezone-by-location-name "US/Eastern")
#<LOCAL-TIME::TIMEZONE EDT EST EWT EPT>
T

;; タイムゾーンをUS/Easternに設定(と前後の確認)
CL-USER> (local-time:now)
@2018-12-03T23:56:26.228703+09:00
CL-USER> (setf local-time:*default-timezone* (local-time:find-timezone-by-location-name "US/Eastern"))
#<LOCAL-TIME::TIMEZONE EDT EST EWT EPT>
CL-USER> (local-time:now)
@2018-12-03T09:56:31.047229-05:00

 ちなみにもし揃えたいタイムゾーンUTCの場合、定数local-time:+utc-zone+にはじめから設定してあるので、こちらを利用するとよいでしょう。

 どうでしょう。地味ながら、それなしではつくれないソフトウェアがぼろぼろありそうな、とても感謝なライブラリということがおわかりいただけたと思います。

まとめ

 この記事ではANSI Common Lisp仕様における日時の扱い方を解説し、より実践的で便利なライブラリであるlocal-timeを紹介しました。日時の扱いは間違いを生みやすく、そして時々刻々と変化するため再現の難しいバグを生みやすいです。基本的には自分で実装したりせず、信頼できるライブラリを用いましょう。





……ん? 記事はもう終わりですよ?





Common Lispと時間とタイムゾーン 〜 設定のブレに気をつけろ!

 ある日、日時を扱うソフトウェアをつくっていてハマったことがあったんです。

 それは、おしごとでのある案件でねぇ、そのときはlocal-timeの不具合だったように見えたんですよ。わたしはねぇ、「Lispアドベントカレンダーのネタにしよう」と思ってわくわくしていたんです。わくわくして記事を書いて、ふと、再現実験させてみちゃったりする。すると、再現しないんですよ。恐ろしくてね、こう、冷や汗がタラ〜って、流れてしまいましてねぇ。

——これは、そんな哀しみの物語。

発生現象

 そのとき書いていたソフトウェアはあるウェブアプリケーションのバックエンドプログラムでした。そのシステムではアクセス可能時間を制御しており、DBに記録される最終操作日時が特定の時間を過ぎるまで操作が行えないという処理が必要でした。処理系にはSBCLを選択し、RDBMSとしてMySQLを利用しておりました。各RDBMSのクライアントライブラリはlocal-timeい依存しておらず、したがってDBから取得される日時は素のCommon Lispで扱えるuniversal timeでやってきます。

 そこで、Universal timeを受けとり、現在操作が可能かを判定する述語として以下のようなready-pを用意してAPIのほうで呼び出していました。

;; プログラムの最初でタイムゾーンをUTCにしている、こんなコードで:
;; (setf local-time:*default-timezone* local-time:+utc-zone+)
;; 理由は`local-time:today`はUTCでの0:00を返すため
(defun ready-p (created-at)
  (or (null created-at)
      (let ((created-at (universal-to-timestamp created-at)))
        (timestamp<= created-at
                     (timestamp+ (local-time:today) 2 :hour))))))

 このプログラムのフロントエンドを担当していたプログラマMac使い(ぼくはUbuntu使い)で、Common Lispで実行可能バイナリを作成可能とはいえ、Ubuntuでつくったバイナリをそのまま渡しても動きません。なので、Docker上にUbuntu環境をつくってその上でビルドし、そのDockerfileを渡すようにしました(そのへんの話は12/7(金)のLispアドベントカレンダー記事(TODO:リンクを貼ること)に書きます)。

 そして、渡したDockerfileをビルドした彼が云って曰く、
「なんか日時の処理がおかしい…🤔」

 具体的には、システム時計が2018-12-04T17:00:00Zのときに上記のコードにcreated-at引数に2018-12-04T2:00:01Z相当のuniversal timeを与えてready-pを呼ぶと、nilが返ってくるのです(同日の2:00:012:00:00より後なので、tとなるのが正しい)。

 なんでじゃ。なんでなのじゃ…。

調査開始

 時刻の不具合といえばやっぱりタイムゾーンがあやしいです。なのでいろいろ話したり試したり調べたりしてみたところ、次のようなことがわかりました:

 タイムゾーンについてほかの影響要素はないかと調べると、POSIXシステムにおいてはTZ環境変数を通じてタイムゾーンをユーザが変更できるようでした(参考: GNU libcのマニュアル)。

 立てた仮説はこうです。
TZ環境変数の値によって、local-time:universal-to-timestampの返す値が異なるのではないか?」

 そこで、手元マシン、タイムゾーンUTC仮想マシンを作成し、こんな感じで検証してみました。

$ TZ='Asia/Tokyo' ros run -s local-time -e '(setf local-time:*default-timezone* local-time:+utc-zone+) (print (local-time:universal-to-timestamp 3800000000)) (terpri)(quit)'

@2020-06-01T11:33:20.000000Z
$ TZ='UTC' ros run -s local-time -e '(setf local-time:*default-timezone* local-time:+utc-zone+) (print (local-time:universal-to-timestamp 3800000000)) (terpri)(quit)'

@2020-06-01T11:33:20.000000Z

 お、おぉ……? 同じ日時になっとるやんけ。あのおしごとでの解決方法はなんだったんだ…? その場では上記仮説に従い、

(defun universal-to-timestamp* (ut)
  (multiple-value-bind (sec min hour date month year)
      (decode-universal-time ut)
    (encode-timestamp 0 sec min hour date month year)))

というコードを追加して、UTCに直す、ということをやったのですが、それは間違っていたということなの…?

追加調査

 結論からいえば、間違っていました。正しい原因はこうです。
mysqldCommon Lispのプログラムとの間でタイムゾーンの設定が異なると、cl-mysqlがtimestamp (SQLの型)をuniversal timeに変換するときに時間がずれる」

 家で上記の確認をしても、一向に現象が再現できないため、新たな方向で調べはじめました。確認のために書いたコードはこれです。タイムゾーンUTCに設定されたVMを立て、その中で、データベースの作成後にまずcraete-tableinsert-rowを流し、その後tztestテーブルの中身を、TZ環境変数の値を変えつつ見てみます。

#!/bin/sh
#|-*- mode:lisp -*-|#
#|
exec ros -Q -- $0 "$@"
|#
(progn ;;init forms
  (ros:ensure-asdf)
  #+quicklisp(ql:quickload '(:uiop :cl-dbi :dbd-mysql :sxql :local-time) :silent t)
  )

(defpackage :ros.script.tztest.3752729426
  (:use :cl))
(in-package :ros.script.tztest.3752729426)

(defmacro with-db ((var) &body body)
  `(cl-dbi:with-connection (,var :mysql :database-name "tztest"
                                 :host "localhost"
                                 :username "root"
                                 :password "root")
     (cl-mysql-system::set-character-set "utf8mb4")
     ,@body))

(defun execute-sql (conn sxql)
  (multiple-value-bind (query params)
      (sxql:yield sxql)
    (apply #'dbi:execute (dbi:prepare conn query) params)))

(defun create-table ()
  (with-db (conn)
    (let ((query (sxql:create-table :tztest
                     ((id :type :bigint :not-null t
                          :auto-increment t :primary-key t)
                      (ts :type :timestamp
                          :default (local-time:parse-timestring "2000-01-01"))))))
      (execute-sql conn query))))

(defun insert-row (ts-str)
  (with-db (conn)
    (let ((query (sxql:insert-into :tztest
                   (sxql:set= :ts ts-str))))
      (execute-sql conn query))))

(defun select-rows ()
  (with-db (conn)
    (let ((query (sxql:select (:id :ts)
                   (sxql:from :tztest))))
      (dbi:fetch-all (execute-sql conn query)))))

(defun print-db-timestamp (row)
  (format t "~a: ~a~%" (getf row :|id|)
          (local-time:universal-to-timestamp (getf row :|ts|))))

(defun main (&rest argv)
  (declare (ignorable argv))
  ;; (create-table)
  ;; (insert-row "2018-12-02 04:00:00")
  (let ((rows (select-rows)))
    (format t "***** TZ: ~a, *dt*: ~a~%"
            (uiop:getenv "TZ") local-time:*default-timezone*)
    (mapc #'print-db-timestamp rows)
    (terpri)
    (setf local-time:*default-timezone* local-time:+utc-zone+)
    (format t "***** TZ: ~a, *dt*: ~a~%"
            (uiop:getenv "TZ") local-time:*default-timezone*)
    (mapc #'print-db-timestamp rows)
    (terpri)))
;;; vim: set ft=lisp lisp:

 mysqldTZUTCの状態で固定し、roswellスクリプトのほうはTZの値を変えて実行した結果がこちらです。

$ TZ=Asia/Tokyo sudo ./tztest.ros
***** TZ: Asia/Tokyo, *dt*: #<TIMEZONE LMT BST GMT BDST BST BST GMT GMT>
1: 2018-12-01T19:00:00.000000Z

***** TZ: Asia/Tokyo, *dt*: #<TIMEZONE UTC>
1: 2018-12-01T19:00:00.000000Z

$ TZ=UTC sudo ./tztest.ros
***** TZ: UTC, *dt*: #<TIMEZONE LMT BST GMT BDST BST BST GMT GMT>
1: 2018-12-02T04:00:00.000000Z

***** TZ: UTC, *dt*: #<TIMEZONE UTC>
1: 2018-12-02T04:00:00.000000Z

 見事、cl-mysqlの返す日時にちょうど9時間、UTCJSTの間の時間分の差が生まれています。

オチ

 複数のプログラムが協調して動くシステムを構築するとき、とくに日時や時刻を扱う場合には、走らせるソフトウェアの間でタイムゾーンの設定が同じになるように注意しましょう。