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 LispのANSI仕様で公式に定義されている日時表現です。プリミティブすぎるので、実際に実用する際には抽象化を施す必要がありそうです。そして、その抽象化をしてくれるライブラリがあるのです。それが次の節で述べる、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>
たとえばこれを外部サーバのRDBMSがUS/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:01
は2:00:00
より後なので、t
となるのが正しい)。
なんでじゃ。なんでなのじゃ…。
調査開始
時刻の不具合といえばやっぱりタイムゾーンがあやしいです。なのでいろいろ話したり試したり調べたりしてみたところ、次のようなことがわかりました:
- SQL仕様では
timestamp
型には、タイムゾーンありとタイムゾーンなしものがある(参考: PostgreSQL 9.4.5のマニュアル) - MySQLはタイムゾーンありの
timestamp
は存在しない!(参考: MySQL 5.6のマニュアル) - ぼくの環境では直でMySQL Serverが動いており、マシンのタイムゾーンは
Asia/Tokyo
に設定されている - 彼の環境ではDockerコンテナ内でMySQL Serverが動いており、OSのタイムゾーンは未設定(おそらくUTC)
タイムゾーンについてほかの影響要素はないかと調べると、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に直す、ということをやったのですが、それは間違っていたということなの…?
追加調査
結論からいえば、間違っていました。正しい原因はこうです。
「mysqld
とCommon Lispのプログラムとの間でタイムゾーンの設定が異なると、cl-mysqlがtimestamp (SQLの型)をuniversal timeに変換するときに時間がずれる」
家で上記の確認をしても、一向に現象が再現できないため、新たな方向で調べはじめました。確認のために書いたコードはこれです。タイムゾーンがUTCに設定されたVMを立て、その中で、データベースの作成後にまずcraete-table
とinsert-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:
mysqld
はTZ
がUTCの状態で固定し、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時間、UTCとJSTの間の時間分の差が生まれています。
オチ
複数のプログラムが協調して動くシステムを構築するとき、とくに日時や時刻を扱う場合には、走らせるソフトウェアの間でタイムゾーンの設定が同じになるように注意しましょう。