Core の DB の接続・暗号化・起動時の排他
このページは、VehicleVision.PleasanterTools.Core.Data が決めている DB まわりの規約と挙動を説明します。対象は 0.1.0-preview.9(最新のプレビュー版)です。バージョン別の違いは 概要 の対応表と 変更履歴 を参照してください。
プレビュー版・未確認の範囲
Core は 0.1.0-preview.N で、API が変わり得ます(概要)。このページは、リリースタグのソース(src/VehicleVision.PleasanterTools.Core.Data/)を読んで書いています。4 つの RDBMS の実機で動かして確かめた内容ではありません。 コード例は、ソースの署名から組み立てたもので、実行して確かめていません。
設定名の規約(AppDatabase) preview.5 以降
ツールごとに DB の設定名・ファイル名・ロック名を手で書くと、書き写しの違いが増え、取り違えの原因になります。AppDatabase は、アプリ ID とマイグレーションを持つアセンブリを受け取り、残りの名前を規約で導きます。
アプリ ID は一意にします。小文字の英字で始まり、小文字の英数字とハイフン(連続しない)だけで書けます。形が違うと、作る時に例外で止まります。
| 値 | 規約(<id> はアプリ ID、<ID> はその大文字でハイフンを下線にしたもの) | sample-app の場合 |
|---|---|---|
| RDBMS を選ぶ設定名 | <ID>_DB_PROVIDER | SAMPLE_APP_DB_PROVIDER |
| 接続文字列を与える設定名 | <ID>_DB_CONNECTIONSTRING | SAMPLE_APP_DB_CONNECTIONSTRING |
| 暗号化の検査を緩める設定名 | <ID>_DB_ALLOW_INSECURE | SAMPLE_APP_DB_ALLOW_INSECURE |
| SQLite の既定ファイル名 | <id>.db | sample-app.db |
| マイグレーションの排他のロック名 | <id>-startup-migrations | sample-app-startup-migrations |
メールの環境変数の接頭辞(<ID>_MAIL_)は、Core.Mail と Core.Data が互いを参照しないため、AppDatabase には含まれません。メール側は メール送信 を参照してください。
ロック名はアプリごとに別になります。同じ DB サーバを複数のアプリが使うとき、ロック名が同じだと別のアプリのマイグレーションを待たせてしまうためです。アプリ ID が一意なら、ロック名も衝突しません。
組み込み方の例
アプリのデータ層に AppDatabase を 1 つだけ持ちます。次はソースの XML ドキュメントにある書き方に沿った例です。
public static class SampleAppDatabase
{
public static AppDatabase Instance { get; } =
new("sample-app", typeof(SampleAppDatabase).Assembly);
// アセンブリの読み込み時に作る(SQLite のモデル型の登録が、必ず先に済む)
[ModuleInitializer]
internal static void Initialize() => _ = Instance;
}起動時の流れは次のとおりです。メソッドの名前と引数はソースの署名に合わせていますが、この組み立て自体は実行して確かめていません。
var db = SampleAppDatabase.Instance;
// 未設定なら既定(ここでは SQL Server)。読めない値は止める
var provider = db.ParseProvider(configuration[db.ProviderSetting], DatabaseProvider.SqlServer);
// 設定があればそれを使う。SQLite だけは App_Data 配下の <id>.db が既定
var connectionString = db.ResolveConnectionString(
provider, configuration[db.ConnectionStringSetting], environment.ContentRootPath);
// 暗号化を切っていないか(切っていれば例外。緩める設定が true のときだけ通す)
var allowInsecure = bool.TryParse(configuration[db.AllowInsecureSetting], out var allow) && allow;
db.EnsureSecure(provider, connectionString, allowInsecure);
// DB が繋がるまで待ってから、排他を取ってマイグレーションを当てる
var failure = await db.Migrator.WaitForDatabaseAsync(provider, connectionString, TimeSpan.FromSeconds(60));
if (failure is not null)
{
throw failure;
}
await db.Migrator.MigrateUpWithLockAsync(
provider, connectionString, AppDatabase.DefaultMigrationLockTimeout);
// 以後の読み書きは、このファクトリから接続を作る
var connections = new DbConnectionFactory(provider, connectionString);RDBMS の選択
ParseProvider は、設定値を DatabaseProvider の名前(SqlServer / PostgreSql / MySql / Sqlite)として読みます。大文字小文字は区別しません。未設定(空白だけを含む)なら、呼び出し側が渡した既定値になります。読めない値は黙って既定へ落とさず、例外で止まります。 例外の文には、設定名と指定できる値が出ます。
接続文字列の解決
ResolveConnectionString(内部では DatabaseConnectionString.Resolve)の規則です。
| 条件 | 結果 |
|---|---|
| 設定値がある | その値を使う |
| 設定値が無く、RDBMS が SQLite | <コンテンツのルート>/App_Data/<id>.db を、無ければ作成する接続文字列で返す(App_Data フォルダーも作る) |
| 設定値が無く、SQLite 以外 | <ID>_DB_CONNECTIONSTRING が設定されていない の例外で止まる |
接続文字列は、設定ファイルへ直書きせず、環境変数か Key Vault から読む前提です(DbConnectionFactory のコメント)。
接続の作り方と暗号化
DbConnectionFactory は、RDBMS に合わせた接続を作ります。
- SQL Server: 接続文字列に
Encrypt=Falseと書かれていても、必ずEncrypt=Trueにして接続します。 暗号化しないという逃げ道を残さないための動作です。 - SQLite: 待機時間(busy timeout)を 30 秒に設定し、接続を開くたびに
PRAGMA journal_mode = WAL(有効にできなければ例外)とPRAGMA foreign_keys = ONを適用します。 - PostgreSQL・MySQL: 接続文字列をそのまま使います。
起動時の暗号化の検査(ConnectionSecurity)
EnsureSecure は、接続文字列が暗号化を切っていないか、相手の証明書を確かめているかを見て、問題があれば InvalidOperationException で止めます。例外の文には問題の理由が出ます(接続文字列そのものは出しません)。
| RDBMS | 問題として扱う指定 | 直し方 |
|---|---|---|
| SQL Server | Encrypt=False(実際には暗号化されるが、読み替えを黙ってやらないために咎める)、TrustServerCertificate=True | 指定を外す |
| PostgreSQL | SslMode が Disable、Prefer、Allow、Require(Require は証明書を確かめない) | VerifyCA か VerifyFull にする |
| MySQL | SslMode が None(Disabled)、Preferred、Required(Required は証明書を確かめない) | VerifyCA か VerifyFull にする |
| SQLite | 検査しない(同一プロセスがローカルファイルを直接読むため) | - |
検証環境が自己署名の証明書を使うときのために、<ID>_DB_ALLOW_INSECURE=true を設定すると、検査の結果を無視して起動できます。緩むのは検査だけです。 SQL Server の接続は、それでも必ず暗号化されます。本番では true にしないでください。
運用での確認
起動時に「DB への接続が暗号化されていない、または相手を確かめていない」という例外で止まったら、例外の文に出る理由(上の表の指定)を接続文字列から直します。検証環境で意図して緩めるときだけ、例外の文が示す設定名(<ID>_DB_ALLOW_INSECURE)を true にします。
起動時マイグレーションと排他(DatabaseMigrator)
MigrateUpWithLockAsync は、排他を取ってから未適用のマイグレーションをすべて適用します。複数台が同時に起動しても、マイグレーションを当てるのは 1 台だけになります。待機時間の既定値として、AppDatabase.DefaultMigrationLockTimeout(5 分)が用意されています。取れなかったときは TimeoutException(Migration lock was not acquired within N seconds.)で止まります。
排他の仕組みは RDBMS ごとに違います。
| RDBMS | 排他の方法 |
|---|---|
| SQL Server | sp_getapplock(排他、セッション所有) |
| PostgreSQL | pg_try_advisory_lock |
| MySQL | GET_LOCK |
| SQLite | DB ファイルと同じ場所の <DB ファイル>.migration-lock.sqlite に書き込み予約(BEGIN IMMEDIATE)を取る。ファイルを持たない DB(:memory:)では使えない |
- ロックの名前には DB の名前から作った値を付け足します(SQL Server・MySQL)。同じロック名のアプリが同じサーバの別々の DB を使っても、互いに待たせません。
- 100 ミリ秒ごとに取り直し、期限まで待ちます。ロックを保持する接続を閉じれば、DB 側でも解放されます。
- 前方のみです。ロールバック用のスクリプトには頼りません。
この排他を直接使いたい場合のために、DatabaseMigrationLock が公開されています preview.3 以降。
待機・状況の確認
| メソッド | 役割 |
|---|---|
WaitForDatabaseAsync | DB が接続を受けるまで、1 秒おきに試して待つ。繋がれば null、期限までに繋がらなければ最後の例外を返す。コンテナで DB と同時に立ち上げると、DB より先にアプリが動き出すことへの備え。待つのはここだけで、アプリ本体は待たない |
HasPendingMigrations | 未適用のマイグレーションがあるか |
PendingMigrations | 未適用の名前の一覧(20 M0020_Xxx のように、番号と型の名前)。DB は読むだけで、版の表も作らない。説明文を出さず型名を出すのは、日本語の説明文が Azure の Kudu の Debug console で文字化けするため |
GetStatus | 適用済みの最大の版・最新の版・未適用の件数・最後に適用した日時(UTC)。管理画面の表示用 |
DatabaseStartupState は、起動時マイグレーションの完了まで受付や常駐処理を止めておくための状態です。MarkReady を呼ぶと IsReady が真になり、WaitUntilReadyAsync で待っていた処理が進みます。
時刻の持ち方(DbTime)
DB に入れる時刻は、必ず DbTime を通します。DateTime.UtcNow をそのまま渡さないでください。
| メソッド | 動作 |
|---|---|
DbTime.ForDb / UtcNowTruncated | 秒未満を切り捨て、DateTimeKind を外して(Unspecified)DB へ渡す形にする |
DbTime.AsUtc | DB から読んだ値に UTC の印を付け直す(値は動かさない)。DateTime? 版もある |
DbTime.Truncate | 秒未満の切り捨てだけ(Kind は保つ) |
理由は、ソースのコメントによると次の 2 つです。
- MySQL の
datetimeは秒未満を保持せず四捨五入するため、そのまま渡すと保存した時刻が現在より未来に丸められ、「今より前か」の判定が通らなくなる。 - Npgsql は
DateTimeKind.Utcを見てtimestamptzと判断するが、列は時間帯を持たないtimestampのため、PostgreSQL がセッションの時間帯へ変換してから格納する。サーバの時間帯がAsia/Tokyoだと保存値が 9 時間ずれる(ソースのコメントは「実際に踏んだ」と記しています)。
列に入っているのは常に UTC です。読み出した値は Unspecified になるので、JSON などへ出す前に AsUtc で印を付けます。
SQL の方言差(SqlDialect)
RDBMS ごとに書き方が違う部分は SqlDialect に閉じ込めています。
| 機能 | 内容 |
|---|---|
Format | SQL 中の [識別子] を、RDBMS の引用符へ書き換える(SQL Server は [x]、PostgreSQL と SQLite は "x"、MySQL は `x`)。角括弧で囲んだ所だけが書き換わる。文字列リテラルを含む SQL には使わない(リテラル内の [ まで書き換えるため) |
Page | 件数を絞る句(SQL Server は OFFSET/FETCH、それ以外は LIMIT/OFFSET)。@Limit と @Offset を使い、呼ぶ側で ORDER BY が要る |
EscapeLike / LikeEscape | LIKE の逃がし文字に ! を使う。%・_・[ と ! 自身を逃がす |
SupportsReturning | RETURNING 相当が使えるか(SQL Server・PostgreSQL・SQLite) |
SQLite のモデル型の登録 preview.4 以降
SQLite は、列挙型や record のような型を Dapper が組み立てるために、型の対応表の登録が要ります。登録の対象は、走査したアセンブリの中にある列挙型と、名前つきコンストラクタを持つ型です。AppDatabase を作ると、マイグレーションを持つアセンブリ(と additionalModelAssemblies に渡したアセンブリ)が対象に加わります。実際の登録は SQLite の接続を作るときだけで、ほかの RDBMS の挙動は変わりません。直接使うなら SqliteTypeHandlers.AddModelAssemblies です(何度呼んでもよい)。
preview.4 より前は、Core.Data のアセンブリだけが対象でした。アプリのモデルを SQLite で読むと、Dapper が A parameterless default constructor or one matching signature … is needed で止まる原因になります。