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

TypeDescriptionRequired
db_nointegerリンカーで使用されるデータベースナンバー 自動生成では毎回現在時刻が使用されるので、強制上書き時に固定する場合に指定する
dbDbType使用するDB。現在のところmysqlのみ対応Yes
titlestring仕様書等のためのタイトル
authorstring仕様書等のための著者
ignore_foreign_keybooleantrueの場合は外部キー制約をDDLに出力しない
plural_table_namebooleanテーブル名を複数形にする
timestampableTimestampableデフォルトのタイムスタンプ設定
time_zoneTimeZone日時型のデフォルトのタイムゾーン設定
timestamp_time_zoneTimeZonecreated_at, updated_at, deleted_atに使用されるタイムゾーン
soft_deleteSoftDelete論理削除のデフォルト設定
use_cachebooleanキャッシュ使用のデフォルト設定
use_fast_cacheboolean高速キャッシュ使用設定(experimental)
use_cache_allboolean全キャッシュ使用のデフォルト設定
use_insert_delayedboolean遅延INSERTを使用する
use_save_delayedboolean遅延SAVEを使用する
use_update_delayedboolean遅延UPDATEを使用する
use_upsert_delayedboolean遅延UPSERTを使用する
tx_isolationIsolation更新トランザクション分離レベル
read_tx_isolationIsolation参照トランザクション分離レベル
enginestringMySQLのストレージエンジン
character_setstring文字セット
collatestring文字セット照合順序
preserve_column_orderbooleanDDL出力時のカラム順序維持設定
groupsMap<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

TypeDescriptionRequired
typeGroupTypeYes
titlestring
modelsMap<property, ModelDef>
enumsMap<property, EnumDef>

Group Type

any of the following

  • model(モデル定義)
  • enum(列挙型定義のみ)

Model Def

Properties

TypeDescriptionRequired
titlestring仕様書等のためのタイトル
commentstringコメント
table_namestringテーブル名
ignore_foreign_keybooleantrueの場合は外部キー制約をDDLに出力しない
timestampableTimestampableタイムスタンプ設定
disable_created_atbooleancreated_atの無効化
disable_updated_atbooleanupdated_atの無効化
soft_deleteSoftDelete論理削除設定
versionedbooleanキャッシュ整合性のためのバージョンを使用するか
countingstringsave_delayedでカウンターを使用するカラム
use_cachebooleanキャッシュを使用するか
use_fast_cacheboolean高速キャッシュを使用するか(experimental)
use_cache_allboolean全キャッシュを使用するか
use_cache_all_with_conditionboolean条件付き全キャッシュを使用するか
use_insert_delayedboolean遅延INSERTを使用する
use_save_delayedboolean遅延SAVEを使用する
use_update_delayedboolean遅延UPDATEを使用する
use_upsert_delayedboolean遅延UPSERTを使用する
ignore_propagated_insert_cacheboolean他サーバでinsertされたデータをキャッシュするか
on_delete_fnboolean物理削除時の_before_deleteと_after_deleteの呼び出しを行うか
abstractboolean抽象化モード
inheritanceInheritance継承モード
enginestringMySQLのストレージエンジン
character_setstring文字セット
collatestring文字セット照合順序
mod_namestring名前にマルチバイトを使用した場合のmod名
act_asActAs機能追加
exclude_from_apibooleanAPI生成から除外する
columnsMap<property, ColumnTypeOrDef>カラム
relationsMap<property, RelDef>リレーション
indexesMap<property, IndexDef>インデックス

Inheritance

Properties

TypeDescriptionRequired
extendsstring継承元Yes
typeInheritanceType継承タイプYes
key_fieldstringcolumn_aggregationの場合のキーカラム
key_value[boolean, number, string, integer]column_aggregationの場合のキーの値

Inheritance Type

any of the following

  • simple(単一テーブル継承 子テーブルのカラムも含めたすべてのカラムを親となるテーブルに格納する)
  • concrete(具象テーブル継承 子クラスごとに共通のカラムとそれぞれのモデルのカラムをすべて含んだ状態で独立したテーブルを作成する)
  • column_aggregation(カラム集約テーブル継承 単一テーブル継承と似ているが、型を特定するための _type カラムがある)

ActAs Definition

Properties

TypeDescriptionRequired
sessionbooleanセッションDBとして使用

Column Type Or Def

any of the following


Column Def

Properties

TypeDescriptionRequired
titlestring
commentstring
typeColumnTypeYes
signedboolean指定がない場合はunsigned
not_nullboolean指定がない場合はnullable
primaryboolean
auto_incrementAutoIncrement
lengthinteger長さ(文字列の場合はバイト数ではなく、文字数)
maxinteger最大値(decimalは非対応)
mininteger最小値(decimalは非対応)
collatestring
not_serializablebooleanserializeに出力しない(パスワード等保護用)
precisioninteger
scaleinteger
time_zoneTimeZone
enum_valuesArray<EnumValue>列挙型の値
db_enum_valuesArray<DbEnumValue>DBの列挙型を使用する場合の値
enum_modelstringスキーマ内で定義された列挙値名 (名前は::区切り)
json_classstringJson型で使用する型名
exclude_from_cachebooleanキャッシュからの除外設定
skip_factorybooleanfactoryからの除外設定
renamestringカラム名の別名設定
sridintegerPoint型のSRID
defaultstring
sql_commentstring
api_visibilityApiVisibilityAPI可視性
api_requiredbooleanAPI入力時必須

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

TypeDescriptionRequired
namestringYes
titlestring
commentstring
valueinteger0~255の値Yes

DB Enum Value

Properties

TypeDescriptionRequired
namestringYes
titlestring
commentstring

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

TypeDescriptionRequired
titlestring
commentstring
modelstring結合先のモデル 他のグループは::区切りで指定
typeRelationsType
localstring結合するローカルのカラム名
foreignstring結合先のカラム名
in_cachebooleanmanyあるいはone_to_oneの場合にリレーション先も一緒にキャッシュするか 結合深さは1代のみで子テーブルは親に含んだ状態で更新する必要がある
raw_condstringリレーションを取得する際の追加条件 記述例:rel_group_model::Cond::Eq(rel_group_model::ColOne::value(1))
order_bystring
descboolean
limitinteger
use_cacheboolean
use_cache_with_trashedbooleanリレーション先が論理削除されていてもキャッシュを取得する
on_deleteReferenceOptionDBの外部キー制約による削除およびソフトウェア側での削除制御
on_updateReferenceOptionDBの外部キー制約による更新

Relations Type

Allowed values

  • many
  • one
  • one_to_one

Reference Option

Allowed values

  • restrict
  • cascade
  • set_null
  • set_zero

Index Def

Properties

TypeDescriptionRequired
fieldsMap<property, IndexFieldDef>
typeIndexType
parserParser

Index Field Def

Properties

TypeDescriptionRequired
sortingSortType
lengthinteger

Sort Type

Allowed values

  • asc
  • desc

Index Type

Allowed values

  • index
  • unique
  • fulltext
  • spatial

Parser

Allowed values

  • ngram
  • mecab

Enum Def

Properties

TypeDescriptionRequired
titlestringタイトル
commentstringコメント
enum_valuesArray<EnumValue>列挙値Yes
mod_namestring列挙子の名前にマルチバイトを使用した場合の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_countlimit制限付きの場合に、全件検索結果数と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?;

データ型

NameRust TypeMySQL Type
tinyintu8 or i8TINYINT
smallintu16 or i16SMALLINT
intu32 or i32INT
bigintu64 or i64BIGINT
floatf32FLOAT
doublef64DOUBLE
varcharStringVARCHAR
booleanboolTINYINT
textStringTEXT
blobVec<u8>BLOB
timestampchrono::DateTimeTIMESTAMP
datetimechrono::DateTimeDATETIME
datechrono::NaiveDateDATE
timechrono::NaiveTimeTIME
decimalrust_decimal::DecimalDECIMAL
array_intVec<u64>JSON
array_stringVec<String>JSON
jsonUser Defined or serde_json::ValueJSON
enumenumUNSIGNED TINYINT
db_enumStringENUM
db_setStringSET
pointsenax_common::types::point::PointPOINT

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
PASSWORDYesサーバとの接続時に使用されるパスワード
ETCD_PORT
ETCD_USER
ETCD_PW
ETCD_DOMAIN_NAME
ETCD_CA_PEM_FILE
ETCD_CERT_PEM_FILE
ETCD_KEY_PEM_FILE

実行

下記コマンド実行されますが、実際にはサービスとして起動する必要があります。

$ senax-linker