SNSアカウントの活用方針

連絡がつくもの・つかないもの

1日1回以上確認する

  • Twitter: @const_MUTE
    • タイムラインはほぼ確認できていません
    • DMは通知が来るように設定しており、確認しています

数日に一度確認する

  • Discord

未定

現在確認していない

今後の予定

  • Twitter
    • 基本的に利用縮小の方向
    • 連絡手段としてのDM確認は継続
  • Bluesky
    • フィットすれば利用する
  • Misskey
    • フィットすれば利用する
  • instagram, tiktok
    • フォローするコンテンツの動向に従う
    • が、結局見なくなっていく可能性が高い

async-graphql製アプリのセキュリティ対策

概要

logmi.jp

上記を読んで、先日つくったGraphQLアプリに対策を施すことができるか確認した。

先日作ったGraphQLアプリ

vraisamis.hatenadiary.jp

github.com

やったこと

  • introspection対策
  • field suggestion対策
  • 再帰的Fragment対策
  • alias対策

やっていないこと

introspection対策、field suggestion対策

この2つはメソッドを呼ぶだけで対策が可能。

SchemaBuilder 型のメソッドに disable_introspection() disable_suggestions() があるので、それらを呼ぶだけ。

例えば、releaseビルドでのみ無効化したいときは、以下のようにする。

let s: SchemaBuilder<_, _, _> = ...;
if cfg!(not(debug_assertions)) {
    s = s.disable_suggestions().disable_introspection();
}

再帰的Fragment対策

元々対策されている模様。

(……が、エラーメッセージが「再帰深度の最大は32です」なので確認したほうがよさそう)

query {
  user(id: "user-01HBCCGK3MG5HA7GJG25BGV6PJ") {
    ...A
  }
}

fragment A on User {
  name
  ...B
}

fragment B on User {
  ...A
}
{
  "data": null,
  "errors": [
    {
      "message": "The recursion depth of the query cannot be greater than `32`",
      "locations": [
        {
          "line": 12,
          "column": 20
        }
      ]
    }
  ]
}

alias対策

aliasはどれだけ多くても許可するようになっている。以下のように10000個のaliasをリクエストしたが普通にresponseが返ってきた。

{
  alias0: user(id: "user-01HBCCGK3MG5HA7GJG25BGV6PJ") {
    name
  }
  alias1: user(id: "user-01HBCCGK3MG5HA7GJG25BGV6PJ") {
    name
  }
  # ...
  alias9999: user(id: "user-01HBCCGK3MG5HA7GJG25BGV6PJ") {
    name
  }
}

これに対策するには独自のExtensionの実装が必要。以下に記載がある。

async-graphql.github.io

この中の parse_query を利用する。

let result = next.run(ctx, query, variables).await?; とすると、resultの中からパース済みのGraphQL queryを取り出せるので、そこからaliasを見つけてカウントしていき、エラーを出すようにする。

今回は以下のように制限した。

  • aliasは全部で10個まで
  • 各階層(ネストの深さ)ごとに、aliasは合計3つまで

全体コードは以下。

github.com

実装のコアは以下。

#[async_trait]
impl Extension for RestrictQueryAliasesImpl {
    async fn parse_query(
        &self,
        ctx: &ExtensionContext<'_>,
        query: &str,
        variables: &Variables,
        next: NextParseQuery<'_>,
    ) -> ServerResult<ExecutableDocument> {
        let result = next.run(ctx, query, variables).await?;

        // after parsed
        let query_selection_set = find_query(&result);

        let aliases = alias_count_recursive(&query_selection_set);
        if aliases > self.limit {
            Err(ServerError::new(
                format!("エイリアスは全部で{}個より多くできません", self.limit),
                None,
            ))?;
        }

        let aliases_per_level = alias_count_by_nested_level_recursive(&query_selection_set, 1);
        let max_aliases_per_level = max_value_or(aliases_per_level, 0);
        if max_aliases_per_level > self.limit_per_level {
            Err(ServerError::new(
                format!(
                    "エイリアスは各階層で{}個より多くできません",
                    self.limit_per_level
                ),
                None,
            ))?;
        }

        Ok(result)
    }
}

このextensionをSchemaBuilderで s.extension(RestrictQueryAliases::default()) として作成すればOK。

同じ階層に4つ以上aliasをいれてリクエストをすると以下のようにエラーになる。

{
  alias0: user(id: "user-01HBCCGK3MG5HA7GJG25BGV6PJ") {
    name
  }
  alias1: user(id: "user-01HBCCGK3MG5HA7GJG25BGV6PJ") {
    name
  }
  alias2: user(id: "user-01HBCCGK3MG5HA7GJG25BGV6PJ") {
    name
  }
  alias3: user(id: "user-01HBCCGK3MG5HA7GJG25BGV6PJ") {
    n0: name
    n1: name
    n2: name
    ownedBoards {
      b0: id
      b1: id
      b2: id
      owner {
        id
      }
    }
  }
}
{
  "data": null,
  "errors": [
    {
      "message": "エイリアスは各階層で3個より多くできません"
    }
  ]
}

同じ階層ではaliasを3つ以内に押さえていても、全体で10個以上になるリクエストは以下のようにエラーになる。

{
  alias0: user(id: "user-01HBCCGK3MG5HA7GJG25BGV6PJ") {
    name
  }
  alias1: user(id: "user-01HBCCGK3MG5HA7GJG25BGV6PJ") {
    name
  }
  alias3: user(id: "user-01HBCCGK3MG5HA7GJG25BGV6PJ") {
    n0: name
    n1: name
    n2: name
    ownedBoards {
      b0: id
      b1: id
      b2: id
      owner {
        id
        i0: id
        i1: id
      }
    }
  }
}
{
  "data": null,
  "errors": [
    {
      "message": "エイリアスは全部で10個より多くできません"
    }
  ]
}

async-graphql + sqlx + shakuを使ってGraphQL を試した

RustでGraphQLアプリを構築して、気づいたことや感想まとめ

作ったもの

https://github.com/vraisamis/graphql-rust-sample

  • Kanban風のデータを取得できるアプリ
  • GraphQLアプリ
    • Queryのみ実装済みで、Mutation, Subscriptionは未実装
    • QueryではDataLoaderを実装
  • 言語: Rust 1.71.0
  • ライブラリ(主要)
    • サーバー: axum 0.6.19
    • GraphQL: async-graphql 6.0.0
    • DI: shaku 0.6.1
    • SQL: sqlx 0.7.2
  • データストア
    • Postgres 16.0
    • dynamodb on localstack 2.3 (未使用)
  • オニオンアーキテクチャ
  • DDD(ドメイン駆動設計)
    • とはいえ、クエリしか作っていないのでIDくらいしか活用していない

ドメイン

だいたいこんな。未実装箇所もある https://github.com/vraisamis/graphql-rust-sample#domain

レイヤー

だいたいこんな。 https://github.com/vraisamis/graphql-rust-sample#layer

やっていないこと(完了していないこと)

  • テストの実装
  • Mutation, Subscription
  • ドメインの実装
  • dynamodb
  • CI構築

Tips

Rust: 言語機能

Rustの言語機能関連

Trait alias

何がしたかったか

shaku のモジュール MBox<dyn UsersQuery> をprovideできるとき、 M: HasProvider<dyn UsersQuery> を満たしている。 M の具体型は実装レイヤで決まるので、実装に関与しないレイヤでは抽象的に扱いたい。

しかしこのとき、 M の指定が大変なことになりやすい。

fn foo<M>(m: &M)
where
    M: HasProvider<dyn UsersQuery>,
    M: HasProvider<dyn BoardQuery>,
    M: HasProvider<dyn ColumnsQuery>,
    M: HasProvider<dyn CardsQuery>,
    // ...
{
    todo!()
}

これを解消するために直感的には typeエイリアスを定義したい。

// これはできない
type MyFuncTrait = HasProvider<dyn UsersQuery> +
    HasProvider<dyn BoardQuery> +
    HasProvider<dyn ColumnsQuery> +
    HasProvider<dyn CardsQuery>;

これはできない。

対策

SEE: https://zenn.dev/ttttkkkkk/articles/e213d91ef7b47a

上の記事に全部書いてあるが……以下をやる。

  • すべてを前提とするtrait定義
  • すべての前提traitを実装している型に対して全称実装
// trait定義
pub trait QueryProvider
where
    Self: HasProvider<dyn UsersQuery> +
        HasProvider<dyn BoardQuery> +
        HasProvider<dyn ColumnsQuery> +
        HasProvider<dyn CardsQuery>,
{
}

// すべての前提traitを実装している型に対して全称実装
impl<T> QueryProvider for T
where
    Self: HasProvider<dyn UsersQuery> +
        HasProvider<dyn BoardQuery> +
        HasProvider<dyn ColumnsQuery> +
        HasProvider<dyn CardsQuery>,
{
}

ちなみに、この用途だと FooQuery が増えるたびに2箇所に追記していくことになる。マクロで解決できないかと思ったが、構文エラーになって駄目だった。

// これはできない
macro_rules! queries {
    () => {
    HasProvider<dyn UsersQuery> +
        HasProvider<dyn BoardQuery> +
        HasProvider<dyn ColumnsQuery> +
        HasProvider<dyn CardsQuery>
    };
}

pub trait QueryProvider
where
    Self: queries!(),
{
}

impl<T> QueryProvider for T
where
    Self: queries!(),
{
}

pub(crate) とか re-exportとか

Rustのstruct, enum定義は縦に長くなりがちである。これは主に以下のような理由による。

  • メンバの定義とメソッド定義を別に書く必要があるため、構文要素が多くなる
    • ジェネリクスを持つ( Foo<T> )場合、 T が実装しているtraitによって実装するメソッドを増やしたりするともっと多くなる
  • traitを実装する記述を書くほど長くなる
    • メソッド本体よりも周りの impl FooTrait for Foo where ... がいっぱいになる
  • テストを同じファイルに書く習慣がある

また、DDDでは以下のようにしたくなる。

  • ユーザー情報に関連するモデルを複数作りたい
    • User
    • UserName
    • Email
  • 同じサブドメインに関連するモデルがわかりやすい構造で外部(上位レイヤ)から参照したい
    • use domain_kanban::user::{User, UserName, Email}

これをそのまま実現しようとすると、1ファイルに大量の定義や実装を書くことになる。対策として、複数ファイルに分ける方法を利用できる。 具体的には、以下のようにuser/email.rs, user/user_name.rs を作成する。

src/
|-- lib.rs
|-- user/
|   |-- email.rs
|   `-- user_name.rs
`-- user.rs

そして、以下のようにする。

// -------- user/email.rs --------
// `pub(crate)` で(直接)参照可能範囲を制限する
pub(crate) struct Email {
    // ...
}

impl Email {
    // ...
}

// -------- user/user_name.rs --------
pub(crate) struct UserName {
    // ...
}

impl Email {
    // ...
}

// -------- user.rs --------
// mod にはpubをつけない( `domain_kanban::user::email::Email` というふうには参照させない)
mod email;
mod user_name;

// pub use (=re-export) で `domain_kanban::{Email, UserName}` で参照できるようにする
pub use email::Email;
pub use user_name::UserName;

pub struct User {
    // ...
}
// ...

こうすることで、ファイルが長くなるのを抑えつつ、外部への見せ方を変える事ができる。 ただし、こうするとファイル数が多くなるので、やり過ぎは禁物。

Vec<Result<T, E>> to Result<Vec, E>

SEE: https://qnighy.hatenablog.com/entry/2017/06/14/220000

Vec<T>map していると、軽率に Vec<Result<T, E>> になる。

これだと扱いにくいのだが、 collect でどうにかできる。

let vec_of_results = vec![Some(1), Some(2)];
// collectは複数の型に変換できるので、要求する型を束縛する変数側に指定する
// `collect::<Result<Vec<_>, _>()` でもいいが、個人的にはこちらのほうが書きやすい
let result_of_vec: Result<Vec<_>, _> = vec_of_results.into_iter().collect();
// `?` が使える文脈なら、それを使って処理を続ける
let v = result_of_vec?;
// `?` を使うなら、collect側に書いたほうが1行で完結できる
let v = vec_of_results.into_iter().collect::<Result<Vec<_>, _>>()?;

Vec<Future> to Vec

dataloader実装(後述)をやっていても、複数回クエリを回したいときもある。

let result_futures: Vec<impl Future<Output=Foo>> = keys
    .into_iter()
    // some_queryがFutureを返す
    .map(|k| some_query(/* ... */))
    .collect();

このあとどうやって処理するかというと、futures-util join_all を使う。

let result_vec: Vec<Foo> = join_all(result_futures).await;

Rust: (Virtual) Workspaceの活用

オニオンアーキテクチャにおけるレイヤー分けを見据えて、リポジトリ全体はVirtual Workspaceにした。 ワークスペースのメンバcrateは、 crates/ ディレクトリ配下に置いた。rootディレクトリがゴチャつかないようにするためで、好みの問題かもしれない。

メンバcrateの指定は Cargo.toml で以下のようにした。

[workspace]
members = [ 'crates/*' ]

bin crateが crates/main だけで他がlib crateであれば、 cargo run で問題なくmainが起動できた。

Workspace Dependencies

https://doc.rust-lang.org/cargo/reference/workspaces.html#the-dependencies-table

Workspaceでこれが一番便利だったかもしれない。

/Cargo.tomlworkspace.dependencies 配下にいつも通り依存関係を書く。 各 /crates/*/Cargo.tomldependencies で、 package-name.workspace = true とすると、そのcrateを利用できる。

例:

# -------- on /Cargo.toml --------
[workspace.dependencies]
anyhow = "1.0.72"

# -------- on /crates/main/Cargo.toml --------
[dependencies]
anyhow.workspace = true

# -------- on /crates/domain-util/Cargo.toml --------
[dependencies]
anyhow.workspace = true

実際に定義したものを列挙してコメントしておく。説明の都合sourceとは掲載順が違うことがある。

# -------- on /Cargo.toml --------
[workspace.dependencies]
# anyhow, thiserror: エラー処理はどの層でも行うので共通で入れた
# ちゃんとエラー処理するほうに倒すと、anyhowは減っていく可能性もある
anyhow = "1.0.72"
thiserror = "1.0.47"

# tracing, tracing-subscriber: ロギング。
# ログに使っているcrate versionが揃うように。
# tracing-subscriberはもしかしたらmainだけでいいかもしれない。ちゃんとログ実装したら除去できるかも。
# 各層から依存するlog crateでtracingを使っていることを隠蔽しようかとも思ったけど、
# マクロの構文が物によって違いそうなのでやめた。
tracing = "0.1.37"
tracing-subscriber = "0.3.17"

# async-trait: infrastructure層で外部リソースを使用することが必至なため、だいたいasyncにする。
# I/Fはquery層などに置くことになるので、trait methodとしてasyncにする必要がある。
async-trait = "0.1.72"

# futures-util, itertools: 一般的なutility
futures-util = "0.3.28"
itertools = "0.11.0"

# shaku: DI。I/F側と実装側でバージョン統一するため。
shaku = "0.6.1"

# sqlx: これはinfrastructure層でしか使わない……と見せかけて、migrateとかscriptとかで使っている(後述)
# ここではバージョンのみ定義していて、featuresは使う側のcrateで決めている。
sqlx = "0.7.2"

# ulid: これはdomain-utilで吸収しているのでここではいらないかも……
ulid = "1.0.1"

# 以下各レイヤの定義。利用側でpathを毎回書かなくてよくなる。
# 依存されないcrateはここには列挙していない
presentation-axum = { path = "./crates/presentation-axum" }
presentation-graphql = { path = "./crates/presentation-graphql" }
infrastructure-rdb = { path = "./crates/infrastructure-rdb" }
query-resolver = { path = "./crates/query-resolver" }
domain-kanban = { path = "./crates/domain-kanban" }
domain-util = { path = "./crates/domain-util" }

# tokio: main関数を使う場所で使っている。mainアプリケーションでは下層のspawnerにも使っている。
[workspace.dependencies.tokio]
version = "1.29.1"
features = ["macros", "rt-multi-thread"]
  • 便利ポイント
    • シンプルに同じバージョンや同じfeaturesを共有できるので便利
    • featuresは利用側で追加できる。共通のものだけ置いておくのが便利
    • レイヤー間の依存関係について、個別にpathで書くのがちょっとめんどくさかった。これが解消されるので便利。
  • イマイチポイント
    • cargo-add とかのツールのサポート(後述)

Workspace: cargo-(add|outdated)

それぞれ依存crate管理に関わるツール。ツール単体に気になるところはなく、普通に便利だった。

workspace dependencies を書くうえではコツがいりそうなので、ポイントに絞って書く。

cargo-add

端的に言うとworkspaceはサポートしていない。

issueはここにある: https://github.com/rust-lang/cargo/issues/10608

workspace dependenciesには自分で書くとして、そのヒントとなる情報は --dry-run オプションで得るのがちょっと便利だった。

# --package オプションの指定がめんどくさいので、移動しておく。
$ cd crates/main
$ cargo add frunk --dry-run
    Updating crates.io index
      Adding frunk v0.4.2 to dependencies.
             Features:
             + frunk_proc_macros
             + proc-macros
             + std
             + validated
             - serde
warning: aborting add due to dry run

この結果を見ると以下がわかる。Webで調べる場合との比較も書いておく。

  • 最新版は0.4.2っぽい
    • crates.io をみればわかるが、GitHubを見てもわからないことが多い
      • releasesを使ってリリースしているところもあればそうでないところもある。なんなら途中から切り替わったところもある
      • /Cargo.toml に書いてないこともある。Virtual Workspaceを使っていてmulti crateそれぞれpublishされている場合とか
      • デフォルトブランチの内容がリリースの最新とは限らない。大体のリポジトリには、リリースバージョンごとにタグをつけてあるけど、「大体」
    • crates.io はREADMEの幅が狭いので、説明文が読みにくいことがある。 feature flagsはREADMEにあることが多いので、読みにくいと困る
  • default featuresは frunc_proc_macros, proc-macros, std, validated で、optional featuresは serde らしい
    • これもどの Cargo.toml をみたらいいかわかりにくい。みてもわからないことも……
    • そもそもRust全体として、外部から指定するfeatureなのか内部のためにあるfeatureなのかがわからないという話がある。ここは変わらない
      • たとえばこのfrunkなら、実際は validated, proc-macros, serde が指定するもので、残りは依存されるものでおそらく外部から指定するものではない
      • ……が、実は serdeCargo.toml に載ってない。READMEにだけある

cargo-outdated

workspace supportはあるっぽい。

  • w, --workspace
    • なしだと workspace dependenciesをチェックする。というより、rootの Cargo.toml をみるみたい
    • ありだと、workspace memberのdependenciesをチェックする
  • R, --root-deps-only
    • 直接依存しているcrateのみを表示するようになる
    • デフォルトだと依存しているものの依存先も再帰的に辿っていくので、更新対象を確認する用途だとほぼ必須っぽい

上記を整理すると、以下のような使い分けになる。

  • workspace dependenciesを確認するとき: cargo outdated -Rw
  • 各crateのdependenciesを確認するとき: cargo outdated -R

Workspace: スクリプト

進むうちに、いくつかスクリプトが欲しくなった。

  • Rustコード系
    • GraphQLのスキーマを吐きたくなった。async-graphqlはcode firstなので、Rustコードからスキーマを生成する
    • migrateコード。migrate方法は深く追求せずsqlxのを使うことにしたが、sqlx cliをインストールせずにやろうと思うとscriptにする必要があった
    • テスト用のデータ生成。これをmigrateとまぜるときれいにしたいときとか面倒なので、別のbinにしたかった
  • シェルスクリプト
    • 開発用の cargo ... などのコマンド。オプションが多くて思い出すのがめんどくさくなってきた

それぞれ検討した方法は以下の通り。1番目に記載したものが採用したもの。

  • Rustコード系
    • スクリプト用の(multi bin) crateを用意
      • workspace内のコードを再利用したい部分もあったので、crateを追加で作成した
      • migrateだけ独立させた。マイグレーションSQLが別途必要で、どのbinがこれを使うのかを明確にしたかった
      • それ以外はmulti crate binにした。開発用スクリプトなので、binary sizeは気にしなくていい。
      • ここに記載があるように、 src/bin 配下に置いたファイルにmainを置けば新しいbinにできる
      • 実行は cargo run --bin $BIN_NAME のようにする。workspace rootからやるなら -package scripts を追加する
    • rust-script を使う
      • crateを作らなくても、 [rust-script](https://github.com/fornwall/rust-script) で個別のスクリプトを実行できる。
      • 個別にdependenciesを定義できる良さがあるが、これは rust-analyzer が効かない
        • 何か工夫すればできる可能性があるが、知らない&そのための工夫が必要なプロジェクトではない
      • scriptにしても結局workspace内のcrateに依存させるので、workspace内にcrate作ってもあまり変わらないなと思った
  • シェルスクリプト
    • READMEに書く
      • 特に説明することはない……READMEに1行説明とともに列挙するだけ
    • cargo-make にまとめる
      • cargo-make にまとめると cargo make migrate みたくすることができる
      • CIとかを見据えると、1箇所で管理できて名前付きにできるこの方法のほうがリッチだけど、 Makefile.toml を書くのがやや面倒
      • 多機能ゆえに機能を把握していないと触りにくい(性格の問題)

Workspace: default members

前述のように、bin crateを追加したことで実行時引数が増えた。

  • before: cargo run
  • after: cargo --project main run

ちょっと増えたぐらいだから別にいいんだけど、 default-members に指定することで引数不要にできるのでそうした。

[workspace]
members = [ 'crates/*' ]
default-members = [ 'crates/main' ]

cargo-watch

GraphQLサーバーのコードを更新するたびに再ビルドさせて実行させるのに、 cargo-watch が便利だった。

cargo watch -x run で実行できる。

  • watch はデフォルトで check をするので、 x で実行内容を指定する
  • default-members でないものは -package などを指定する必要があるが、 x 'check --package scripts' のようにquoteが必要
  • GitHub を見たら、 cargo watch -- cargo check --package scripts のようにもできるらしい

cargo-expand

マクロをそれなりに使うときは、 cargo-expand を使うとコンパイルエラー調査が便利である。 今回だと、async-trait, tokio, async-graphql, shaku, sqlxあたりがマクロをよく使う。

cargo-expand はデフォルトだと全部を展開しようとする。itemの指定ができるので、以下のように使うと便利。

# 該当のcrate rootまで移動しておく
cd crates/infrastructure-rdb

# mod単位でexpand
cargo expand query::card
  • 本当にstruct単位でexpandしようとすると、implが表示されないなど情報不足なことがある
    • かといってmoduleだと多いなーという場合は確認用にsubmoduleに切り出すといいかもしれない
  • ただ、期待しすぎない方がいい。普通にコードを読むより多くの知識を要求されることが多いので、ある程度「わからんものはわからん」という気持ちでいないとつらくなる
    • 見ても分からなければ強い人に聞いて、また強くなったら帰ってこよう

Rust utilityライブラリ

あまり書くことはないけど使ったもの。

itertools

VecをgroupingしてHashMapにするなどのときに便利だった。 でもびっくりするぐらいメソッドがあるので、iterator関連のほしいメソッドはググるのが賢明。

futures-util

future関連のメソッドが追加されていて便利。 どうしてもiteratorの要素ごとにFutureが生成されてしまったので、 join_all を使った。

GraphQL

GraphiQL UI

GraphiQLというGraphQLのUIがあり、 async-graphql でもすぐ利用できるようになっている。 cargo watch -x run で起動し、8000ポートにマップした。 GraphQLの要素が補完され、ドキュメントも出るのでクエリ組み立てに便利。

watch でサーバーが再起動しても、UIは残るので使いやすかった。 サーバー再起動時にはschemaを取り直さないので、 Refresh GraphQL schema ボタンを押すことで補完されるようになる。

dataloaderと相互参照

dataloaderを実装した(というより、schemaのresolverを分けた)ことで、相互参照が実現できた。

{
    usersAll {
        id
        name
        # userがオーナーのボードを参照
        ownedBoards {
            id
            title
            # ボードのオーナーを参照
            owner {
                id
                name
            }
        }
    }
    boardsAll {
        id
        title
        # ボードのオーナーを参照
        owner {
            id
            name
        }
    }
}

schemaのresolverを分けたりしなくても相互参照はできる。 しかし、dataloaderの実装に任せることで、実装をシンプルに保つことができた。

async-graphql

GraphQL実装はRust製のasync-graphqlを使った。一通りの機能があり、ドキュメントもあるのであまり困らず使えた。

Schema生成

ここにある通りに書いた。

実装上の考慮事項として、Schema型にはdataやextentionsなどのデータを紐づけているが、これはスキーマ生成だけを考えると不要である。そのため、以下のように考慮しておく(と楽)。

  • schema生成時はdataやextentionsを紐付けない
  • アプリとschema生成で同じbuildを通るように共通化

通化にあたって、SchemaやBuilderの型引数にquery, mutation, subscriptionがあって煩雑になったので、 type キーワードでエイリアスを作って対策した。

// 定義
type SchemaType = Schema<Query, EmptyMutation, EmptySubscription>;
type SchemaBuilderType = SchemaBuilder<Query, EmptyMutation, EmptySubscription>;

// 利用
fn schema_builder() -> SchemaBuilderType {
    Schema::build(Query, EmptyMutation, EmptySubscription)
}

fn schema() -> SchemaType {
    schema_builder().finish()
}

fn schema_with<F>(f: F) -> SchemaType
where
    F: FnOnce(SchemaBuilderType) -> SchemaBuilderType,
{
    f(schema_builder()).finish()
}

また、rust-scriptだと補完が効かなくて作りにくかったのでscripts crateにまとめた。

dataloader実装

dataloaderの実装方法自体はドキュメントにコードで記載があり、それをなぞった。以下が私にとってのポイントだった。

  • Trait実装として提供するので、同じキーのdataloaderを複数個同じ型に実装できない
    • たとえば Id<User> をキーにユーザー一覧をとりたいときとボード一覧をとりたいときとか
    • →未確認だが、 UserIdForBoard のようなstructにラップして利用すれば良さそう?もしくはdataloaderを分ける?
      • そもそもdataloaderを複数にするべきかどうかがわかっていない。一つでいいような気がする。
  • dataloaderはadapter層で、実行するクエリはquery層で定義している。そのため、IDの型があわない。せっかく keys: &[K] でもらっても変換するのでVecにしている。
    • →仕方ないと思って諦めた
    • ちなみに、返却時も再変換が必要
  • 戻り値が HashMap<K, V> であるので、この形に変換が必要
    • →あきらめてquery層をこちらに寄せてHashMapを返すようにした

Data 取得

async-graphqlは context から参照できるデータを持つ。このデータは格納時に Any にして、取り出すときにダウンキャストしている。

let modules: &Modules = context.data()?;
// or
let modules = context.data::<&Modules>()?;

そのままでも使えるが、型注釈が必須な点がやや面倒である。

個人的には前者のほうが読みやすいが、続けてメソッドを呼び出したい場合などでは後者の書き方になり、このとき型注釈を忘れるとエラーになってしまったり、 ::<> のように装飾が多かったりとあまり好きではない。

以下のようにcontextを拡張するメソッドをつくって、型注釈を不要にした。メソッド名から何が返るのかもわかりやすく、実装コストに対して効果的な印象。

// ContextにDataLoader, Modulesを取得するメソッドを作成する
// dataはAnyで型消去しているけど、このプロジェクト内ではSchemaを作っている箇所で必ず設定しているはずなので気にしないことにする
pub trait ContextExt {
    fn data_loader(&self) -> Result<&DataLoader<Modules>, GqlError>;
    fn modules(&self) -> Result<&Modules, GqlError> {
        Ok(self.data_loader()?.loader())
    }
}

impl<'ctx> ContextExt for Context<'ctx> {
    fn data_loader(&self) -> Result<&DataLoader<Modules>, GqlError> {
        self.data()
    }
}

sqlx

マクロ

sqlxはマクロで「コンパイル時にSQL結果の型をチェック」できる。

これの利用に向けてはちょっとコツがいる。

  • コンパイル時に参照できる場所にテーブルが存在するDBを立てる必要がある
    • dockerを使って立てるのが楽
      • docker内にテーブルを用意するのは、sqlxのmigrate機能等を使うといい
    • offlineモードというのもあるが、試していない
  • 環境変数で参照するDBを指定する必要がある
    • コマンドが長くなるので、スクリプトにまとめた方がいい
  • query!() マクロの場合)コンパイル時に型チェック=コンパイルしないと型が決まらない
    • rust-analyzerが補完してくれない
    • クエリを書いたあと、後述処理を書くまえにコードを保存(ないしは再コンパイル)するといい
  • コンパイル時にSQLでエラーになったとき、エラー箇所がわからない
    • Rustのコンパイルエラーメッセージにエラー内容が記載されていないことがある
    • dockerのPostgres(コンパイル時の接続先)のstdoutを見るのがいい
      • ただし、prepared statementのエラーかつ改行なしで出ていて、どこが間違っているのかすぐにわからないこともあった

感想としては、「すごいが、コンパイル時チェックなしの関数でもいいかな……」という感じだった。理由は以下。

  • クエリがstatic文字列でなくてはいけない
    • 動的に where 句のパターンを書き換えたいときにつらい
    • 似たような or 同じクエリを使い回せない
      • query_file!() マクロがあれば完全に同じクエリならいける
  • (前述)クエリ結果を束縛した状態でsaveしないとrust-analyzerが補完してくれない
  • 安全性は(テストをちゃんと書くなら)関数で書いてもあまり変わらない

poolとtransaction

sqlxの(postgres)クエリを実行するとき、 PgPoolTransaction<'a, Postgres> が必要になる。それぞれの使い方は以下の通り。

  • pool: &PgPool の場合
    • .execute(&pool) とする
  • mut transaction: Transaction<'a, Postgres> の場合
    • .execute(&mut transaction) とする
  • transaction: &mut Transaction<'a, Postgres> の場合
    • .execute(&mut **transaction) とする
    • 特にこれを忘れやすい
    • 関数にクエリをまとめていると引数に登場しがち

特にC言語の経験からすると3番目は謎が深いが、とりあえず覚えることにする。

derefして &Connection に、更にderefして '&mut を取っている感じで認識している。

migrate

sqlxがCLIを提供していて、それを通してmigrateした。

migrateファイルの作成も適用もCLIで問題なく行えた。

shaku: Providerのエラー型をasync-graphqlに合わせる

RustのDIライブラリ。async-graphqlのcontextと組み合わせて、以下のようにしてクエリを取得したい。

let user_query: Box<dyn UsersQuery> = context.modules()?.query().provide()?;

これは工夫しなくても実現できる……が、エラー型がちょっと扱いにくい。最終的には async_graphql::Error 型にしたいが、 shaku::HasProvider::provide() が返すエラーは Box<dyn std::error::Error> であり async_graphql::Error 型に変換できない。

そこで、この変換だけをラップするためのメソッドをtraitで拡張して作成した。

// shakuのErrorをasync-graphqlにあわせる
pub trait HasProviderGql<I: ?Sized>: HasProvider<I> {
    fn provide_gql_result(&self) -> Result<Box<I>, GqlError>;
}

impl<T, I: ?Sized> HasProviderGql<I> for T
where
    T: HasProvider<I> + ?Sized,
{
    fn provide_gql_result(&self) -> Result<Box<I>, GqlError> {
        self.provide().map_err(|e| {
            GqlError::new(format!(
                "providing <{}> failed: {}",
                type_name::<I>(),
                e
            ))
        })
    }
}

これを使うと、GraphQL クエリメソッド内でも ? でエラー型を変換できる。

開発体験: ChatGPT

使い込んでいないので3.5での結果。

ChatGPTにやらせたこと(1): ドメインモデル図を描かせる

ありきたりなkanbanアプリのドメインモデル図だったので、ChatGPTに書かせた。

Kanbanアプリケーションをドメイン駆動設計で作成します。ドメインモデル図をmermaid形式で示してください。

指示としては以上で、それ以降は「集約を小さくしてください」「操作を追加してください」などを追加指示した。

結果として取得できたコードは以下で、いくつか間違っている箇所がある。手動で直せばいい範囲なので、ChatGPTに再修正させず手で直した。

classDiagram
    class User {
        <<Entity>>
        +userId: int
        +name: string
        +email: string
        +boards: Board[]
    }

    class Board {
        <<Aggregate>>
        +boardId: int
        +title: string
        +owner: User
        +members: User[]
        +columns: Column[]
        +addColumn(title: string): Column
        +removeColumn(columnId: int): void
    }

    class Column {
        <<Aggregate>>
        +columnId: int
        +title: string
        +tasks: Task[]
        %% --------間違った結果の箇所: addColumnではなくaddTaskっぽい。--------
        +addColumn(taskTitle: string, taskDescription: string): Task
        +removeTask(taskId: int): void
    }

    class Task {
        <<Entity>>
        +taskId: int
        +title: string
        +description: string
        +status: string
    }

    User --> Board : owns
    User --> Board : member of
    Board --> Column : has
    Column --> Task : contains

ChatGPTにやらせたこと(2): レイヤー構造を描かせる

同じ理屈で、オニオンアーキテクチャのレイヤー構造を図にしてもらった。

mermaidの図の書き方を思い出すより早くできた。

ChatGPTにやらせたこと(3): 典型的な docker-compose.yml を書かせる

RDBを構築したくなったタイミングでChatGPTに docker-compose.yml も書かせてみた。

今回は典型的な(後で使うつもりのlocalstackと)Postgresを立てるだけだったので、ほとんどそのまま出力できた。

注意点は以下。

  • localstackは古いバージョンでのみ利用できる環境変数があったので除去した
  • ポート番号はそのままで割り当てられるので、それを 60000 から振るように追加指示した

ChatGPTにやらせたこと(4): RDBテーブル定義を作らせる

RDBのテーブル定義を作るのが面倒だったので、吐き出させた。

何を入力にしたかというと、GraphQL Schemaを入力にした。

以下graphqlのデータをPosgresで構築します。テーブル定義のDDLを示してください。

追加注文したのは以下の通り。

  • IdVARCHAR 型にしてください
  • テーブル間の関連を別テーブルに切り分けてください
    • これは2回くらい注文した。
  • テーブル名をsnake_caseに変更してください
  • テーブル名を複数形にしてください

ChatGPTにやらせたこと(5): Rustコードをサンプルデータ投入SQLに変換させる

infrastructure層の実装が終わって、RDBと接続して確認したかったが、データがない。

仮実装の段階でRustコードでデータをメモリ上に作る実装があったので、これを入力にINSERT文を吐かせてみたら、思ったより精度良く吐かせることができた。

以下Rustコードで作成するのと同等のデータをPostgreSQL DBに用意するINSERT文を示してください。
[Rustコード...]

なお、テーブル定義は以下のとおりです。
[テーブル定義...]

特に、配列参照を使ってIDがおなじになるようにしていた箇所が全体的に正しく解決されていたので驚いた。

気になっていること

frunkで変換が楽になる?

レイヤーの関係で変換が必要な presentation_graphql::scalar::Id<T>domain_util::Identifier<T> とか、クエリの返却値とgraphql responseとか、細かい変換をいくつも書いた。

frunkという関数型プログラミング系のライブラリがあって、この中のGenericという機能がこの辺りの変換をサポートしてくれていそう。活用したら楽になりそう。

infrastructureのテスト

互いのテストが影響しないようにするために、インメモリDBを使いたいが、sqlxでそのあたりうまくできるのかを調査する必要がある。

認証とか

まだアプリケーション内での認証処理とかについて調べていないので確認する必要がある。

coc-metals がなくなってもなんとかcocで生きている

経緯

しばらく前から、Scalaを書く機会が訪れた。NeoVimのLanguage Clientプラグインとしてはcocを使っているので、Scalaでもcoc-metalsを入れて使うことにした。

github.com

https://github.com/scalameta/coc-metals

2022年3月に coc-metalsの開発終了が宣言された 。

github.com

なんとかしなきゃなと思いつつも、ひとまず現在のプロジェクトでは問題なく動いていたので、そのまま使い続けていた。

2022年10月、現在のプロジェクトのScalaバージョンアップに伴って、ついにいくつかの機能が動かなくなってしまった。具体的には補完と定義ジャンプあたりができなくなった。

試行錯誤

補完と定義ジャンプくらいは動かないと開発しづらいので、これらをできるように色々試した。

試行錯誤の方向性

coc-metals のREADMEでは「 nvim-metals でNeoVim組み込みのLanguage Clientを変わりに使おう」と書いてある。しかし、他のcocプラグインなどもあったり一度慣れた操作を全て捨てなくてはいけなかったりで、これを理由に乗り換えるのは現実的ではないなと思った。

よって、「coc上でmetalsを使う方法」を模索することにした。

公式の推奨は手動インストール(だが、なるべく使いたくない……)

とりあえずcocとmetalsの公式を見てみた。coc向けのmetalsの導入方法はどちらも手動でのインストールを推奨していた。

github.com

scalameta.org

しかし、手動でのインストールは腰が引けた。私の開発環境は複数あって、そのうち特定の環境でしかScalaを利用しないし、ビルドツールの導入から必要なようだったので単純にめんどくさいなと思った。これまで coc-metals にmetalsの管理を任せていたので、余計に手間だと感じたのかもしれない。

いったんmetalsをそのまま使える方向を模索しようと思った。

coc-metals が参照するmetalsのバージョンアップ

正直今までREADMEに従ってただ導入していただけだったので、なぜ動かなくなったかの調査から始めた。metals起動時にバージョンに関する警告が出ており、ここに 0.11.2 を利用していると書かれていた。調査時点でリリースされているのは 0.11.9 まであったが、 coc-metals の最新版が参照しているのは 0.11.2 であるようだ。そこでこのバージョンを上げれば直るのではと考えた。

coc-metals のREDMEに設定項目について記載されており、 metals.serverVersion0.11.9 などを入れれば良さそうだ。

github.com

設定してみたところ、metalsのダウンロードができなくなり、起動に失敗するようになってしまった。

coc-metalsmetals-languageclient0.4.2 を利用している。metalsのダウンロードも metals-languageclient の機能だ。そして metals-languageclient@0.4.2 では、metalsをダウンロードするScalaのバージョンは 2.12 に固定されていた。

github.com

metalsのリリースノートを見ると、metalsの 0.11.3 からビルドするScalaのバージョンが 2.13 になったっぽい。なので org.scalameta:metals_2.12:0.11.9 をダウンロード出来ずに失敗してしまうらしい。

coc-metals の依存する metals-languageclient のバージョンアップ

それならということで、 coc-metals の依存する metals-languageclient のバージョンアップをやってみることにした。いわゆるモンキーパッチだ。

coc-metals はこれまで :CocInstall でインストールしていた。これはnpmから取得しているので、これにパッチを当てるのは無理だ。cocのドキュメントを見ると、gitでのインストール方法が書いてあった(ただし、依存ライブラリをビルドする関係でディスクスペースを多く使うので非推奨らしい)。

github.com

そこで、以下のようにとりあえず coc-metals をcloneしてNeoVimで読み込めるようにした( yarn は適当に準備)。

# 適当なディレクトリ
cd /path/to/directory
git clone https://github.com/scalameta/coc-metals.git
cd coc-metals
yarn install

# NeoVimの設定ファイルにて
:set runtimepath^=/path/to/directory/coc-metals
# 読み込めると以下コマンドの結果に `coc-metals` が増える
:echo map(CocAction('extensionStats'), {k, v -> v['id']})

インストールは出来たので、 yarn upgrade --latest metals-languageclient にて更新した。その結果、 yarn build が通らなくなってしまった。インターフェースが色々変わったようだ……

metals-languageclient の最新は 0.5.17 だったので、バージョン飛ばし過ぎかなと思って 0.5.1 (0.5系の最初)まで戻したのだけど、ビルドできない状況は変わらなかった。これまでコントリビュートしてこれなかった身としてはエラーをすぐに解決することは無理そうだった。ついでに、これに関するマイグレートイシューが立っていて、すでに試されていることであると知った。

github.com

metalsを手動インストールして使う

ここまできて coc-metals を使い続けるのは無理だなと判断した。そのため、metalsを手動で入れて利用する方向にかじを切った。metalsの手動インストール方法については、ドキュメントに載っていたため、これを参考にビルドしていった。

scalameta.org

Coursierをインストール

私の環境にはJavaは導入済みだったので、あまりそれについては考えずに、未インストールのCoursierからインストールした。

get-coursier.io

上記Coursierのドキュメントに従って、

brew install coursier/formulas/coursier

とした。すでに sbt 等は sbtenv などで用意していたため、 cs setup は行わなかった。

metalsのビルド

前掲のmetalsのドキュメントに従って、以下のようにした。オプションは吟味していない。

cs bootstrap \
    --java-opt -Xss4m \
    --java-opt -Xms100m \
    --java-opt -Dmetals.client=coc.nvim \
    org.scalameta:metals_2.13:0.11.9 \
    -r bintray:scalacenter/releases \
    -r sonatype:snapshots \
    -o ~/bin/metals-vim -f

変更点は以下の通り。

  • coursier コマンドを適宜インストールして使うのではなく、前節で brew install したものを使う
    • これによりCoursierは cs で起動する
  • 出力先を /usr/local/bin ではなく、 ~/bin にする

-------- (2023/07/20 追記) --------

metals.client はnvim-lsp以外にも設定可能な値があるようだった。以下を参考に coc.nvim にした。設定値が変わる模様。 github.com


パスを通す

前節で ~/bin へ配置したが、普段自分でビルドしないので、ここにはパスが通っていなかった。私はzshなので、 ~/.zprofile に以下を追記した。

[ -d ~/bin ] && PATH="$HOME/bin:$PATH"

cocと連携させる

coc-nvimwikiに設定があるので、そのまま coc-settings.json へコピペした。

github.com

"languageserver": {
  "metals": {
    "command": "metals-vim",
    "rootPatterns": ["build.sbt"],
    "filetypes": ["scala", "sbt"]
  }
}

動作確認

ここまでの設定でNeoVimでScalaファイルを開き動作確認をした。

  • できたこと
    • ファイル内のdocumentHover
  • できなかったこと
    • ファイルをまたぐdocumentHover
    • 定義ジャンプ
    • 補完
    • coc-metals にあった metals.build-import などのコマンド

感触としては、動いてはいるけど十分ではない、という感じだ。とはいえ、スタートラインには立ててはいるようだ(ちなみに、この段階だと「この先試行錯誤してもだめ」な可能性はあって、戦々恐々としながら進めていった……)。

まともに動くようにする

追加の動作確認

動いている気はするものの裏付けがなかったので、これを確認した。coc-nvimwikiにDebug language serverという節があった。これを参考に試していった。

github.com

まず、 :CocList services を見てみたところ、 languageserver.metals[running] となっていた。問題なく起動は出来ているようだ。

次に、metalsのログを見てみた。 :CocCommand workspace.showOutput から、 languageserver.metals を選ぶ……が、ほとんどログが出ていなかった。タイミングによっては(ログがないからか)選ぶことすらできない。

ドキュメントを見直すと、 "trace.server": "verbose" を追加するとLSP通信の内容が全部出るようだったので、これを追加してやり直したところ、詳細なログを得ることができた。

ファイル内のdocumentHoverは思ったとおりうまくいっているらしい。

(ファイル内のdocumentHover)
[Trace - 1:50:37] Sending request 'textDocument/hover - (4)'.
Params: {
    "textDocument": {
        "uri": "(省略)"
    },
    "position": {
        "line": 10,
        "character": 25
    }
}

[Trace - 1:50:38] Received response 'textDocument/hover - (4)' in 485ms.
Result: {
    "contents": {
        "kind": "markdown",
        "value": "(省略)"
    }
}

ファイルをまたぐdocumentHoverは思ったとおり何も返ってきていなかった。

(ファイルをまたぐdocumentHover)
[Trace - 1:52:43] Sending request 'textDocument/hover - (5)'.
Params: {
    "textDocument": {
        "uri": "(省略)"
    },
    "position": {
        "line": 14,
        "character": 23
    }
}

[Trace - 1:52:43] Received response 'textDocument/hover - (5)' in 14ms.
No result returned.

ところで、ここまでで出たログを眺めていて、 coc-metals を使っていたときには出ていたプロジェクトのビルドのログがないことに気がついた。 coc-metals はログをechoする仕組みだったのに対して、cocから直接languageserverを使った場合はechoされない。このことは知っていたので、ビルドのログが無いことに疑問を持たなかったのだ。

metalsが有効になった後に build-import をかける

ビルドするために何を行えばいいかはおよそ見当がついていて、metalsの調子悪いときに :CocCommand の中から実行していた build-import をすれば良さそうだろうと考えていた。しかし、今までは coc-metals:CocCommand に追加してくれていたので、 coc-metals を使わなくなった今どうやって実行すれば良いのかが分からなかった。

とりあえず coc-metals のコードからなんとなく command などで検索してみると、起動時にコマンド登録しているっぽいコード が見つかった。このコマンド群の定義は metals-languageclientServerCommands に定義されていた。

github.com

github.com

実際に叩いているコマンドの文字列はわかった。次はコマンドの実行方法だ。先の metals-languageclient のところにserver commandsと書いてある。関係がありそうなものをcocのhelpから探していると、 :call CocAction("runCommand", "name") とすることでコマンドが実行できるらしい。

試しに :call CocAction("runCommand", "build-import") を実行したところ、30秒ぐらい制御を取られ、何かが実行された。その後、documentHoverの情報を改めて確認すると以下の変化があった。

フイル内のdocumentHoverは元々出ていたが、 range が追加された。

(ファイル内のdocumentHover)
[Trace - 2:07:21] Sending request 'textDocument/hover - (11)'.
Params: {
    "textDocument": {
        "uri": "(省略)"
    },
    "position": {
        "line": 10,
        "character": 20
    }
}

[Trace - 2:07:21] Received response 'textDocument/hover - (11)' in 99ms.
Result: {
    "contents": {
        "kind": "markdown",
        "value": "(省略)"
    },
    "range": {
        "start": {
            "line": 10,
            "character": 20
        },
        "end": {
            "line": 10,
            "character": 29
        }
    }
}

また、ファイルをまたぐdocumentHoverの情報も以下のように出るようになった。

(ファイルをまたぐdocumentHover)
[Trace - 2:09:35] Sending request 'textDocument/hover - (12)'.
Params: {
    "textDocument": {
        "uri": "(省略)"
    },
    "position": {
        "line": 14,
        "character": 21
    }
}

[Trace - 2:09:35] Received response 'textDocument/hover - (12)' in 22ms.
Result: {
    "contents": {
        "kind": "markdown",
        "value": "(省略)"
    },
    "range": {
        "start": {
            "line": 14,
            "character": 21
        },
        "end": {
            "line": 14,
            "character": 24
        }
    }
}

これで一旦の目的を果たした。しかし、使いにくい点があるのでもう少し改善したいと思う。

cocのコマンドを非同期に実行する

直前のコマンド( :call CocAction(...) )は、完了するまで制御を返さない。 build-import はプロジェクトサイズによっては結構時間がかかるので、長い時間NeoVimが操作を受け付けなくなる。これは非常に使いにくい。

この解は簡単で、 :call CocActionAsync(...) という非同期版を使えば良い。この2つの関数は、アクションの実行結果の扱いの違いがある。

  • 同期版: 戻り値で返す(例: :echo CocAction("commands") などを叩いてみると結果が表示される)
  • 非同期版: 引数にcallbackを与えて扱う( call CocAction("commands", {err, result -> ...}) みたくするらしいが試してない……ちなみに非同期なのでechoしても当然何も表示されない)

cocのコマンドにmetalsのコマンドを追加する

:call CocActionAsync("runCommand", "build-import") を打てばいいことはわかったが、毎回打つにはつらすぎる。

NeoVimなので、必要なカスタマイズは自分ですればいい。キーバインドやコマンドを定義するのが普通だが、今まで :CocCommand から呼び出していたので、それと同じ操作感にしたい。

cocのhelpを見ると、 coc#add_command() という関数があった。これを使うと CocCommand で呼び出せるコマンドを追加できるようだ。これを使って coc#add_command("metals.build-import", ":call CocActionAsync('runCommand', 'metals.build-import')") とすることで、 :CocCommand の一覧に表示されるようになった。

うまくいくことがわかったので、サーバーコマンドとして一括で登録する define_metals_commands というlua関数を書いた。

local add_coc_command = function(name, command, description)
  vim.fn["coc#add_command"](name, command, description)
end

local define_metals_commands = function()
  local commands = {
    "ammonite-start",
    "ammonite-stop",
    "analyze-stacktrace",
    "bsp-switch",
    "build-connect",
    "build-disconnect",
    "build-import",
    "build-restart",
    "compile-cancel",
    "compile-cascade",
    "compile-clean",
    "copy-worksheet-output",
    "debug-adapter-start",
    "doctor-run",
    "file-decode",
    "list-build-targets",
    "generate-bsp-config",
    "goto",
    "goto-position",
    "goto-super-method",
    "new-scala-file",
    "new-java-file",
    "new-scala-project",
    "reset-choice",
    "scala-cli-start",
    "scala-cli-stop",
    "sources-scan",
    "super-method-hierarchy",
  }

  for _, v in pairs(commands) do
    local name = "metals." .. v
    local action = ":call CocActionAsync('runCommand', '" .. v .. "')"
    add_coc_command(name, action)
  end
end

これで一通りのコマンドを追加することが可能だ。私は全部使っているわけではないが、一旦全部登録したままにしている。コマンド名に metals. とつけているので、他のものと競合することはないはずだ。

metalsが実行可能なときだけcocに登録する

ここまでで一応使える状況にはなったが、別環境のことを考慮していなかった。 metals-vim がPATHにない状態でScalaファイルを開くとcocがエラーを出す。

前述のように coc-settings.json に書いていると、条件分岐を行う方法がない。ドキュメントを眺めると coc#config() という関数でも coc-settings.json と同等のことが行えるらしい。これなら、コードからの呼び出しなので分岐は自由だ。

coc-settings.json をやめてluaで書く

coc#config() を使っていこうと思ったのだが、 coc-settings.json との併用は非推奨らしい。

仕方がないので、全てをluaで置き換えた。文法は大きく違わないので、以下に注意してluaのtableに置き換え、登録していった。

  • keyの扱い
    • JSONではキーはダブルクォートで囲む必要がある
    • luaではキーは以下のようになる
      • 特殊文字を含まないなら識別子のようにベタ書き
      • 特殊文字を含むなら [”foo.bar”] のように囲む
        • cocの設定には一部ドットを含むものがあるので、私は統一の意味も込めて全てこちらで書くことにした
  • keyとvalueの区切り文字
  • 配列の記法
    • JSONでは []
    • luaでは {} (キーなしのtableとして扱う)
  • 配列の先頭INDEX

ちなみにこのあたりの書き方はいつもluaのマニュアルの(非公式)日本語訳を見ている。

milkpot.sakura.ne.jp

ただし、1箇所読んだだけだとわかりにくかったり、そもそもどこに書いてあるのか目次からわからなかったりする。 たとえば、前述のテーブルの記法の件は「3.4.9 - テーブルコンストラクタ」にある。変数や型の節ではない。3章までよめば文法はわかるので、NeoVimでluaを使うならここまで全部読んでしまってもいいかもしれない。文法以外の関数(たとえば for でテーブルを走査するときによく使う pairs() )などは、ググった方が早い。ググっても載ってない細かい仕様が必要な場合だけドキュメントに戻ってくるので十分だ。

github.com

NeoVim側のluaAPIについてはこのドキュメントを見ている。あとは coc-sumneko-lua の補完に頼っている。更に情報がほしいときだけ、 :h nvim_echo() などで api.txt を読んでいる。関数の存在を和訳ドキュメントで見て、細かいオプションはhelpを見るのが丁度いいかなと思う。

luaに置き換えると、例えば先に示したmetalsの設定の場合は以下のようになる。

local languageserver_config = {}
languageserver_config.metals = {
  ["command"] = "metals-vim",
  ["rootPatterns"] = { "build.sbt" },
  ["filetypes"] = { "scala", "sbt" },
}
-- 実際に呼んでいるのは coc#config("languageserver", languageserver_config)
My.PutCocConfig("languageserver", languageserver_config)

これなら分岐でもなんでも仕込み放題だ。ついでにコメントも書ける。

metalsの登録を分岐する

前節のような感じで置き換えが出来たので、 metals-vim の実行ファイルがある時だけ上記の部分のコードを追加したい。

まずは実行可能ファイルがあるかのチェックをする。これはNeoVimの関数を利用するのが楽っぽいので、以下関数を定義した。ちなみに My というのは、変数や自作関数を作ったときに置いておくグローバル変数として自分で定義したものだ。

local function has_executable(name)
  return vim.fn.executable(name) == 1
end
My.has_executable = has_executable

これを使って、

if My.has_executable("metals-vim") then
  -- 前述のコード
end

とした。これでいい感じだ。

ここまででやっと使える域に達したと感じることができた。

できていないこと

とりあえず動くようになったが、出来ていないこともありそのうちなんとかしたい気持ちはあるので書き残しておく。

metals起動時まで CocCommand への登録を遅らせる

上記の方法だと、metalsがないときには CocCommand へ登録されないが、あるときにはcoc起動時に登録される。今まで coc-metals ではサービス起動時(≒Scalaファイルを開いたとき)に登録されていたので、そちらに近づけたいと思った。

調べた限りでは、サービスステータスの変更に伴ってCallbackが呼ばれるような仕組みが見当たらず、断念した。

以下が似た投稿で、 user CocNvimInit イベントを使っていたが、このイベントはcocの起動時に呼ばれるため、coc起動後にサービスが立ち上がった場合(私は普段そうなることが多い)に対応できなかった。

github.com

まぁ、コマンドが一覧に出るだけで他のコマンドを叩きたいときはたいてい絞り込んで使うため、あまり邪魔にはなっていないのでよしとした。

metals開始時に、 build-import コマンドを実行する

これも上記と同様の理由でトリガーできないため断念した。一旦手動で叩く手間を軽減したので、しばらくは様子見することにした。

metalsを自動でインストールする

頑張れば書ける……が、インストールする頻度が低いし、設定ファイルにインストールスクリプトが載っていればとりあえずはOKということにした。

以下が面倒だった。

  • インストール先
    • NeoVimだけが読める場所とかでもいいのだけれど、私はこれにあまり詳しくない
    • ホームディレクトリ以下に置く場合、PATHを通してあるディレクトリが何で、存在するのかどうかなどがあって手間&シェルの設定まで変える元気がなかった
  • インストール方法
    • coursier なのか cs なのか、そもそもあるのかどうか
    • ないとパッケージマネージャーでいれるかそれ以外か、使い捨てかどうか……など

metalsの準備ができたかどうかわからない

metals(に限らないけどLanguageServer)は起動にちょっと時間がかかる。私の環境では1〜2分くらい。 その間にcocの機能でdocumentHoverすると Hover Not Found になってしまう。 終わったかどうかのチェックは CocAction workspace.showOutputlanguageserver.metals を目視するか、「そろそろ大丈夫だろう」という勘でやっている。目視の方は、別のウィンドウが開かれるので鬱陶しいし、勘は間違っていることが度々ある。

lightlineを使っているので、ここに表示したいが、このあたりを調べきってない。今は基本勘で、ダメそうだったら showOutput 出しながら目視するくらいにしている。

build-import しても別のファイルを開くとdocumentHoverできなかったりする

要するに完璧に動くわけじゃないのだけれど、症状を言語化できていない。今動く範囲でコーディングが可能なので放置している。

コマンドリストを手動で作っている

このあたりからどうでもいい話になってくる。

CocCommand に登録するコマンドの一覧を、何かと連携して作っているわけではなく、参考リポジトリからコピペして作っている。これだとコマンドが増えたときに気づけない。

ただ、そんなに新しいコマンドが必要なこともなさそうだし、追加するにしても配列に1行足す程度なので手間じゃない。そういうわけで放置することにした。

コマンド名にvimがついている

本当に軽微な話だけど、 coc#add_command("metals.build-import", ...) としているのに、コマンド名は vim.metals.build-import となる。

見た目が気になるだけだし直し方が分からないので放置している。

感想

なんとか開発できるくらいにはなった

全部完璧にできたわけじゃないが、コードを読んだり書いたりはできるようになった。また気が向いたら調整すると思うけど、一段落できて安心した。

フリーライディングと向き合った

一番精神的に大きな話。

coc-metals の開発終了は少し前から知っていたけれど、その時はまだ動いていたのもあって、メンテナには手を挙げなかった(挙げられなかった)。今回、実際動かなくなってから色々調べたりパッチを当てようとしたりして、最終的にメンテナに手を挙げることができなかった。

metals-languageclient についての知識不足でコードの修正方法が分からなかったのもある。英語のReading以外がとても苦手というのもある。だが自分の中で一番大きかったのは「Scalaを専門で書いているわけではないので時間を割くのが難しい」と思ってしまった点だ。

そう思ったときに「やっぱり自分もフリーライディングしてるし望んでるんだな」と気づいた。誰かがメンテしてくれる安定した無料のソフトウェアを使いたいだけだった。

これは結構へこんだし、しばらく向き合い方に悩んだが、今私にできる貢献としてはこういうブログを書いて同様に困る人を減らすことくらいだろうということに落ち着いた(あとはシンプルにお金を払うとかだろうか……これも追って検討したい)。英語がもうちょっと書けたら、cocにサービスステータス反応でアクションを仕込む方法を聞くか要望上げるかできるんだけど……

LSPはサーバー起動コマンドを設定するだけで使える……とはいかない

どこかで「LSPはプロトコルが整理されているので、クライアントではサーバーの参照方法だけ設定に書けば使えるしプラグイン入れるのは無駄!」みたいな論を見た気がするのだけれど(掘り起こせなかった……)、少なくとも今回はこうはならなかった。

今回で言えば、以下のことをしなければいけなかった。ただしmetals以外でも同様かどうかは調べてない。

  • サーバーのインストール管理
  • サーバーコマンドを簡単に呼び出せるようにする
  • 特定のタイミングでサーバーコマンドを呼び出す

luaでの設定が書きやすい

今まではほとんどの設定をvimscriptで書いていたが、今回初めて大掛かりな設定をluaで書いたところ、なんとなく書きやすいなと感じた。以下のようなあたりが理由だと思う。

  • トリッキーさがない
    • 関数の引数は a: としないといけないとか
    • augroup END とか
    • コマンドなのか関数なのかとか
    • 大きなハッシュテーブル等を作るときに、行末 \ が不要
  • 構文ノイズが少ない
    • let しなくても変数が書き換えられる
    • call がなくても関数を呼び出せる
  • NeoVimのAPIが使いやすい(少なくとも私が使った範囲では)
  • NeoVimのAPIluaでできないことを補完してくれている

まぁ、そもそもvimscriptはvimコマンドの集合だし、そりゃプログラミング言語で書いたほうが書きやすくて当然である。

気持ちとしては全部luaにしてもいいなぁと思っているが、ちょっとずつ移行していけばいいかなと思った。

おわりに

私のneovim config はこちらだ。

github.com

開発するためにエディタを整えているのか、エディタを整えるために開発しているのか分からなくならない程度にこれからも頑張っていきたい。

RustでAWS Lambda Container Imageを1イメージで複数Functionデプロイできるのか?

この記事は Rust Advent Calendar 2021 19日目の記事です。

はじめに

こちらは、やってみたけどうまく行かなかった記事です。 以下について情報提供をお待ちしています。

  • Lambda Container Imageのローカル実行でメモリ使用量を確認できない?
  • Lambda Container Imageの bootstrap に引数は渡せない?

やりたかったこと

AWS Lambdaがコンテナイメージでの実行をサポートしてしばらくが経ちます。 RustでLambdaのコンテナイメージを作る場合、1つのイメージにつき1つのバイナリが必要そうでした。

Python等でLambda コンテナイメージを作る場合は、1つのイメージに複数のhandlerを含め、CMDで実行するものを変更できました。これと同様のことがRustでもできるのではないでしょうか?

1つのイメージに複数のhandlerを含めたときに気になるのが、バイナリサイズや実行時間への影響です。今回はこれらをローカル実行にて確認してみたいと思います(思っていました)。

実験内容

以下3種類のLambda用バイナリを作ります。

  1. simple-hello: {"message": "Hello, World!"} を返すだけのシンプルなLambda
  2. simple-regex: 入力の date が、文字列で YYYY-MM-DD の形になっているか確認するLambda
  3. cli-mixed: 上記2つのLambda handlerをCLI引数で実行し分けるLambda

これらについて、以下を(ローカルで)確認します。

  • バイナリサイズ
  • イメージサイズ(バイナリサイズの大小の他、ベースイメージとのサイズ比も気になるところかと思います)
  • 初回起動時、2回目以降起動時の実行時間
  • 使用メモリ量

実験

作成したコード

GitHubに配置しました。

github.com

ビルド

以下のようにそれぞれビルドします。

cargo build --release --target x86_64-unknown-linux-musl
docker build -t simple-hello -f Dockerfile-simple-hello .
docker build -t simple-regex -f Dockerfile-simple-regex .
docker build -t cli-mixed -f Dockerfile-cli-mixed .

バイナリサイズ

ls で普通に確認します。

s -lh  target/x86_64-unknown-linux-musl/release/
合計 21M
drwxr-xr-x 15 vraisamis vraisamis 4.0K 12月 18 21:14 build
-rwxr-xr-x  2 vraisamis vraisamis 7.7M 12月 18 21:14 cli-mixed
-rw-r--r--  1 vraisamis vraisamis  162 12月 18 20:50 cli-mixed.d
drwxr-xr-x  2 vraisamis vraisamis  20K 12月 18 21:14 deps
drwxr-xr-x  2 vraisamis vraisamis 4.0K 12月 18 20:49 examples
drwxr-xr-x  2 vraisamis vraisamis 4.0K 12月 18 20:49 incremental
-rwxr-xr-x  2 vraisamis vraisamis 5.6M 12月 18 20:50 simple-hello
-rw-r--r--  1 vraisamis vraisamis  168 12月 18 20:50 simple-hello.d
-rwxr-xr-x  2 vraisamis vraisamis 7.1M 12月 18 20:53 simple-regex
-rw-r--r--  1 vraisamis vraisamis  168 12月 18 20:50 simple-regex.d
  • simple-hello は5.6MBです
  • simpleregex は7.1MBです。このプログラムは、simple-helloにregexを足しただけのものなので、差分はそのライブラリ分と言えるでしょう。
  • cli-mixed は7.7MBです。このプログラムは、上記2つを組み合わせ、CLI引数パースライブラリを入れたもので、これが差分と言えるでしょう。

バイナリサイズについてですが、以下記事で言及されているサイズとも大きく違いません。おかしいところはないと言えるでしょう。

zenn.dev

また、cli-mixed での増分は0.6MBほどで、CLIパースライブラリ( clap 3)を入れることによる影響は小さいものと考えて良さそうです。

イメージサイズ

docker images
REPOSITORY                       TAG       IMAGE ID       CREATED         SIZE
cli-mixed                        latest    a1edde0b912e   25 hours ago    312MB
simple-regex                     latest    140072c9464d   26 hours ago    311MB
simple-hello                     latest    5e7d492f9742   27 hours ago    310MB
public.ecr.aws/lambda/provided   al2       606c70acc7a0   36 hours ago    304MB

上記の通り、ベースのイメージは300MBほどあります。最小のLambdaであれば、イメージサイズに比べて十分小さく、デプロイ速度等への影響は出にくいものと考えられます。

実行時間・使用メモリ量

いずれも以下のようにしてローカル実行します。

docker run --rm -p 9000:8080 simple-hello

# 別terminalで
curl -XPOST "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{"date": "2014-01-01"}'

# docker runしたほうのterminalに以下が表示される
START RequestId: 4965b0b0-8129-427d-9551-292b0391e4dd Version: $LATEST
END RequestId: 4965b0b0-8129-427d-9551-292b0391e4dd
REPORT RequestId: 4965b0b0-8129-427d-9551-292b0391e4dd  Init Duration: 0.35 ms  Duration: 5.25 ms       Billed Duration: 6 ms
Memory Size: 3008 MB     Max Memory Used: 3008 MB

実行(できなかった)

cli-mixed は以下の通り実行できませんでした。

docker run --rm -p 9000:8080 cli-mixed hello
time="2021-12-19T14:25:39.891" level=info msg="exec '/var/runtime/bootstrap' (cwd=/var/task, handler=)"
time="2021-12-19T14:25:43.398" level=info msg="extensionsDisabledByLayer(/opt/disable-extensions-jwigqn8j) -> stat /opt/disable-extensions-jwigqn8j: no such file or directory"
time="2021-12-19T14:25:43.398" level=warning msg="Cannot list external agents" error="open /opt/extensions: no such file or directory"
START RequestId: 8410dbfa-8b8a-43a1-80a4-91f3b48fee35 Version: $LATEST
cli-mixed

USAGE:
    bootstrap <SUBCOMMAND>

OPTIONS:
    -h, --help    Print help information

SUBCOMMANDS:
    hello
    help     Print this message or the help of the given subcommand(s)
    reg
time="2021-12-19T14:25:43.402" level=warning msg="First fatal error stored in appctx: Runtime.ExitError"
time="2021-12-19T14:25:43.402" level=warning msg="Process 16(bootstrap) exited: Runtime exited with error: exit status 2"
time="2021-12-19T14:25:43.402" level=error msg="Init failed" InvokeID= error="Runtime exited with error: exit status 2"
time="2021-12-19T14:25:43.402" level=warning msg="Reset initiated: ReserveFail"
time="2021-12-19T14:25:43.402" level=warning msg="Cannot list external agents" error="open /opt/extensions: no such file or directory"
cli-mixed

USAGE:
    bootstrap <SUBCOMMAND>

OPTIONS:
    -h, --help    Print help information

SUBCOMMANDS:
    hello
    help     Print this message or the help of the given subcommand(s)
    reg
END RequestId: 5739ea46-a317-4581-b9c8-dc073f0e7b1f
REPORT RequestId: 5739ea46-a317-4581-b9c8-dc073f0e7b1f  Init Duration: 0.23 ms  Duration: 8.27 ms       Billed Duration: 9 ms
Memory Size: 3008 MB     Max Memory Used: 3008 MB

初回実行時間・メモリ使用量

いずれも10回実行した結果です。 (最小)~(平均)±(分散)~(最大) で表記します。

プログラム Init Duration [ms] Duration [ms] Memory Size [MB] Max Memory Used [MB]
simple-hello 0.18~0.33±0.10~0.49 3.76~4.52±0.47~5.25 3008 3008
simple-regex 0.19~0.31±0.06~0.40 4.60~5.17±0.51~6.15 3008 3008
  • どちらも実行時間としては大きな差はないです。大きなプログラムではないのでそれはそうです
  • 初回実行時のみ、simple-regex正規表現パターンをコンパイルするので、その分だけ遅くなっているようです
  • 主に比較したかった cli-mixed について比較できていないのが残念です
  • メモリ使用量が3GBくらいになっていますが、そんなにつかうはずがありません。確認方法に問題があると思われます

2回目以降の実行時間・メモリ使用量

いずれも10回実行した結果です。

プログラム Duration [ms] Memory Size [MB] Max Memory Used [MB]
simple-hello 1.24~1.35±0.10~1.54 3008 3008
simple-regex 1.03~1.28±0.17~1.67 3008 3008
  • これも実行時間に差がほぼありません
  • 初期化時間がない分、より速くなっています
  • simple-regex が2回目以降パターンがコンパイル済みであるため、もともとあった差がよりなくなっています(平均で見るとちょっと速い)
  • メモリ使用量が3GBくらいになっていますが、そんなにつかうはずがありません。確認方法に問題があると思われます

まとめ

  • 今回の実験では、目的の cli-mixed について調査完了できませんでした。 実行方法について情報をお待ちしています
  • 実行時間に関しては、初回から10msを切る結果で、かなり高速といえるでしょう
  • Lambda Container Imageのローカル実行はかなり簡単で、開発サイクルを回しやすいと感じました