Error downloading org.scalameta:semanticdb-scalac_2.13.7:4.4.28 や scalafix-test-kit_2.12.13:0.9.29 と言われたら

この記事について

  • scalafixに依存しているプロジェクトでscalaのバージョンを上げるとタイトルのエラーが出る場合があるので原因などを整理したもの
  • ちょっと前のメモ書きをブログ化してるので各種バージョンは古いです

各エラーについて

エラー1: Error downloading org.scalameta:semanticdb-scalac_2.13.7:4.4.28 と言われたら

原因

scalafixが依存しているsemantic DBのライブラリが存在していないことが原因

semanticdbのバージョンは Installation · Scalafix に従っていれば以下のようになっているはず。

semanticdbEnabled := true,
semanticdbVersion := scalafixSemanticdb.revision

semanticdbVersion は実際には下記の様に指定されている。

https://github.com/scalacenter/sbt-scalafix/blob/b7b890d00598a574b9d4ea88e4f42168cc83765c/src/main/scala/scalafix/sbt/ScalafixPlugin.scala#L91-L94

    val scalafixSemanticdb: ModuleID =
      scalafixSemanticdb(BuildInfo.scalametaVersion)

    def scalafixSemanticdb(scalametaVersion: String): ModuleID =
      "org.scalameta" % "semanticdb-scalac" % scalametaVersion cross CrossVersion.full

この例では実際には org.scalameta:semanticdb-scalac_2.13.7:4.4.28 と解決される(細かいバージョンはscalaとsbt-scalafixのバージョンで変化する)

今回の細かい値は以下のようになっていた。

  • scalaVersion=2.13.7
  • sbt-scalafix version=0.9.31

このライブラリがmaven上にpublishされていないことが依存が解決できないのが表題のエラーの原因。

publishされているかの確認は https://repo1.maven.org/maven2/org/scalameta/semanticdb-scalac_2.13.7/ に存在しているかで行える。

解決方法

単純にsemanticdbが存在しないことが原因なので、すでにpublish済みのsemanticdbバージョンを使えば良い。

sbt-scalafixでは scalafixSemanticdb(scalametaVersion: String) で指定できるので、以下のように好きなように指定すれば良い。

import scalafix.sbt.ScalafixPlugin.autoImport.scalafixSemanticdb

...
semanticdbVersion := scalafixSemanticdb("4.4.30").revision,

指定するバージョンは Central Repository: org/scalameta から semanticdb-scalac_${使いたいscalaバージョン} 配下にある最新のversionのものにすればよい。

例: Central Repository: org/scalameta/semanticdb-scalac_2.13.7/4.4.30 なら 4.4.30 を指定する。

余談

余談1: ワンポイントsbt

scalafixのコードのなかで CrossVersion.full という指定が出てきていたので意味を整理しておく。

ライブラリのバージョン指定時に CrossVersion.full が指定されていると "org.scalameta" % "semanticdb-scalac" % バージョン番号"org.scalameta" % "semanticdb-scalac_<scalaバージョン>:バージョン番号 として解決され、最終的に org.scalameta:semanticdb-scalac_2.13.7:4.4.28 という指定になる。

普段依存ライブラリを指定する場合は "org.scalameta" % "semanticdb-scalac" %% バージョン番号のように %% を使った指定を行うことが多いが、この場合は "semanticdb-scalac_<scalaバイナリバージョン>:バージョン番号 のように解決され、最終的に org.scalameta:semanticdb-scalac_2.13:4.4.28 のようにバイナリ互換性のあるバージョンまで(2.13.7ではなく2.13まで)を指定するようになっている。これは CrossVersion.binary を指定するのと等しい挙動になる。

CrossVersionには他にCrossVersion.disableも存在するが、これは単にscalaバージョン番号を埋め込んでいないもの(javaのライブラリなど)の指定に用いる。

※ 通常はjava製ライブラリは%、scala製ライブラリは%%を使うと覚えておくと良い。

参考

余談2: scalametaのバージョンはどう指定されているか?

sbt-scalafix version=0.9.31 の場合、scalametaのバージョンは4.4.28と解決される。

これは

をみると scalafix-interfaces.properties というリソースの scalametaVersion に記されていることが分かる。

このファイルの場所はsbt-scalafixに依存している適当なプロジェクトで以下のようにすれば特定できる。

$ sbt
> reload plugins
...
> console
scala> getClass.getClassLoader.getResource("scalafix-interfaces.properties")
res0: java.net.URL = jar:file:/略/https/repo1.maven.org/maven2/ch/epfl/scala/scalafix-interfaces/0.9.31/scalafix-interfaces-0.9.31.jar!/scalafix-interfaces.properties

が、 GitHub - scalacenter/scalafix: Refactoring and linting tool for Scalaリポジトリを見てみるとそういったファイルは存在しない。

こういった場合は自動生成されている可能性があるので build.sbt を探すと以下のように自動生成を行うタスクの記述がある

scalafix/build.sbt at v0.9.31 · scalacenter/scalafix · GitHub

これを見ると scalametaV という変数を参照していることが分かるので

scalafix/Dependencies.scala at v0.9.31 · scalacenter/scalafix · GitHub

にある

  val scalametaV = "4.4.28"

が該当する。

エラー2: not found scalafix-test-kit_2.12.13/0.9.29と言われたら

現象

  • scalafix(scalafix-testkit)はscalaのメジャーバージョン(scala 2.12 or 2.13)だけでなくマイナーバージョン (2.12.13 or 2.12.14)のレベルまで依存している。
  • scalafixとscalaのバージョンの組み合わせが悪いのでnot foundになっている

解決方法

以下のような要領で使えるバージョンを探しましょう

BiTemporal Data Model導入時の注意点

これはなに

  • BiTemporal Data Modelはこういうことができるよ!という内容ではなく、導入時の注意点やちょっとしたつまづきポイントなどをまとめたもの

背景

BiTemporal Data Modelについて、すでに何社かでは導入事例もあるようで*1、たまに BiTemporal Data Modelに入門中 - だいたいよくわからないブログ を参照していただくこともあるようです。

そんな中でBiTemporal Data Modelについてこういうことができてすごいよ!という話だけでなく、導入するとこういうところが辛いとかそういうこともまとめておきたいなと思った次第です。(割とぱっと思いつくところをメモっている感じなので思い出したら追記するかも)

  • DBはMySQLを想定しています。
  • activated_at, deactivated_atをビジネス時間, in_z, out_zを処理時間のカラム名として利用しています。
  • 説明のためactivated_at, deactivated_atが日付になったり日時になったりしています。(実際には日時で管理)
  • 小ネタ集なので技術的にいくらでも回避できるだろうということも一応書いています。(ちょっとだけ考えることが色々ある。というのも注意点にはなると思うので)
  • ネガキャンみたいな記事ですが、これさえ使えば完璧に幸せになれる!ってわけじゃないよ。というのが趣旨なのでBiTemporalを便利に使えるケースもいっぱいあると思います。自分の状況に合うと思ったら使い、合わないと思ったら別の手段を検討するのが良いと思います。

SQL where条件つけ忘れリスク

論理削除についての議論でもあると思うのですが、SELECT時の条件としてout_z=MAXを付け忘れている場合は当然削除されたはずのデータが処理対象になってしまいおかしなことが起こります。

さらにいうとout_z < MAXなデータって通常の本番運用ではあまりないといった使い方も結構ある(state遷移はactiavted_at,deactivated_atでやるのでout_zは障害対応でレコードを消す場合にしか使いません。など)ので、障害対応したら更に障害になった。。。ということも考えられます。

  • out_z < MAXなデータをfixtureに含めたテストを行うことを検討してみると良いかもしれません
  • レビュー時のチェックリスト・オペレーションでSQLを実行する前のチェックリストなどを作成するといったことをしてもいいかもしれません。
  • 実装時のエンバグについてはreladomoなどの対応しているORMを利用することで回避することが可能だと思います
  • リスク・コストとリターンを照らし合わせて履歴・訂正テーブルなどを作り込むことも検討しましょう。

学習コスト

初見時はやっぱり難しいです。勉強用リンク集を整備したり、練習問題つきのサンプルリポジトリがあると新規加入メンバーがキャッチアップしやすいかもしれません。

大きなシステムで大々的に使う場合は良いのですが、小さくあまりいじらないシステムで開発者が転職...といったことになると後任の運用担当者が「なにこれ・・・」となる可能性も否めません。 関わる人が幸せになるように、運用のハードルが低いシステムにするためにbitemporalを採用しないというのも選択肢としては考慮しましょう。

ユニークキーが効かない(効かせにくい)

PostgreSQLなどだと範囲型がある ので試したことはないんですが困らないんじゃないかなと思います。

例えば以下のような商品ごとの設定値を管理するレコードがあったとします。

item_id 設定値 activated_at deactivated_at in_z out_z
1 true 1990/1/1 MAX ... MAX

そして、この設定値を2021/1/1からfalseにしたいとします。 正しい手順としては以下のようになります。

item_id 設定値 activated_at deactivated_at in_z out_z
1 true 1990/1/1 2021/1/1 ... MAX
1 false 2021/1/1 MAX ... MAX

ここで誤って以下のように設定値=falseのレコードを1995/1/1から有効として登録してしまったとします。

item_id 設定値 activated_at deactivated_at in_z out_z
1 true 1990/1/1 2021/1/1 ... MAX
1 false 1995/1/1 MAX ... MAX

上記のテーブルをas_of_time = 2010/1/1 で引くとマスターデータが2件取得される現象が起こります。(あたりまえ)

この手のテーブルだと有効な設定値が一件であることをunique keyなどで保証することが多いと思いますが、activated_at, deactivated_atで管理している場合はせいぜい (item_id, deactivated_at) のunique keyを付けてdeactivated_at=MAXなレコードが2件ないことをチェックする程度の制約になりそうです。in_z, out_zに関してはout_z=MAXのレコードが唯一存在していればいいという割り切りで十分ですが、activated_at, deactivated_atの場合はそうも行かないので注意が必要です。任意のas_of_timeについてactivated_at, deactivated_atの区間に重複がないことをチェックできないタイプのRDBMSでは注意が必要です。*2

性能面のデメリット

そりゃそうだろって話なんですがstatusテーブルをbitemporal data modelで管理するといったことを考えると、status更新時には以下の二行の操作が必要です。

  • 旧レコードで UPDATE ~~ SET deactivated = ...;
  • 新レコードを INSERT ~~~;

単純にsnapshot modelで検索するのに比べて更新コストが高くなりますし、テーブルの件数も増えるのでselectの負荷もかかるかもしれません。パフォーマンス命!の場合はログに残すなどの方法も検討できるかもしれません。

activated_at == deactivate_atの場合の取り扱い

これは細かいことではあるんですが

  • 旧レコードで activated_at ~ deactiavted_at = 2000/1/1 00:00:00 ~ 2009/1/1 10:18:15
  • 新レコードで activated_at ~ deactiavted_at = 2009/1/1 10:18:15 ~ 2010/1/1 00:00:00

のようなレコードを想定した場合、as_of_time=2009/1/1 10:18:15 でデータを取得したときに旧レコードが取れるのか、新レコードが取れるのか、二件取れるのか?は当然ながら実行されるSQLに依存します。

  • activated_at < ${as_of_time} AND ${as_of_time} <= deactivated_atなら旧レコードが取れる
  • activated_at <= ${as_of_time} AND ${as_of_time} < deactivated_atなら新レコードが取れる
  • activated_at <= ${as_of_time} AND ${as_of_time} <= deactivated_at だと二件取れる

どういうSQLにするにしろ、ある程度方針を決めておかないと気づいたらアプリケーションごとにバラバラになっていたというのも新しいメンバーにとっては認知コストの上昇につながるので注意しておくとよいかもしれません。

また2件取れて嬉しいことは少ないと思うんですが $as_of_time BETWEEN activated_at AND deactivated_at のようなクエリにすると2件とれる状態になるのでご注意ください*3

同一時刻で二回アップデート

ちょっと特殊なユースケースでしか発生しないと思うのですが、ある種のバッチなどでstatusを 1 => 2 => 3 => 4と一気に遷移させつつ、履歴は全部残したいみたいなことが限定的な状況下ではあったりするかもしれません。*4

as_of_time=2021/1/1 15:15:15時点で上記のようなstate遷移を行うバッチをシンプルに実装して実行すると以下のようになるかもしれません。(もちろん実装による)

item_id status activated_at deactivated_at in_z out_z
1 1 1990/1/1 00:00:00 2021/1/1 15:15:15 ... MAX
1 2 2021/1/1 15:15:15 2021/1/1 15:15:15 ... MAX
1 3 2021/1/1 15:15:15 2021/1/1 15:15:15 ... MAX
1 4 2021/1/1 15:15:15 MAX ... MAX

ここで困るのは二点

  • ユニークキーとして(item_id, deactivated_at)を付けている場合にduplicate entryになってしまう。
  • as_of_time=2021/1/1 15:15:15時点で有効なレコードが(SQLの条件次第では)複数件存在してしまう。(このバッチではなく、他の処理のSQLに依存してしまうため調査が必要になるカモ)

これですごい困ったことはないんであれなんですが、as_of_timeをちょっとずつずらしながら変更するみたいなハックを試みても良いかもしれません(その処理が重要ならもう少し考えても良いかもしれませんが)

またactivated_at, deactivated_atを秒精度で保存していると高速に呼ばれるAPIでも踏むことがあるかもしれません。ミリ秒精度(もしくは更に細かく)での保存・管理を検討してみてもよいでしょう。

MAXの値がUTCに変換されてしまう

MAXの値としては9999-12-31 23:59:59などを利用するとことが多いと思うのですが、アプリケーションの実装次第では9999-12-31 14:59:59 などUTCに変換された値がDBに入ることがあります。 これはまあテストで気づくでしょって感じなんですが、実装次第ではこういう事が起きるので確認したほうが良いこととしては挙げておきます。

*1:1986年の論文とかも普通にあるようですし昔からやってたよというところももちろんあるでしょう

*2:イケてるTemporal DB 情報お待ちしております

*3:完全な余談ですが債権の金利計算では両端の考慮について、それぞれを前落片端・後落片端・両端と呼んで区別するそうです。債券計算サイト

*4:テスト環境でテストデータを作成するためにそれっぽい状態を作りこむバッチとか

AWS SDK javaのパッケージとv1, v2の共存について

死ぬほど今更ですがAWS SDK javaについて自分のほしい情報を早引きしたかったのでまとめ。

v1とv2のgroupid・パッケージの違い

v1,v2はgroupidやパッケージが異なっているがどちらがv2なのかそこまで自明ではないので以下にまとめる。

以下のマッピングになっている。

version groupid s3のときの指定例 全部一括で依存する時の指定
v1 com.amazonaws com.amazonaws.aws-java-sdk-s3 com.amazonaws.aws-java-sdk
v2 software.amazon.awssdk software.amazon.awssdk.s3 software.amazon.awssdk.aws-sdk-java

BOMについて

AWS SDKは内部的には別々のライブラリになっており、S3, kinesisなど必要なサービスに関するライブラリのみに依存出来る。 これらのライブラリはパッケージが一部被っている関係で、バラバラのバージョンに依存するとバイナリ互換に関する問題が発生しうる。

BOM (bills of materials)はこの問題に対処するもので、要は一貫性が取れてるライブラリのバージョン番号一覧のようなもの。 依存にBOMを加えつつ各サービスごとのライブラリのバージョン指定を省略することで、いい感じに互換性のあるバージョンを解決してくれるようになっている。

設定例

Apache Maven プロジェクトの設定 - AWS SDK for Java を参考にすると以下のようになる。

BOM指定

<project>
  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>software.amazon.awssdk</groupId>
        <artifactId>bom</artifactId>
        <version>2.X.X</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>
</project>

依存指定

    <dependency>
      <groupId>software.amazon.awssdk</groupId>
      <artifactId>dynamodb</artifactId> <- version指定不要
    </dependency>

bomを使いたくないとき

bomを使いたくない場合は手動で互換性のあるバージョンを指定することになります。

とはいえv1の例v2の例 を見ると基本的に全部一貫して同じバージョンを使っておけば良いのであんまり苦にはならないはず。(一部v1のswfみたいな開発が止まってるやつは古いバージョンになっていたりするっぽい)

心配なときは、以下から最新バージョンを見つけて各サービスの利用すべきバージョンをチェックしてみよう。

V1, V2同時利用について

SDK for Java 1.x と 2.x の並行使用 - AWS SDK for Java によるとv1, v2を一つのアプリケーションで同時に依存しても良さそう。

特に工夫も必要なくサービスごとに別々にv1, v2の依存を追加すればいいっぽい。(パッケージ違うから割と安心してできるはず。)

FOLIOでのrefined活用法 - ドメインを型で形式知化し、契約を型で表すことで認知漏れを防ぐ

FOLIOアドベントカレンダー2021 12日目です。

FOLIOではrefinedというライブラリを採用しているので、活用事例とその目的について考えていこうと思います。

お品書きは以下です。

  • refinedとは
  • ドメイン知識の形式知
  • 契約を型で表すことによる認知漏れリスクへの対策
  • プラットフォームとしてのrefined
  • まとめ

refinedとは

refinedScalaでrefinement typeを表現するためのライブラリで、Haskell同名ライブラリScala版として開発されました。

refinement type(篩型, ふるい型) はざっくりいうと、「値が0以上である」とか「長さが1以上である」といった制約を型で表現し、静的に保証するための概念で、1991年にFreemanらによってRefinement Types for MLで提案されました。

利用方法

refinedでは制約を val a: Int Refined Positive のように、元の型に対して特定の述語を追加する形で表現します。

この制約付きの型に対応する値を生成するためには二種類の方法があります。

1つ目の方法はマクロを使った方法です。

import eu.timepit.refined.auto._

val a: Int Refined Positive = 1
val b: Int Refined Positive = -1 // コンパイル時に値が検証されるためエラー

この方式は便利ですが当然ながらコンパイル時に値が特定可能な場合(数値をリテラルで初期化する場合等)でのみ利用が可能です。

2つ目の方法はランタイムに値を検証するメソッドを呼ぶ方法です。制約を満たさない場合はEitherや例外の形でエラーが返されます。

val i = 10
val a: Either[String, Int Refined Positive] = refineV[Positive](i)

 なお Int Refined Positive の場合は下記のように PosInt.fromPosInt.unsafeFrom のようなメソッドを使うことも可能です。

val i = 10
val a: Either[String, PosInt] = PosInt.from(i)

val j = -10
val b: PosInt = PosInt.unsafeFrom(j) // 制約を満たしていないため実行時例外

余談ですが PosIntのような型はRefinedTypeOps を継承すれば作成可能です。FOLIOでは正の値(>0)を表現する PosInt の他に、非負の値(>= 0)を表現する NonNegInt 、これらの BigDecimal 版である PosBigDecimal , NonNegBigDecimal などをよく利用しています。

その他の使い方については refinedのREADMEさらなる型安全性を求めて ~ Refinement TypeをScalaで実現する ~ - Visional Engineering Blog にとても分かりやすい解説があります。

ドメイン知識の形式知

FOLIOの中でも筆者が所属するチームは口座残高や株式売買に関わるシステムを担当することが多く、現金残高・証券残高・証券残高評価額・平均取得単価・約定単価・約定数量・約定金額・実現損益・実現損益前日比・含み損益・含み損益前日比など一目には違いが分かりにくい”お金や数量を表現する値”がたくさん存在しています。

複雑なドメインと戦いながらロボアド基盤をリプレースするときにした工夫 でも(20ページ目などで)発表したのですが、これらの概念を全体的に把握するだけでも一定のハードルがあります。

また、これらの概念を理解したとしても、それらが実際に取る値を理解するという次のハードルがあります。 証券残高評価額が非負の値を取るとしても、証券残高評価額の前日比は正負両方の値を取ります。現金残高は一見すると非負の値を取りそうですが業務フローによってはシステム的に負の残高を許容することもあります。

こういった知識を持たないまま開発を行うと、想定していない計算が行われたり重要なエッジケースがテストされないまま本番稼働を迎えるリスクがあります。

refinedを使うと以下のように暗黙的に持っていた不変条件に関する知識を宣言的に明示することができます。

case class OrderAmount(value: PosBigDecimal) // この場合は注文金額は0にはならない
case class OrderAmount(value: NonNegBigDecimal) // この場合は注文金額は0になりうる

上記のようなクラスを新しく作成する場合にドメインに関する知識を宣言的に表明することで、ドメイン層のクラスをチームで共有可能な知識置き場にすることができます。

契約を型で表すことによる認知漏れリスクへの対策

前述したコードの書き方は不変条件の表明を通した、いわゆる契約プログラミングの実践の一例と言えると思います。

Scalaには契約プログラミング用のメソッドとしてrequireやensuringなどが用意されている ので、これらの手法とrefinedを使った手法では何が違うのか考えてみたいと思います。

契約を型で表すことのメリット

refinedを使って契約を型で表現する手法では、fromやunsafeFromのような契約の検査を行うコードを明示的に書く必要があります。これはプライマリコンストラクタ等に require(value > 0) を記述するような手法と異なる点です。

後者の方式でも契約違反が発生した場合に後続の処理を停止させることは可能です。一方でこの処理が契約違反になる可能性があるかどうかについてはプログラマが注意を払う必要があります。

case class Fee(value: BigDecimal) {
  require(value > 0)
}

case class Cash(value: BigDecimal) {
  require(value >= 0)

  def calc(f: Fee): Cash =  Cash(value - f.value) // これは契約を満たさない可能性があるが、プログラマがそれを認識しているとは限らない
}

例のcalcメソッドのような処理を書く際に不変条件を意識できなかったとすると、これはエッジケースの認知漏れにつながる可能性があります。 もしかすると考慮漏れが発生しており設計・仕様へのフィードバックが必要な状態かもしれませんし、あるいは仕様の(暗黙の)範囲内で(設計書には書いてないけど)エラーハンドリングを行う必要があるかもしれません。

脱線: 負にならないとされているものが負になったらどうしようもないしエラーをハンドリングする必要があるのか?という疑問がありそうなので少しだけ背景の解説を入れておきます。例えば全口座を処理するバッチではエラーがあった場合に即座にプロセスを終了させると全口座の処理がストップしてしまいます。こうなると復旧が長時間化し、障害規模も単一口座から全口座へ広がってしまいます。これを避けるため該当口座の処理をskipし次口座の処理を開始、全口座処理終了後にskipされた口座についてエラーを報知するといったエラーハンドリングを行う場合があります。*1

バグや仕様の不整合でしか起こり得ないケースなのか、データによってはあり得るケースなのかによって対応は分かれると思いますが、どちらにせよそういうエッジケースがあるということを認知しなければ対応は始まりません。ここを認知しなければエラーハンドリングや適切なテストも行われず、仕様の考慮漏れを起こしたまま本番環境にデプロイされてしまう可能性も出てきてしまいます。

一方でrefinedを使って契約を型で表現する手法では、ある値がNonNegBigDecimalなのかPosBigDecimalなのかを何らかの検証を通して保証する必要があります。

case class Fee(value: PosBigDecimal)

case class Cash(value: NonNegBigDecimal) {

  def calc(c: Cash, f: Fee): Cash =  {
    // NonNegかどうかの検査をしないとCashのインスタンスを作れない。
   // プログラマは負になる場合が仕様の範囲でありえるかどうか考え、エラーハンドルを行うか異常終了させるか検討する必要が出てくる。
    // 下記コードでは契約違反の場合は例外になる。
    val nonNeg = NonNegBigDecimal.unsafeFrom(c.value.value - f.value.value)
    Cash(nonNeg)
  }
}

このようなコードスタイルの場合、仕様上ありえない(バグ以外では成功する)のか、データとしては不正なものの厳密にはあり得ること(バグではなく仕様の範囲内)なのかをプログラマが考えるきっかけがうまれます。

計算結果がマイナスになりうるといったエッジケースを認知することさえできれば、エラーハンドリング用コードの追加は容易ですし、異常終了でよいとしても復旧用マニュアルの整備を含めた事前のリスク対応索の整備が可能です。(検討を行った結果、仕様上ありえないことが分かったならその理由をコメントに書いてunsafeFromを呼び出してもよいでしょう。) いずれにせよどういう事象がありえるのか?の分析を行う機会が得られれば何らかの対応を取ることが可能になります。

つまり、このような手法を使うことで

  • エッジケースがそもそもあるんじゃないか?といった点に気づくきっかけを得られる
  • (上記を通して)設計段階でも開発段階でもテスト段階でも気づかず、エッジケースが本番で発生し障害になるというリスクを低減できる

といった効果が得られると期待しています。

もちろんrequire等を使った契約プログラミングの「誤った計算が行われた状態で処理が進んでしまうことがない」といった利点もそのまま享受できます。

nullとOptionの関係に近いかもしれない

上記で例に上げたエッジケース(マイナスになりうる)などは設計段階でもたいていは拾われますし、熟練のプログラマなら「ドメインオブジェクトなんだから不変条件あるし、それのチェックは当然」 と当たり前のように気づけるかもしれません。なにより境界値分析などでテストケースに上がる部分でもあるので、上述のようなリスクが本番環境で顕在化することは実際にはあまり多くはないのかもしれません。

一方で、「必ず目を配りながらコードを書く必要がある」、「うっかり考慮を忘れると本番でエラーが発生しバッチ処理が異常終了する」といったことはプログラマとしては可能な限り避けたいものです。

これらと同じような問題を避ける手法として「nullをOptionで表現する」手法があると考えています。nullも設計段階で拾うべきですし熟練のプログラマならNullPointerExceptionなんて出さないかもしれませんし、テストケースで確実にテストされるべきですが、実際にはNullPointerExceptionで涙を流した人は多いのではないでしょうか。

値がないことをnullの代わりにOptionで表現することで予期せぬ場所で例外が出るのを防ぐことができたり、nullになりうる箇所が制限されることでデバッグや障害時の原因調査を迅速に行うことが可能です。同様のことは事前条件として value != null を書くことでも実現できますが、「値はnullではない」・「値はnullになりうる」という契約をOptionのような型で表現することで(mapとかgetOrElseみたいな面倒なコードと引き換えに)安全なコードを手に入れることができますし、「ここ値ない可能性ありますけどどうしましょう?」といった相談も自然にできるようになると思います。

nullをOptionにしてもテストシナリオの分析やテストが不要になるわけではないのと同様に、refinedで契約を型として表してもそれらが不要になるわけではありませんが上記と同じようなメリットを得られるのではないかと考えています。

余談: 顕在的契約計算あるいはHybrid Type Checkingについて

ちなみにrefinement typeを利用して契約計算を行う手法を顕在的契約計算やHybrid Type Checkingというようです。

後者の論文では動的な契約の検査の欠点として

dynamic checking provides only limited coverage – specifications are only checked on data values and code paths of actual executions. Thus, dynamic checking often results in incomplete and late (possibly post-deployment) detection of defects.

ということが挙げられています。動的な契約の検査は実行時にしか行われず、また与えられた値に対してのみの検査となるため、テストは通っていてもデプロイした後に契約を満たさない値が与えられてエラーが出る可能性があるということのようです。対照的に静的な型検査では網羅的な検査が事前に行われるためこのようなことは通常起こりません。

すべてを静的型で検査するぞ!うおー型安全!!!と行きたいところですが、残念ながら表現力豊かな型システムでは、ある型がある述語を満足するかを静的に検査するのが難しい(決定可能でなくなる)ケースがあったりリーズナブルな時間内に型検査を終わらせることが難しくなるようです。

しかし、明らかに型が合っているプログラムについては短時間で型検査を終わらせることは可能ですし、逆に明らかに型が合わないプログラムをエラーと判定することも短時間で可能そうです。 そこでHybridなアプローチとして静的に検証可能な範囲では静的な検査、実行時に行う必要があるような検査では動的な検査(契約違反なら例外)を挿入することで両者の欠点を補いたいというモチベーションでHybrid Type Checkingが提案されていそうです。

このようなハイブリッドなアプローチを行うことで

  • 静的に検査可能な部分は静的に検査
    • 型検査を通した網羅性の担保
    • 実行時の検査実行によるオーバーヘッド削減
  • 動的な検査の強制

といったメリットを享受することが可能そうです。

refinedを使ったコードの場合

  • 静的にチェック可能な関係は静的にチェックされる
    • 例えば NonNegIntPosInt は代入可能だが逆は不可といった単純なサブタイピング関係はコンパイル時にチェックされる
  • それ以外のものは動的なチェックとなるが検査は強制される
    • 例えばNonNegIntのfromやunsafeFromのどちらでも実行時の検査が行われる

といった特徴を持っていますので、これも一種の顕在的契約計算の理論に基づいたプログラミング方法といえるかもしれません。(顕在的契約プログラミングみたいな単語を連想しましたがググった感じそういう用語はなさそうでした。)

ただし実際にコードを書いてみると静的にチェック可能な部分は思ったより少ないかもしれません。(例えばPosInt + PosInt = PosInt であるということすら静的には保証できません。 参考: refinedでPosInt + PosInt を PosIntにしたい - だいたいよくわからないブログ

こういったものは研究が進んで実装的にこなれてきて業務的に使いやすい物が産まれるまで時間がかかりますし、現段階では無闇に静的にチェックする範囲を広げると辛いかもしれませんが、時代が進んでいくとこういった研究の恩恵も享受できるかもしれません。

プラットフォームとしてのrefined

ここまで、不変条件を宣言的に型として表明しそれを保証する利点を述べてきました。

しかし、refinedを使わずともファクトリーメソッドで必ずチェックするようにすればvalue object自体が契約を表明する型になるんでないの?という声もありそうです。 実際、ファクトリーメソッドでEitherを返すようにすればCashのインスタンスを作ろうとした段階でチェックが必要なことに気がつけます、

一応

  • refinedであれば静的な保証ですむ場合がある(例えばNonNegBigDecimalのvalueを別のクラスに詰め替える場合は動的な検査は不要)
  • より宣言的であり、ファクトリーメソッドの実装を見に行く必要がない
  • 値域を表明するための方法(Pos等以外にもGreater[5]やRegex["[0-9]+"]など)が確立されており、実装者による表現方法のブレが少ない

といったメリットがあると思いますし、refinedを使ってもファクトリーメソッドが使えなくなるわけではないのでapplyを直接呼び出すのが気になる人はrefinedとファクトリーメソッドの併用することも可能です。

とはいえ、refinedを使わずに自前でfrom, unsafeFrom相当のファクトリーメソッドを作成することはもちろん可能ですし十分実用可能な範囲だと思います。

じゃあrefined使う意味ないよねってことになると寂しいので、こういった保証にrefinedを使う副次的な利点としてrefined自体がScalaライブラリのエコシステム内で既に受け入れられているという点も挙げておきます。

例えば

といった有名どころのライブラリでrefinedを使ったクラスのサポートが行われています。

そのためjsonのデシリアライズ時やdbからのselect時にフィールドを一つずつ検査するコード(あるいは自前のファクトリーメソッドを自動でいい感じに呼んでくれる仕組み)を自分で書かずとも自動で検証を行ってくれます。

こういった点は自前でファクトリーメソッドを作る方法に比べたときの既存のライブラリを使うメリットと言えそうです。

コーディングスタイルによってはファクトリーメソッドで検証するから上記は不要だ!という人もいるかもしれませんが、ある程度マッピングを簡略化できるので便利に使える人もいるかも知れません。

まとめ

この記事ではScalaのrefinedというライブラリについて、およびFOLIOでの実用例について解説しました。

主なメリットは3つです。

  • ドメイン知識の形式知
    • 似たような値がたくさんあるドメインに対する知識を型で表現するだけでなく、その不変条件も型で表現することでドメイン上の暗黙知形式知に転換し、チームで共有可能にする。
  • 契約を型で表すことによる認知漏れリスクへの対策
    • 契約プログラミングにおけるランタイムの安全性に加え、エラーハンドリング自体がそもそも不足してしまうといったエッジケースの「認知漏れ」に起因するリスクを低減する。
  • プラットフォームとしてのrefinedの利便性
    • 様々なライブラリと連携した一括検査によって生産性を向上させる。

だいぶ長々と書きましたが、型で表現されてたら便利かもね!程度の話でした。

*1:もちろん明らかに何かおかしい場合は止めたほうがいいので全部そうするとは限りません。

MySQLでのVARCHARサイズ変更を行うALTER TABLEについて

これは何

MySQLにはVARCHARの文字数を拡張するDDLをオンラインで行うための最適化が存在する。

一方でこの最適化は内部のバイナリ表現に依存した限定的な最適化であり、意図せずオフラインDDLとして実行される可能性がある。

本ページではこの最適化の仕組みと安全にサイズ変更を行うための対策について解説する。

結論

結論1

MySQL 5.6以前では文字数が何文字だろうとVARCHARサイズ変更に関する最適化は行われないのでオフラインDDLになる。

そのため変更したい場合は停止メンテ等を行うかpt-online-schema-change等の利用を検討すること。

結論2

MySQL 5.7以降ではVARCHARサイズとCHARSETに依存してオンラインで拡張可能なサイズが変わるので、将来の拡張を考えた上でサイズを検討しよう。

結論3

以下のように ALGORITHM=INPLACE, LOCK=NONEDDL末尾につけることで、DDLをオンラインで実行できるか検証できる。(algorithm, lockについては MySQLでカラム追加などのalter table中にクエリがブロックされるかなどについてのメモ - だいたいよくわからないブログ を参照)

エラーがでたらオフラインDDLになってしまうため停止メンテ等を行うかpt-online-schema-changeの利用を検討すること。

ALTER TABLE table_name CHANGE COLUMN colum_name colum_name VARCHAR(63), ALGORITHM=INPLACE, LOCK=NONE;

背景

MySQL :: MySQL 5.7 Reference Manual :: 14.13.1 Online DDL Operations によるとMySQL 5.7以降では

Extending VARCHAR column sizeIn Place=yes, Rebuilds Table=no, Permits Concurrent DML=yes, Only Modifies Metadata=yes となっている。

つまりread/writeはブロックされず、DDL自体も高速に終了することが期待できる。

しかし The number of length bytes required by a VARCHAR column must remain the same. といった記載もあり、対象範囲が自明ではない。以下ではこれらの挙動について解説する

前提: VARCHARについて

VARCHAR は 1 バイトまたは 2 バイトのlength prefixが付いたデータとして格納される。length prefixは値に含まれるバイト数を示す。 ( つまり${byte数prefix}${実際の文字列} という表現になる)

MySQL :: MySQL 5.7 Reference Manual :: 11.3.2 The CHAR and VARCHAR Types

このとき文字列のバイト数に依存して、length prefixのbyte数も以下のように変化する。

  • 1~255byteの場合は1byte (00~FF)
  • 256byte以上の場合は2byteで表現される。(0100~FFFF)
  • MySQLの1行のサイズ上限があるため65535byteより大きなVARCHARはそもそも定義することができないのでlength prefixが3byteになることはない

また DDLで指定する VARCHAR(255)などの括弧内の数字はbyte数ではなく文字数なので、VARCHAR(255)のlength prefixが1byteであるかどうかは文字コードに依存する。(latin1なら1文字1byteなのでVARCHART(255)のlength prefixは1byte、utf8mb4なら1文字4byteなのでVARCHART(255)のlength prefixは2byteになる)

確認内容

MySQL 5.6

MySQL 5.6のドキュメントには Extending VARCHAR column size についての記載はない。

see: MySQL :: MySQL 8.0 Reference Manual :: 17.12.1 Online DDL Operations

実際に下記のDDLで検証したところサイズの拡大・縮小ともにALGORITHM=INPLACEではエラーとなり、ALGORITHM=COPYが要求された。

ALGORITHM=INPLACE is not supported. Reason: Cannot change column type INPLACE. Try ALGORITHM=COPY.

ここから考えると、MySQL 5.6ではVARCHARに関する最適化はなく表中の Changing the column data type 相当のオフラインDDLになっていると想定される。

-- size=2にセット
ALTER TABLE foo MODIFY COLUMN foo_id VARCHAR(2);

-- size=2 to 3 => エラーになる
ALTER TABLE foo CHANGE COLUMN foo_id foo_id VARCHAR(3), ALGORITHM=INPLACE, LOCK=NONE;

-- size=2 to 1 => エラーになる
ALTER TABLE foo CHANGE COLUMN foo_id foo_id VARCHAR(1), ALGORITHM=INPLACE, LOCK=NONE;

MySQL 5.7

MySQL 5.7ではMySQL :: MySQL 5.7 Reference Manual :: 14.13.1 Online DDL Operationsにあるとおり、VARCHARのサイズ拡張はオンラインDDLとして実行可能であるという記載がある。

しかし、この最適化には以下のような注意点がある。

  • カラムサイズの縮小に関しては対応されていない。(縮小の場合はオフラインDDLになる)
  • length prefixのbyte数は同じである必要がある(異なる場合はオフラインDDLになる)
カラムサイズの縮小について

サイズの縮小時に最適化されない旨は上記の公式ドキュメント内に記載がある

Decreasing VARCHAR size using in-place ALTER TABLE is not supported. Decreasing VARCHAR size requires a table copy (ALGORITHM=COPY).

実際に MySQL 5.7で同様の構文でサイズを縮小しようとしたところMySQL 5.6と同様のエラーが発生することが確認できた。

length prefixのサイズ制限について

前提の項で挙げたとおりVARCHARのbyte数が1~255の場合と 256以上の場合ではlength prefixのバイト数が異なる。

そのため VARCHAR(1) => VARCHAR(2)の場合はオンラインDDLになるが、VARCHAR(3) => VARCHAR(1000)の場合はオフラインDDLになってしまうといった問題が発生しうる。

さらにこれはバイト数での話であり、「文字数を意味するVARCHAR(N)のN」について考えたい場合は文字コードについても考える必要がある。

具体的には以下のようにオンラインDDLで実行されるかのしきい値が変わる。

  • CHARSET = latin1 のときは1文字1byteなので
    • VARCHAR(1)~VARCHAR(255)はlength prefixのサイズが1
    • VARCHAR(256)~はlength prefixのサイズが2
  • CHARSET = utf8mb4 のときは1文字4byteなので
    • VARCHAR(1)~VARCHAR(63)はlength prefixのサイズが1
    • VARCHAR(64)~はlength prefixのサイズが2

安全なマイグレーションの手引き

まず不用意にオフラインDDLになることをさけるため、 LOCK=none を付与することをおすすめする。

read/writeがブロックされるalter tableになっていたら実行前にエラーになってくれる。

またMySQL 8.xのドキュメントを見てもVARCHARのサイズ拡張に対応したアルゴリズムはINPLACEだけなので、 ALGORITHM=INPLACE もつけてよいだろう。(将来的になにか素敵なアルゴリズムが出た場合は見直す必要が出るかもしれないが、とはいえ現状でもブロックなしでtableコピーもないので気にするほどではない・・はず)

MySQL :: MySQL 8.0 Reference Manual :: 17.12.1 Online DDL Operations

エラーになった場合は諦めるか停止メンテ、あるいはpt-online-schema-change等の利用を検討するのが良いだろう。