SenaXについて
SenaXはRustで書かれたORMで、キャッシュに特化しており非常に高速に動作します。
非常に高速に動作し、具体的な速度例として、DBからの取得とカウンター加算の更新を行うAPIのリクエストを100%キャッシュヒットであれば32cpuで42万req/s程度の性能を出すことができます。
他の一般的なORMのキャッシュではそのサーバ単体でのキャッシュでしかなく、他のサーバでデータが更新された場合の同期は自動的には行われないことがほとんどです。 SenaXはクエリーの更新内容を他のサーバに送信し、自動的にキャッシュを同期します。
SenaXはyaml形式のスキーマからDBレイヤーのソースコードとマイグレーションDDLを自動生成します。 マクロ生成やライブラリを使用するORMでは挙動の理解や部分的な修正が困難ですが、SenaXで生成されたコードは比較的単純で理解しやすく、コピーしてカスタマイズすることも可能です。
SenaXの特徴として次の点が挙げられます
- 取得クエリー集約
- エンティティキャッシュ
- 遅延一括更新
- サーバ間キャッシュ更新同期
取得クエリー集約
一般的なORMではAPIへのアクセスごとにそれぞれDBへクエリーを送り、その結果を返します。
SenaXではほぼ同時に行われたアクセスからのDBへのクエリーを一つにまとめ、一回のクエリーで結果を取得します。
具体的には主キーでの取得をINクエリーに変換して取得します。
これにより、キャッシュにないデータにアクセスが殺到した場合のDBの負荷を低減します。
エンティティキャッシュ
エンティティのリレーションのデータは取得時のJOINクエリーに依存して取得するのではなく、スキーマに定義されたone-to-oneあるいはone-to-manyの下位の結合関係を持つテーブルのデータを常にまとめてキャッシュします。
キャッシュの取得が主キーからのみでは従属側のテーブルから外部キーでの複数行取得ができませんので、まとめてキャッシュすることによってリレーションのデータ取得を改善できます。
また、エンティティの取得はユニークキーからの取得も対応しています。
遅延一括更新
ログなどをDBに保存する際に、同時に受け付けた他のアクセスのログとまとめてバルクインサートで保存することができます。
また、同一ページへのアクセスカウンタなどは同時にアクセスしたカウントアップ数をまとめて、合計数でのDB更新にも対応しています。
サーバ間キャッシュ更新同期
キャッシュに保存されたデータが更新される場合、他のサーバにも更新内容を伝達してそれぞれのサーバのキャッシュが最新であるように同期されます。
更新差分はカラムごとに処理されるため、別のサーバで同時に異なるカラムが更新されても問題ありません。
同じカラムが更新される場合はバージョン管理で競合を防ぐことができます。
チュートリアル
インストール
# cargo install senax
初期ファイル生成
$ senax init example
$ cd example
Actix サーバ生成
$ senax new-actix server
serverの部分は任意のパッケージ名で、actix-web を使用したWebサーバを生成します。
生成されるコードはSIGUSR2シグナルによるホットデプロイに対応しています。
DBテンプレート生成
$ senax use-db server data
スキーマに data.yml を生成し、先程の server パッケージに data というDBを使用することを設定します。
envファイル
$ cp .env.sample .env
.env の SESSION_DB_URL, DATA_DB_URL などのDB設定を必要に応じて変更してください。
スキーマファイル
スキーマファイルは基本的には設定ファイルとグループごとのモデル記述ファイルに分けて記述します。 シンプルなケースでは設定ファイル内にまとめて記述することもできます。
下記にチュートリアル用の内容を記述していますので、該当するファイルを修正してください。
schema/data.yml
# yaml-language-server: $schema=https://github.com/yossyX/senax/releases/download/0.2.0/schema.json#definitions/ConfigDef
title: Example DB
author: author name
db: mysql
ignore_foreign_key: true
timestampable: real_time
time_zone: local
timestamp_time_zone: utc
tx_isolation: read_committed
read_tx_isolation: repeatable_read
use_cache: true
use_cache_all: true
preserve_column_order: false
groups:
note:
type: model
複数のデータベース設定のための設定ファイルです。
yaml-language-serverの設定はVSCodeであればYAMLのプラグインを使用することにより、入力時にアシスタントが効きます。
db は現在のところ MySQL しか対応していません。
schema/data/note.yml に下記のファイルを作成してください。
# yaml-language-server: $schema=https://github.com/yossyX/senax/releases/download/0.2.0/schema.json#properties/model
note:
timestampable: fixed_time
soft_delete: time
versioned: true
on_delete_fn: true
use_fast_cache: true
columns:
id:
type: int
primary: true
auto_increment: auto
key: varchar
category_id: int
content:
type: text
length: 2000
not_null: true
relations:
category:
use_cache: true
tags:
type: many
in_cache: true
indexes:
key:
fields:
key:
length: 20
type: unique
content:
type: fulltext
parser: ngram
tag:
columns:
id:
type: int
primary: true
auto_increment: auto
note_id: int_not_null
name: varchar_not_null
relations:
note:
category:
columns:
id:
type: int
primary: true
auto_increment: auto
name: varchar_not_null
counter:
counting: counter
timestampable: none
use_save_delayed: true
columns:
note_id:
type: int
primary: true
date:
type: date
primary: true
counter: bigint_not_null
relations:
note:
グループ内のテーブルの設定ファイルです。
note モデルは tag と紐づいていますが、 relations の type が many では複数形の tags で自動的に紐づきます。
モデル生成
senaxのコマンドで db/data, db/session 下にクレートを生成します。
$ senax model data
$ senax model session
生成されたファイルの note.rs 等はカスタマイズ用で、 _note.rs が本体です。再度モデル生成を実行すると note.rs は上書きされず、 _note.rs は常に上書きされます。
モデル名が _Tags のようにアンダースコアが付くのは他のライブラリと被らないようにそのような命名規則になっています。
Docker での開発環境で VS Code 等を使用していてモデルの再生成後に変更が反映されていない場合は、ウィンドウの再読み込みや生成されたモデルのファイルを VS Code 上で再度保存するなどしてモデルの更新を VS Code に認識させる必要があります。
マイグレーション生成
db/data/migrations下にDDLファイルを生成します。
$ senax gen-migrate data init
$ senax gen-migrate session init
スキーマファイルを修正して再実行すると現状のDBを確認して差分のDDLを出力します。
DB仕様書の更新履歴出力のためにコメント部分に更新内容が出力されています。コメントの追加や不要な更新内容を削除して仕様書に出力される更新内容を変更することができます。
※ テーブル名はデフォルトでグループ名とモデル名を結合した名前になります。変更する場合はスキーマで table_name を指定してください。 また、 plural_table_name の設定でテーブル名を複数形で生成することが出来ます。
マイグレーション実行
$ cargo run -p db_data -- migrate -c
$ cargo run -p db_session -- migrate -c
sqlxのマイグレーションを実行します。
シャーディング設定がある場合すべてのシャードにクエリーを発行するようになっています。
-c はクリーンマイグレーションで、DBを作成してからマイグレーションを実行します。
シードスキーマ生成
$ cargo run -p db_data -- gen-seed-schema > db/data/seed-schema.json
もしくは、
$ target/debug/db_data gen-seed-schema > db/data/seed-schema.json
シードファイルの入力アシスタントのためのスキーマファイルを生成します。
シードファイル生成
$ senax gen-seed data init
シードファイルもマイグレーションと同様にDBに登録済みかのバージョン管理を行いますので、コマンドでシードファイルを生成します。
シードファイル記述
db/data/seeds/20221120120831_init.yml (数値部分は生成日時によって変化します。)
# yaml-language-server: $schema=../seed-schema.json
note:
category:
diary:
name: diary
note:
note_1:
category_id: diary
key: diary
content: content
tag:
tag_1:
note_id: note_1
name: tag 1
category_id の「diary」と note_id の「note_1」はそれぞれ category と note が生成されたときのオートインクリメントで登録された ID が渡されるようになっています。
シードデータ投入
$ cargo run -p db_data -- seed
DBテーブル定義書生成
DB仕様書のER図出力のために graphviz が必要となります。
# apt-get install graphviz
$ senax gen-db-doc data -e -H 10 > db-document.html
環境変数のLC_ALL, LC_TIME, LANGの設定により日本語の定義書を生成します。 "-e"はER図出力、"-H 10"は仕様書更新履歴を10件分出力します。
コード記述
_Noteを取得して日毎のカウンターを加算しています。 save_delayed ではこの処理が終わった後で同一の更新対象をまとめてaddの内容を加算して更新します。その更新内容をキャッシュに反映して他のサーバにも伝達します。
server/src/routes/api/cache.rs
#![allow(unused)] fn main() { #[get("/cache/{key}")] async fn handler( key: web::Path<String>, http_req: HttpRequest, session: Session<_SessionStore>, ) -> impl Responder { let ctx = get_ctx_and_log(&http_req); let result = async move { // セッションの更新例 let session_count = session .update(|s| { let v: Option<u64> = s.get_from_base(SESSION_KEY)?; match v { Some(mut v) => { v += 1; s.insert_to_base(SESSION_KEY, v)?; Ok(v) } None => { s.insert_to_base(SESSION_KEY, 1)?; Ok(1) } } }) .await?; let mut conn = DataConn::new(); let mut note = _Note::find_by_key_from_cache(&conn, &*key) // ユニークキーからのキャシュ取得 .await .with_context(|| NotFound::new(&http_req))?; note.fetch_category(&mut conn).await?; // これもキャシュから取得 let category = match note.category() { None => None, Some(v) => Some(v.name().to_owned()), }; let date = Local::now().date_naive(); let counter = _Counter::find_optional_from_cache(&conn, (note.id(), date)).await?; let count = counter.map(|v| v.counter()).unwrap_or_default() + 1; // ここまではキャッシュから当日のカウント取得 let mut counter_for_update = _CounterFactory { // 当日のカウントが未登録の場合 INSERT note_id: note.id(), date, counter: 0, } .create(&conn); let _ = counter_for_update.counter().add(1); // UPDATE加算 counter_for_update._upsert(); // INSERT ... ON DUPLICATE KEY UPDATE の指示 _Counter::update_delayed(&mut conn, counter_for_update).await?; Ok(Response { id: note.id(), category, article: note.content().to_string(), tags: note.tags().iter().map(|v| v.name().to_string()).collect(), count, }) } .await; json_response(result, &ctx) } }
キャッシュを使用しないバージョンです。
server/src/routes/api/no_cache.rs
#![allow(unused)] fn main() { #[get("/no_cache/{key}")] async fn handler( key: web::Path<String>, http_req: HttpRequest, session: Session<_SessionStore>, ) -> impl Responder { let ctx = get_ctx_and_log(&http_req); let result = async move { let mut conn = DataConn::new(); let mut note = _Note::find_by_key(&mut conn, &*key) .await .with_context(|| NotFound::new(&http_req))?; note.fetch_category(&mut conn).await?; note.fetch_tags(&mut conn).await?; let category = match note.category() { None => None, Some(v) => Some(v.name().to_owned()), }; let date = Local::now().date_naive(); let counter = _Counter::find_optional(&mut conn, (note.id(), date)).await?; let count = counter.map(|v| v.counter()).unwrap_or_default() + 1; let note_id = note.id(); let cond = db_data::cond_note_counter!((note_id=note_id) AND (date=date)); // WHERE句を生成するマクロ conn.begin().await?; let mut update = _Counter::for_update(&mut conn); // 更新内容を指定するための空のUpdate用オブジェクト生成 let _ = update.counter().add(1); _Counter::query() .cond(cond) .update(&mut conn, update) .await?; conn.commit().await?; Ok(Response { id: note.id(), category, article: note.content().to_string(), tags: note.tags().iter().map(|v| v.name().to_string()).collect(), count, }) } .await; json_response(result, &ctx) } }
この例ではWHERE句の使用例のため、UPDATEクエリーで記述しています。
また、簡略化のため当日のカウンターが未登録の場合を考慮していません。
upsert を使用すれば未登録の場合は登録、すでに登録されている場合は更新を実行できます。
サーバ起動
$ cargo run -p server
http://localhost:8080/api/cache/diary にアクセスして結果を確認できます。
本番環境ではリリースモードでビルドして起動します。
$ cargo build -p server -r
$ target/release/server
s
スキーマ
スキーマはSymfony 1 の doctrine/schema.yml に近いイメージです。
Doctrine の inheritance も一通り対応しています。
特徴的な構造として、モデルは必ずグループの下に分類されます。
一つのデータベースに数百のテーブルがあるとディレクトリツリーが長くなったり管理が困ることになりますので、1階層分グループ分けできるようになっています。
テーブル名がマルチバイト文字や複数形にも対応していますが、GraphQLがマルチバイトに対応していないため注意が必要です。
Schema Definition
Config Definition
データベース設定
Properties
Type | Description | Required | |
---|---|---|---|
db_no | integer | リンカーで使用されるデータベースナンバー 自動生成では毎回現在時刻が使用されるので、強制上書き時に固定する場合に指定する | |
db | DbType | 使用するDB。現在のところmysqlのみ対応 | Yes |
title | string | 仕様書等のためのタイトル | |
author | string | 仕様書等のための著者 | |
ignore_foreign_key | boolean | trueの場合は外部キー制約をDDLに出力しない | |
plural_table_name | boolean | テーブル名を複数形にする | |
timestampable | Timestampable | デフォルトのタイムスタンプ設定 | |
time_zone | TimeZone | 日時型のデフォルトのタイムゾーン設定 | |
timestamp_time_zone | TimeZone | created_at, updated_at, deleted_atに使用されるタイムゾーン | |
soft_delete | SoftDelete | 論理削除のデフォルト設定 | |
use_cache | boolean | キャッシュ使用のデフォルト設定 | |
use_fast_cache | boolean | 高速キャッシュ使用設定(experimental) | |
use_cache_all | boolean | 全キャッシュ使用のデフォルト設定 | |
use_insert_delayed | boolean | 遅延INSERTを使用する | |
use_save_delayed | boolean | 遅延SAVEを使用する | |
use_update_delayed | boolean | 遅延UPDATEを使用する | |
use_upsert_delayed | boolean | 遅延UPSERTを使用する | |
tx_isolation | Isolation | 更新トランザクション分離レベル | |
read_tx_isolation | Isolation | 参照トランザクション分離レベル | |
engine | string | MySQLのストレージエンジン | |
character_set | string | 文字セット | |
collate | string | 文字セット照合順序 | |
preserve_column_order | boolean | DDL出力時のカラム順序維持設定 | |
groups | Map<property, GroupDef> | モデルグループ | Yes |
DB type
Allowed values
mysql
Timestampable
any of the following
none
real_time
(クエリー実行日時)fixed_time
(DbConnの生成日時)
TimeZone
Allowed values
local
utc
SoftDelete
any of the following
none
time
flag
unix_time
(ユニーク制約に使用するためのUNIXタイムスタンプ UNIX time for unique index support)
Isolation
Allowed values
repeatable_read
read_committed
read_uncommitted
serializable
Group Def
Properties
Type | Description | Required | |
---|---|---|---|
type | GroupType | Yes | |
title | string | ||
models | Map<property, ModelDef> | ||
enums | Map<property, EnumDef> |
Group Type
any of the following
model
(モデル定義)enum
(列挙型定義のみ)
Model Def
Properties
Type | Description | Required | |
---|---|---|---|
title | string | 仕様書等のためのタイトル | |
comment | string | コメント | |
table_name | string | テーブル名 | |
ignore_foreign_key | boolean | trueの場合は外部キー制約をDDLに出力しない | |
timestampable | Timestampable | タイムスタンプ設定 | |
disable_created_at | boolean | created_atの無効化 | |
disable_updated_at | boolean | updated_atの無効化 | |
soft_delete | SoftDelete | 論理削除設定 | |
versioned | boolean | キャッシュ整合性のためのバージョンを使用するか | |
counting | string | save_delayedでカウンターを使用するカラム | |
use_cache | boolean | キャッシュを使用するか | |
use_fast_cache | boolean | 高速キャッシュを使用するか(experimental) | |
use_cache_all | boolean | 全キャッシュを使用するか | |
use_cache_all_with_condition | boolean | 条件付き全キャッシュを使用するか | |
use_insert_delayed | boolean | 遅延INSERTを使用する | |
use_save_delayed | boolean | 遅延SAVEを使用する | |
use_update_delayed | boolean | 遅延UPDATEを使用する | |
use_upsert_delayed | boolean | 遅延UPSERTを使用する | |
ignore_propagated_insert_cache | boolean | 他サーバでinsertされたデータをキャッシュするか | |
on_delete_fn | boolean | 物理削除時の_before_deleteと_after_deleteの呼び出しを行うか | |
abstract | boolean | 抽象化モード | |
inheritance | Inheritance | 継承モード | |
engine | string | MySQLのストレージエンジン | |
character_set | string | 文字セット | |
collate | string | 文字セット照合順序 | |
mod_name | string | 名前にマルチバイトを使用した場合のmod名 | |
act_as | ActAs | 機能追加 | |
exclude_from_api | boolean | API生成から除外する | |
columns | Map<property, ColumnTypeOrDef> | カラム | |
relations | Map<property, RelDef> | リレーション | |
indexes | Map<property, IndexDef> | インデックス |
Inheritance
Properties
Type | Description | Required | |
---|---|---|---|
extends | string | 継承元 | Yes |
type | InheritanceType | 継承タイプ | Yes |
key_field | string | column_aggregationの場合のキーカラム | |
key_value | [boolean, number, string, integer] | column_aggregationの場合のキーの値 |
Inheritance Type
any of the following
simple
(単一テーブル継承 子テーブルのカラムも含めたすべてのカラムを親となるテーブルに格納する)concrete
(具象テーブル継承 子クラスごとに共通のカラムとそれぞれのモデルのカラムをすべて含んだ状態で独立したテーブルを作成する)column_aggregation
(カラム集約テーブル継承 単一テーブル継承と似ているが、型を特定するための _type カラムがある)
ActAs Definition
Properties
Type | Description | Required | |
---|---|---|---|
session | boolean | セッションDBとして使用 |
Column Type Or Def
any of the following
Column Def
Properties
Type | Description | Required | |
---|---|---|---|
title | string | ||
comment | string | ||
type | ColumnType | Yes | |
signed | boolean | 指定がない場合はunsigned | |
not_null | boolean | 指定がない場合はnullable | |
primary | boolean | ||
auto_increment | AutoIncrement | ||
length | integer | 長さ(文字列の場合はバイト数ではなく、文字数) | |
max | integer | 最大値(decimalは非対応) | |
min | integer | 最小値(decimalは非対応) | |
collate | string | ||
not_serializable | boolean | serializeに出力しない(パスワード等保護用) | |
precision | integer | ||
scale | integer | ||
time_zone | TimeZone | ||
enum_values | Array<EnumValue> | 列挙型の値 | |
db_enum_values | Array<DbEnumValue> | DBの列挙型を使用する場合の値 | |
enum_model | string | スキーマ内で定義された列挙値名 (名前は::区切り) | |
json_class | string | Json型で使用する型名 | |
exclude_from_cache | boolean | キャッシュからの除外設定 | |
skip_factory | boolean | factoryからの除外設定 | |
rename | string | カラム名の別名設定 | |
srid | integer | Point型のSRID | |
default | string | ||
sql_comment | string | ||
api_visibility | ApiVisibility | API可視性 | |
api_required | boolean | API入力時必須 |
Column Type
Allowed values
tinyint
smallint
int
bigint
float
double
varchar
boolean
text
blob
timestamp
datetime
date
time
decimal
array_int
array_string
json
enum
db_enum
db_set
point
Auto Increment
Allowed values
auto
Enum Value
Properties
Type | Description | Required | |
---|---|---|---|
name | string | Yes | |
title | string | ||
comment | string | ||
value | integer | 0~255の値 | Yes |
DB Enum Value
Properties
Type | Description | Required | |
---|---|---|---|
name | string | Yes | |
title | string | ||
comment | string |
API Visibility
Allowed values
readonly
hidden
Column Subset Type
Allowed values
tinyint
smallint
int
bigint
float
double
varchar
boolean
text
blob
datetime
date
time
decimal
array_int
array_string
json
tinyint_not_null
smallint_not_null
int_not_null
bigint_not_null
float_not_null
double_not_null
varchar_not_null
boolean_not_null
text_not_null
blob_not_null
datetime_not_null
date_not_null
time_not_null
decimal_not_null
array_int_not_null
array_string_not_null
json_not_null
Relation Def
Properties
Type | Description | Required | |
---|---|---|---|
title | string | ||
comment | string | ||
model | string | 結合先のモデル 他のグループは::区切りで指定 | |
type | RelationsType | ||
local | string | 結合するローカルのカラム名 | |
foreign | string | 結合先のカラム名 | |
in_cache | boolean | manyあるいはone_to_oneの場合にリレーション先も一緒にキャッシュするか 結合深さは1代のみで子テーブルは親に含んだ状態で更新する必要がある | |
raw_cond | string | リレーションを取得する際の追加条件 記述例:rel_group_model::Cond::Eq(rel_group_model::ColOne::value(1)) | |
order_by | string | ||
desc | boolean | ||
limit | integer | ||
use_cache | boolean | ||
use_cache_with_trashed | boolean | リレーション先が論理削除されていてもキャッシュを取得する | |
on_delete | ReferenceOption | DBの外部キー制約による削除およびソフトウェア側での削除制御 | |
on_update | ReferenceOption | DBの外部キー制約による更新 |
Relations Type
Allowed values
many
one
one_to_one
Reference Option
Allowed values
restrict
cascade
set_null
set_zero
Index Def
Properties
Type | Description | Required | |
---|---|---|---|
fields | Map<property, IndexFieldDef> | ||
type | IndexType | ||
parser | Parser |
Index Field Def
Properties
Type | Description | Required | |
---|---|---|---|
sorting | SortType | ||
length | integer |
Sort Type
Allowed values
asc
desc
Index Type
Allowed values
index
unique
fulltext
spatial
Parser
Allowed values
ngram
mecab
Enum Def
Properties
Type | Description | Required | |
---|---|---|---|
title | string | タイトル | |
comment | string | コメント | |
enum_values | Array<EnumValue> | 列挙値 | Yes |
mod_name | string | 列挙子の名前にマルチバイトを使用した場合のmod名 |
.envファイル
改行区切りを使用するために dotenv ではなく、 dotenvy を使用してください。
DB設定
パラメータ名 | 説明 |
---|---|
{DB名}_DB_URL | 更新用DB接続URL(改行区切りでシャーディング設定) |
{DB名}_REPLICA_DB_URL | 参照用DB接続URL(改行区切りでシャーディング設定、カンマ区切りでレプリカ設定) |
{DB名}_CACHE_DB_URL | キャッシュ用DB接続URL(改行区切りでシャーディング設定、カンマ区切りでレプリカ設定) |
{DB名}_TEST_DB_URL | テストDB接続URL |
{DB名}_DB_MAX_CONNECTIONS | 更新用コネクション数 |
{DB名}_REPLICA_DB_MAX_CONNECTIONS | 参照用コネクション数 |
{DB名}_CACHE_DB_MAX_CONNECTIONS | キャッシュ用コネクション数 |
キャッシュ設定
パラメータ名 | 説明 |
---|---|
{DB名}_FAST_CACHE_INDEX_SIZE | 高速キャッシュインデックスメモリサイズ |
{DB名}_SHORT_CACHE_CAPACITY | ショートキャッシュメモリサイズ |
{DB名}_SHORT_CACHE_TIME | ショートキャッシュ保持時間(秒) |
{DB名}_LONG_CACHE_CAPACITY | ロングキャッシュメモリサイズ |
{DB名}_LONG_CACHE_TIME | ロングキャッシュ保持時間(秒) |
{DB名}_LONG_CACHE_IDLE_TIME | ロングキャッシュアイドル時間(秒) |
{DB名}_DISK_CACHE_INDEX_SIZE | ディスクキャッシュインデックスメモリサイズ |
{DB名}_DISK_CACHE_FILE_NUM | ディスクキャッシュ分割ファイル数 |
{DB名}_DISK_CACHE_FILE_SIZE | ディスクキャッシュファイルサイズ |
{DB名}_CACHE_TTL | キャッシュ保持時間 |
DISABLE_{DB名}_CACHE | そのサーバへのキャッシュと更新通知の無効化(true or false) |
リンカー設定
パラメータ名 | 説明 |
---|---|
LINKER_PORT | リンカー接続ポート(TCP or UNIX) |
LINKER_PASSWORD | リンカーパスポート |
トランザクション
トランザクションには更新用トランザクションと参照用トランザクションがあります。
同時に使用する場合、更新用と参照用の取得順序が混在しているとコネクションプールからの取得がデッドロックする可能性がありますので、取得順序を統一する必要があります。
また、キャッシュの取得は現在使用しているコネクションとは別コネクションで参照用トランザクションを用いて取得されます。
更新用トランザクション
#![allow(unused)] fn main() { let mut conn = DbConn::new(); conn.begin().await?; conn.commit().await?; }
参照用トランザクション
#![allow(unused)] fn main() { let mut conn = DbConn::new(); conn.begin_read_tx().await?; }
セーブポイント
更新用トランザクションがネストされているとセーブポイントになります。
トランザクションを使用しない場合
意図的にトランザクションを使用していないのか、あるいは実装漏れなのか分からないのはバグの元です。
そのため、トランザクションを使用しない自動コミットの場合は begin_without_transaction() の呼び出しが必要です。
単純なinsert, updateが可能です。クエリーの実行ごとにコネクションが変わっている可能性がありますので、DB接続のセッションの連続性に依存する処理はできません。
また、force_delete では更新用トランザクションが必要です。
#![allow(unused)] fn main() { let mut conn = DbConn::new(); conn.begin_without_transaction().await?; }
モデルについて
メソッド
モデル名で自動生成されるメソッド
メソッド | 説明 |
---|---|
bulk_insert | |
bulk_upsert | |
clear_cache | |
delete | |
delete_by_ids | |
eq | |
find | |
find_all_from_cache | |
find_by_key | |
find_by_key_from_cache | |
find_for_update | |
find_for_update_with_trashed | |
find_from_cache | |
find_from_cache_with_trashed | |
find_many | |
find_many_for_update | |
find_many_from_cache | |
find_many_from_cache_with_trashed | |
find_many_with_trashed | |
find_optional | |
find_optional_for_update | |
find_optional_from_cache | |
find_optional_from_cache_with_trashed | |
find_optional_with_trashed | |
find_with_trashed | |
for_update | |
force_delete | |
force_delete_all | |
force_delete_by_ids | |
force_delete_relations | |
insert_delayed | |
insert_dummy_cache | |
insert_ignore | |
query | |
restore | |
save | |
save_delayed | |
set | |
upsert_delayed | |
_receive_update_notice |
QueryBuilder
メソッド | 説明 |
---|---|
bind | |
cond | |
count | |
delete | |
fetch_category | |
fetch_tags | |
force_delete | |
limit | |
offset | |
only_trashed | |
order_by | |
raw_query | |
select | |
select_for | カラム数を削減したサブセット型で取得を行う |
select_for_update | |
select_from_cache | |
select_from_cache_with_count | |
select_one | |
select_one_for | |
select_stream | |
select_stream_for | |
select_with_count | limit制限付きの場合に、全件検索結果数とlimit分の取得結果を返す |
select_with_count_for | |
update | |
with_trashed |
_{モデル名}Rel
メソッド | 説明 |
---|---|
fetch_category |
CRUD
Create
let obj = _{モデル名}Factory {
...
}.create(conn);
_{モデル名}::save(conn, obj).await?;
Read
取得方法は多数ありますので、一例です。
let obj = _{モデル名}::find(&mut conn, id).await?
Update
更新時はカラム名のメソッドでアクセッサを呼び出し、set, add, sub, max, min, bit_and, bit_orを実行して更新します。
let mut obj = _{モデル名}::find_for_update(&mut conn, id).await?
obj.{カラム名}().set(1);
_{モデル名}::save(conn, obj).await?;
Delete
let obj = _{モデル名}::find_for_update(&mut conn, id).await?
_{モデル名}::delete(conn, obj).await?;
let obj = _{モデル名}::find_for_update(&mut conn, id).await?
_{モデル名}::force_delete(conn, obj).await?;
データ型
Name | Rust Type | MySQL Type |
---|---|---|
tinyint | u8 or i8 | TINYINT |
smallint | u16 or i16 | SMALLINT |
int | u32 or i32 | INT |
bigint | u64 or i64 | BIGINT |
float | f32 | FLOAT |
double | f64 | DOUBLE |
varchar | String | VARCHAR |
boolean | bool | TINYINT |
text | String | TEXT |
blob | Vec<u8> | BLOB |
timestamp | chrono::DateTime | TIMESTAMP |
datetime | chrono::DateTime | DATETIME |
date | chrono::NaiveDate | DATE |
time | chrono::NaiveTime | TIME |
decimal | rust_decimal::Decimal | DECIMAL |
array_int | Vec<u64> | JSON |
array_string | Vec<String> | JSON |
json | User Defined or serde_json::Value | JSON |
enum | enum | UNSIGNED TINYINT |
db_enum | String | ENUM |
db_set | String | SET |
point | senax_common::types::point::Point | POINT |
WHRER句マクロ
QueryBuilderで使用するWHRER句はマクロで記述されます。
基本的に比較演算子の左側にカラム名、右側に値となります。
マクロはそのままテキスト化ではなく、プリペアードステートメントに変換されますので、SQLインジェクションは発生しません。
他のテーブルとの結合はJOINではなく、EXISTSで判定します。膨大なブログから表示可能な新着数件を抽出などではJOINよりもEXISTSのほうが早く取得できます。
#![allow(unused)] fn main() { let cond = db_{DB名}::cond_{グループ名}_{モデル名}!(クエリー); let cond = db_sample::cond_note_note!(color = _Color::c); let cond = db_sample::cond_note_note!(content = "ddd"); let cond = db_sample::cond_note_note!(NOT(note_id = id)); let cond = db_sample::cond_note_note!(note_id BETWEEN (3, 5)); let cond = db_sample::cond_note_note!(note_id SEMI_OPEN (3, 5)); let cond = db_sample::cond_note_note!((note_id < c) AND (note_id < 20) AND (note_id IN (3,3))); let cond = db_sample::cond_note_note!((NOT ((note_id < c) AND (note_id < 1) AND (note_id < 2))) OR (note_id < 3) OR (note_id < 4)); let cond = db_sample::cond_note_note!(note_id ANY_BITS c); let cond = db_sample::cond_note_note!(ONLY_TRASHED AND (category EXISTS (name = "diary"))); let cond = cond.and(db_sample::cond_note_note!(json CONTAINS [3,5])); let result = _Note::query().cond(cond).select(conn).await?; }
SEMI_OPENはBETWEENに似ていますが、半開区間 3 <= note_id AND note_id < 5 のように変換されます。
order by については下記のようになります。 IS NULL ASCの指定も可能ですが、インデックスの最適化は得られない可能性があります。
#![allow(unused)] fn main() { let order = db_sample::order_by_note_note!(note_id IS NULL ASC, note_id ASC); let result = _Note::query().order_by(order).select(conn).await?; }
キャッシュ
エンティティキャッシュ
SenaXは一般的なORMのようなクエリーキャッシュの動作はせず、エンティティキャッシュを基本にテーブルのデータとリレーション先のデータをまとめてキャッシュします。 リレーション先は1世代のみです。
ショートキャッシュ
insertされたデータが必ずしも参照されるとは限らないので、参照されるまで比較的TTLの短いショートキャッシュに保存されます。
高速キャッシュ (FAST_CACHE)
エンティティキャッシュをノンブロッキングでより高速に参照できるキャッシュです。
非常に高いキャッシュヒット率の環境下での速度安定化に貢献します。
ディスクキャッシュ
キャッシュをディスクに保存します。保存先はSSDを想定しています。
io_uringを使用しているため、Linuxのみ対応しています。
ディスクキャッシュに保存されている位置を示すインデックス領域をメモリ上に必要とします。そのため、大容量のディスクキャッシュは難しいかもしれません。
ホットデプロイの場合でもディスクキャッシュの再利用を行わず、別ファイルとなります。
瞬時にディスクキャッシュがフルになって2倍の容量を必要とすることはないと思われますが、前のサーバが速やかに終了しない場合は問題が発生する可能性があります。
単純な追記書き込みのため、容量効率はよくありませんが、下記2点に特化しています。
- ディスクキャッシュ上に保存されているかの判定がメモリ上で瞬時に可能
- 指定されたディスク容量を絶対にオーバーしない
バージョンキャッシュ
キャッシュの更新とDBからの取得がほぼ同時の場合に不整合が発生していないかの確認のため、バージョン機能を有効にしている場合にバージョンナンバーをキャッシュします。
ユニークインデックスキャッシュ
ユニークインデックスがある場合、ユニークインデックスからIDを検索するためのユニークインデックスキャッシュを使用します。
ダミーキャッシュ
「いいね!」などの表示は押されていなければデータがありませんのでキャッシュされず、何度も再取得されることになります。
その場合、「いいね!」を解除した状態のデータを insert_dummy_cache で登録することで代わりのキャッシュとすることができます。
サーバ間同期
サーバ間の同期は全データを送るのではなく、更新されたカラムのデータのみ送信します。
受信側はキャッシュを保有していれば受信した更新内容でキャッシュを更新します。
ですので、同時に異なったサーバで更新されてもそれが異なるカラムであれば問題ありません。
同じカラムが更新される場合は、add()やmax()などのメソッドで更新していれば、それぞれ加算や現在値と更新値の最大値での更新になりますので、問題が発生しにくくなります。
さらにバージョン機能を有効にすれば同期が確実になります。
キャッシュ更新通知
_receive_update_notice() で他のサーバからのキャッシュ更新通知を受信して処理をカスタマイズすることができます。独自に実装した新着やランキングの修正、チャット通知などに利用できます。
マイクロサービス化
サービスを再起動するとキャッシュは破棄されます。 新規のイベントの実装等により、既存の安定したサービスのキャッシュが破棄されないようにマイクロサービスアーキテクチャで実装することが望ましいです。 その場合、必ずしも相互のリモートAPIを呼び出す必要はなく、データの参照程度であればローカルのメソッドを呼び出してデータを取得すれば、更新はキャッシュ更新通知で受信することができるので実装が簡単で高速です。
遅延一括更新
insert_delayed
ログやツイートを想定しており、同時にリクエストを受け付けた他の保存データとまとめてバルクインサートを行う。
DBとの接続が切れている場合はディスクに保存して、接続が復活するとDBに保存される。
リレーションのデータも同時に保存可能。
save_delayed
アクセスカウンタを想定しており、同一のページへのアクセスが複数回行われる場合に、それらのアクセス数を加算してからDBへの加算クエリーが実行される。
加算後のカウンタ値を取得してmax()メソッドでの更新に変換してサーバ間同期が行われるので、サーバ間のキャッシュ不整合が発生しない。
DBとの接続が切れている場合はディスクに保存せず、繰り返し再実行が行われる。その間サーバは終了できない。
リレーションのデータは保存されない。
upsert_delayed
「いいね!」のチェックを想定しており、複数のページへの同一のUPDATEクエリーをまとめて実行される。
save_delayed ではそれぞれの主キーごとに1クエリーを必要とするが、upsert_delayed では主キーをIN句にまとめるため効率が良い。
アクセスカウンタにも使用可能であるが、max()メソッドでの更新への変換が行われないので、データを取得するタイミングと加算するタイミングによりサーバ間のキャッシュ不整合が発生する可能性がある。
DBとの接続が切れている場合はディスクに保存せず、繰り返し再実行が行われる。その間サーバは終了できない。
リレーションのデータは保存されない。
リレーション
取得時にJOINでのリレーション取得は行わず、クエリー実行後に対象の主キーをまとめて、リレーション先からIN句を使用したクエリーで取得します。
リレーションの取得
取得したオブジェクトや更新用オブジェクト、あるいは Vec<_{モデル名}> などに fetch_{リレーション名} のメソッドを使用すると、そのオブジェクトやリストに必要なリレーションを一回のクエリで取得してそれぞれ元のオブジェクトに保存します。
#![allow(unused)] fn main() { let mut list = _{モデル名}::query().select(&mut conn).await?; list.fetch_{リレーション名}(&mut conn).await?; }
3世代目の取得
リレーション先のリレーションのような3世代目の取得については Vec<&mut _{モデル名}> にまとめれば fetch_{リレーション名} のメソッドが使用可能です。
その際、2世代目のリレーション先のトレイトが必要になります。
取得された3世代目リレーションのオブジェクトはそれぞれ1世代目オブジェクトが所持する2世代目オブジェクトの下に所持するようになります。
#![allow(unused)] fn main() { use {2世代目のリレーションmod}::*; let mut list = _{1世代目モデル名}::query().select(&mut conn).await?; list.fetch_{2世代目リレーション名}(&mut conn).await?; let mut v: Vec<_> = list.iter_mut().map(|v| v.{2世代目リレーション名}()).flatten().collect(); v.fetch_{3世代目リレーション名}(&mut conn).await?; }
リレーションのタイプ
スキーマに設定するリレーションのタイプとして、one, one_to_one, many に対応しています。
- one
自テーブルの主キーではないカラムに他のテーブルの主キーを保持している状態 - one_to_one
自テーブルが主であり、従属テーブル側も主キーに自テーブルと同じ値を保持して一対一で結合している状態 - many
自テーブルが主であり、従属テーブル側に自テーブルの主キーを保持して一対多で結合している状態
リレーションが外部キー制約としてDDLに生成されないようにする場合は ignore_foreign_key: true を設定します。
なお、one_to_one と many の場合は従属テーブル側に on_delete: cascade を設定可能です。外部キー制約を生成していない場合でもフレームワークにより物理削除に連動して削除されます。
リレーションのキャッシュ
オブジェクトのキャッシュを有効にする場合、 use_cache あるいは in_cache の設定を有効にします。
- use_cache
リレーションのタイプが one の場合に設定可能で、まとめてキャッシュはされませんが、キャッシュオブジェクトからリレーション先のオブジェクトのキャッシュを fetch することが出来ます。 - in_cache
リレーションのタイプが one_to_one, many の場合に設定可能で、キャッシュ時にまとめてキャッシュされます。
なお、 use_cache の代わりに use_cache_with_trashed を設定することでリレーション先が論理削除されていてもキャッシュを取得できます。 これは、例えば社員を論理削除しても過去にその社員が作成したドキュメントから作成者の名前が消えると困るようなケースで使用できます。
ドメイン
ORMのモデル層としてはカスタマイズ用の note.rs にロジックを実装したくなるところですが、自動生成されたクレートのビルドに非常に時間がかかるので、別途ドメイン用のクレートを用意してそちらにビジネスロジックを実装します。
単純な方法としてはビジネスロジックのトレイトを定義して _Note にトレイトの実装を行うことで、 _Note にメソッドが追加されたように扱うことができます。
クリーンアーキテクチャの場合はモデルとテーブルの乖離が大きいので、新しい構造体を定義してデータの詰替などを実装する必要があると考えられます。
その際に _Note の代わりに Note などの名前で新しい構造体を定義すれば、名前が混乱することもなくクリーンに実装できると思われます。
GraphQL
GraphQLについて
GraphQLはまだあまり一般的とはいえないかもしれません。 理由として、下記が挙げられます。
- 自分で一からサーバを実装するのは困難で特定のフレームワークを必要とする
- リレーションをたどって秘密情報が漏れそう
- アクセス制限が不安
メリットとしては複数のマスタの情報を一回で取得できますので、フォームの表示に必要な項目などが効率良くなります。 Rust の async_graphql クレートでは先程あげた問題もなく比較的簡単に実装できそうです。
GraphQLインタフェース生成
下記のコマンドで GraphQL のインタフェースを作成します。
$ senax graphql <server> <db> [group] [model]
server : 実装するサーバパッケージのPATH
db : DB名
group : グループ名(省略可)
model : モデル名(省略可)
graphql.rs には下記の行がコメントアウトされているはずですので、コメントを解除して有効化します。
#![allow(unused)] fn main() { pub mod <db name>; }
#![allow(unused)] fn main() { async fn <db name>(&self) -> data::GqiQueryData { data::GqiQueryData } }
#![allow(unused)] fn main() { async fn <db name>(&self) -> data::GqiMutationData { data::GqiMutationData } }
サーバ起動後、 ブラウザで http://localhost:8080/gql を表示すると GraphiQL の画面で動作確認が出来ます。
GraphQLの仕様としてはRelayには対応していません。Urqlを推奨します。 また、クエリーの階層構造の一番上に取得、更新メソッドが来ることが一般的ですが、大量のメソッドが同列に並ぶのを防ぐため階層構造にしており、下記のようになります。
{
data {
note {
note {
find(id: 1) {
id,
content
}
}
}
}
}
階層構造のメリットとして階層ごとに権限の設定が可能ですので、管理が容易です。 権限設定の例のためダミーのログイン機能も実装されています。
識別子はデフォルトでスネークケースとなります。これは数字付きカラム名などを変換したときの誤動作を防ぐためです。JavaScriptで一般的なキャメルケースにする場合は --camel-case を指定してください。
rust も JavaScript もマルチバイト識別子に対応しているのにも関わらず、残念ながら GraphiQL はわざわざエラーを返してくれます。これが GraphQL の共通仕様かどうかはわかりませんが。
リレーション先の取得についてはスキーマで use_cache, in_cache が指定された1階層のみで、無限にリレーションをたどるような実装にはなっていません。
テスト
テストはテストDB接続URLで指定されたDBで実行されます。
start_test()の呼び出しでデータベースは初期化されマイグレーションが実行されます。
実DBを使用しますので、排他処理により一つずつ実行されます。
#![allow(unused)] fn main() { #[cfg(test)] mod tests { #[tokio::test] async fn test() -> Result<()> { dotenvy::dotenv().ok(); let _guard = db_sample::start_test().await?; let mut conn = DbConn::new(); conn.begin().await?; ... conn.commit().await?; Ok(()) } } }
その他
その他の項目として下記について説明します。
ロガー
senax-loggerは下記の特徴があります。
- LTSV形式で出力します。
- 1秒毎にzstdで圧縮して出力します。ただし、圧縮率は低くなるため、ローテーション後に再圧縮が必要です。
- 1日毎にローテーションします。
- Linuxの場合、io_uringを使用します。
- request, responseなどLOG_FILEで指定されたログは別ファイルに出力できます。
- errorとwarnのログ通知を受けて処理をカスタマイズすることができます。
コード例
#![allow(unused)] fn main() { let (error_rx, warn_rx) = senax_logger::init(Some(time::macros::offset!(+9)))?; }
error_rx, warn_rxを使用しない場合は、必ず戻り値を破棄してください。
#![allow(unused)] fn main() { senax_logger::init(Some(time::macros::offset!(+9)))?; }
セッション
actix-web にはセッションのプラグインがあります。 actix-web のサイトではクッキーにセッションの内容を保存するタイプのみ紹介されていますが、Redisを使用する場合のサンプルも実装されています。 ただ、下記の問題がありました。
- ロックしていない
- TTLの更新が毎回か、更新があったときのみ
1つ目のロックしていない問題について、PHPでは初期の頃からセッションの排他ロックがありました。 ほぼ1ページが1アクセスの時代から排他ロックがあったのですが、現在では1ページの表示にAjaxで複数回APIを呼び出すことが多く、セッションの排他処理がないとタイミング依存の不具合が発生する可能性があります。
2つ目のTTLの更新については、更新があったときのみのTTL延長では操作中に突然ログアウトしてしまう可能性がありますので、アクセスごとに毎回TTL更新する必要があります。ただ、より効率的な運用としては10分毎に1回などのほうがセッションタイムアウトまでの時間もほとんど影響がなく、サーバの負荷を下げることが出来ます。
SenaXではキャッシュを使用することによって非常に高速に動作し、上記の問題を解決したセッションを提供します。
- 排他ロックではなくCAS(Compare-and-Swap)を採用
- TTLの更新はTTL期間の1/64の時間経過後のアクセスで更新
また、セッションの記録領域を base, login, debug の3種類に分けています。 ログアウトしたときにセッション情報を破棄することがありますが、破棄する情報か否かをログアウト処理に個別に実装するのはあまりスマートではありませんので、ログインしている間だけ有効でログアウト時に破棄するデータを login 領域に保存してログアウト時にまとめて破棄を可能にし、破棄しない情報を base 領域に保存して破棄されないようにします。 debug 領域は devプロファイルでのみ有効で、releaseプロファイルでは保存されず、参照は常に空になりますので、無駄な処理が実行されることがありません。
次にセッションをDBに保存するとテーブルにユーザIDのカラムを追加しがちですが、ユーザIDのカラムが必要になりそうな機能の代替実装方法について説明します。
-
複数セッション同時アクセス禁止 複数セッション同時アクセス禁止は、複数のユーザが同一アカウントを流用することによりライセンスに違反して利用できることを防ぐために行われます。 実装としては、ユーザテーブルなどユーザIDが主キーのテーブルに現在ログインしているセッションIDを保存し、有効なセッションであることを確認するようにします。
-
他のセッション無効化 不正アクセスの疑いがある場合などにパスワード更新のタイミングで他のセッション無効化をすることがあります。 実装としては、パスワードにバージョンをつけ、セッションにログイン時のパスワードのバージョンを保存し、アクセスのたびにチェックしてバージョンが変更されていないか確認します。 また、何らかの事情でユーザのアクセス権限を剥奪する可能性がありますので、ユーザアカウントの有効性チェックはアクセスのたびに行うことが望ましいです。
リンカー
リンカーはキャッシュの更新内容をサーバ間に送信する中継器の役割を持ちます。
etcdが必要です。
インストール
etcd接続のため protobuf-compiler が必要となります。
# apt-get install protobuf-compiler
# cargo install senax-linker
自己認証局ファイル生成
certsディレクトリの下に自己認証局に必要なファイルを生成します。
$ senax-linker --cert
.env設定
パラメータ名 | 必須 | 説明 |
---|---|---|
KEY | ||
CERT | ||
CA | ||
HOST_NAME | 認証局用ホスト名 | |
TCP_PORT | ||
UNIX_PORT | ||
LINK_PORT | ||
PASSWORD | Yes | サーバとの接続時に使用されるパスワード |
ETCD_PORT | ||
ETCD_USER | ||
ETCD_PW | ||
ETCD_DOMAIN_NAME | ||
ETCD_CA_PEM_FILE | ||
ETCD_CERT_PEM_FILE | ||
ETCD_KEY_PEM_FILE |
実行
下記コマンド実行されますが、実際にはサービスとして起動する必要があります。
$ senax-linker