Axumのハンドラで任意で渡されるクエリパラメータを受け取る

たとえばAxumのハンドラでクエリパラメータ page を受け付けるときに、

  • page=1が付与されていれば値として1を使う
  • page=aのように値が無効ならデフォルト値を使う
  • pageが付与されていないならデフォルト値を使う
  • page=のように値が空ならデフォルト値を使う

… のすべてをうまくハンドリングしたい。

方法

1つの方法として、たとえばAxumのextractorのドキュメントでも取り上げられているPaginationを任意のクエリパラメータに対応させると次のように書ける。crateとしてserde、serde_withも必要です。

#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct Pagination {
    #[serde_as(as = "NoneAsEmptyString")]
    pub page: Option<usize>,
    #[serde_as(as = "NoneAsEmptyString")]
    pub per_page: Option<usize>,
}

impl Pagination {
    const DEFAULT_PAGE: usize = 1;
    const DEFAULT_PER_PAGE: usize = 10;

    pub fn params(&self) -> (usize, usize) {
        (
            self.page.unwrap_or(Self::DEFAULT_PAGE),
            self.per_page.unwrap_or(Self::DEFAULT_PER_PAGE),
        )
    }
}

impl Default for Pagination {
    fn default() -> Self {
        Self {
            page: Some(Self::DEFAULT_PAGE),
            per_page: Some(Self::DEFAULT_PER_PAGE),
        }
    }
}

pub async fn get_products(query: Option<Query<Pagination>>) {
    let (page, per_page) = query.unwrap_or_default().params();

    // ...
}

これは

  • GET /products
  • GET /products?page=1
  • GET /products?per_page=10
  • GET /products?page
  • GET /products?page=
  • GET /products?page=a

のいずれも対応できる。有効なパラメータでなければ、デフォルト値を使う。

解説

AxumのextractorとSerdeを併用すると、クエリパラメータを引っこ抜いて構造体に格納できる。

#[derive(Deserialize)]
pub struct Pagination {
    pub page: usize,
    pub per_page: usize,
}

pub async fn get_products(query: Query<Pagination>) {
  // let Query(pagination) = query;
  // let page = query.page;
  // ...
}

しかし、このコードだと GET products?page=GET /products? のようにパラメータが送られてこなかったときエラーになる。

Failed to deserialize query string: missing field `page`

まず、GET /products?のようにそもそもパラメータが見つからないならNoneとして扱いたい。これはフィールドをOptionで包めばよい。

#[derive(Debug, Deserialize)]
pub struct Pagination {
    pub page: Option<usize>,
    pub per_page: Option<usize>,
}

さらに、パラメータは送られてきても値が空のときはNone扱いにしたい。これは#[serde(default)]とserde_withのNoneAsEmptyStringを併用することで実現できる。NoneAsEmptyStringを使うとパラメータの値が空(?page=のような形式)をNoneにデシリアライズできるが、その代わりパラメータのキーの存在が必須になる。ここで#[serde(default)]を付与することで、キーが存在しないときはデフォルト値をフィールドにセットするようにしておく。デフォルト値を与えるためにDefaultを実装しておくのがよい。

#[serde_as] // 先頭に付与する必要がある
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct Pagination {
    #[serde_as(as = "NoneAsEmptyString")]
    pub page: Option<usize>,
    #[serde_as(as = "NoneAsEmptyString")]
    pub per_page: Option<usize>,
}

impl Default for Pagination {
    fn default() -> Self {
        Self {
            page: Some(1),
            per_page: Some(10),
        }
    }
}

さらに、パラメータの値が構造体のフィールドの型と合わずデシリアライズでエラーになるとき(?page=abcのような形式)はデフォルトの値を使うとすると、extractor自体をOptionで包んでエラー時にはNoneになるようにしておき、先ほど実装したDefaultを利用することができる。

pub async fn get_products(query: Option<Query<Pagination>>) {
    // paginationはデシリアライズできたものかDefault::default()で得られるもののどちらかになる
    let Query(pagination) = query.unwrap_or_default();
}

これらすべてを取り込むと、最初に書いたようなコードになる。

他の方法

Query extractorは内部(extract::Query::try_from_uri)で文字列をSerdeのデシリアライザに渡しているので、カスタムのデシリアライザを書けばNoneAsEmptyStringは使わなくてよくなりそう。また、たとえばpageper_pageの片方がデシリアライズできなければ、全体をデフォルト値にフォールバックしていたが、それもカスタムのデシリアライザでデフォルト値にするなどの方法も考えられる。

SQLxでテスト実行時にDBのデータを管理する

RDBをデータストアとして使うWebアプリケーション(のバックエンド)の開発で、テストを通じてDBに作成したレコードを自動で削除できると、開発環境のDBが汚れず、また他のテストによって変更されたDBの状態に影響されないので便利。Rustではこれをどうやるのかを調べていた。

SQLxのsqlx::test

SQLxには0.6.1からsqlx::testというattribute macroが追加されている1。これを使うと、テスト実行時に次のような機能を利用できる。

  • テスト実行時にマイグレーションを実行する
  • テスト実行、終了時にテスト用DBを作成、削除する
  • テスト実行時にフィクスチャをDBに投入する

SQLxのリポジトリのexamplesに、簡単なREST APIのテストにおけるsqlx::testのわかりやすい利用例が置いてあるので、使いかたを知りたければこれを見るとよい。

https://github.com/launchbadge/sqlx/tree/main/examples/postgres/axum-social-with-tests

次のような流れで使う。

  • 非同期のテスト関数に#[sqlx::test]を付与する
    • #[sqlx::test]#[tokio::test] のように非同期関数をテストとして実行できる機能もある
  • #[sqlx::test]の引数としてfixturesでテスト実行時にあらかじめDBにレコードを投入するためのクエリを実行する
    • テストファイルと同じディレクトリにfixtures/users.sqlのようなファイルを置く
  • テスト関数は引数としてsqlxが管理するコネクションプールを受け取る
    • このプールを通じてDBに書き込んだレコードはテスト終了時にDBごと削除される
// examples/postgres/axum-social-with-tests/tests/post.rs から引用

#[sqlx::test(fixtures("users"))]
async fn test_create_post(db: PgPool) {
    let mut app = http::app(db);

    // Happy path!
    let mut resp1 = app
        .borrow_mut()
        .oneshot(Request::post("/v1/post").json(json! {
            {
                "auth": {
                    "username": "alice",
                    "password": "rustacean since 2015"
                },
                "content": "This new computer is blazing fast!"
            }
        }))
        .await
        .unwrap();

    assert_eq!(resp1.status(), StatusCode::OK);

    let resp1_json = response_json(&mut resp1).await;

    assert_eq!(resp1_json["username"], "alice");
    assert_eq!(resp1_json["content"], "This new computer is blazing fast!");

    // ...
}

sqlx::testをいつ使うか

sqlx::testはおおむね便利そうだが、テスト関数ごとにDBを作成、フィクスチャ投入、削除を実行する2。テストが増えると速度は落ちそう。

その点を踏まえたWebアプリのテストでの使いどころは、やはり先に述べたようなエンドポイントをテストする場合といえそう。このようなテストは、テストサイズの観点における中テスト (Medium test)として、他のプロセスで動くRDBも利用しつつ、1つのエンドポイントが期待通り動くかを確認するのに役立つ。一方で、もう少し細かいモジュールのテストは、オンメモリだけで完結したり、外部依存があればモックを使って実行するような小テスト (Small test)としてテストすることが多い。エンドポイントに対する主要なテストだけを中テストに任せれば、DB自体の作成と削除が走る回数を相対的に少なく抑えることはできそうだった。

sqlx::testを使うアプリの設計

テストのしやすい設計という観点では、[sqlx::test]を付与した関数に渡るコネクションプールをアプリの実体(axum::Routerとか)に注入できるようにする必要がある。これもAxumなどを使ってアプリを作るときは、アプリに具体的なコネクションプールやリポジトリを注入して、axum::extract::Stateのようなハンドラから使える状態に持たせておけば問題ないはず。イメージとしては次のようなコード。

// アプリの状態
#[derive(Clone)]
pub struct AppState {
    // ハンドラ間でなんらかのリポジトリを共有する
    pub repository: Arc<Repository>,
}

// アプリ本体
pub fn app(repository: Repository) -> Router {
    let state = AppState {
        repository: Arc::new(repository),
    };

    // axum::Router
    Router::new()
        .route("/products/:id", get(get_product))
        .with_state(state)
}

// ハンドラ
pub async fn get_product(
    State(state): State<AppState>,
    Path(id): Path<u32>,
) -> impl IntoResponse {
    // 状態からリポジトリを取得してアプリ固有のロジックを実行する
    let products_app = ProductApp::new(state.repository.clone());
    
    // ...
}

// 引数にsqlx::PgPoolを受けるテスト関数
#[sqlx::test(fixtures("products"))]
async fn get_product(pool: PgPool) {
    // コネクションプールをもとにリポジトリを初期化できるようにしておいて、
    // SQLxのテスト用コネクションプールでリポジトリを初期化する
    let repository = Repository::new(pool);
    let app = app(repository);

    let response = app
        .oneshot(
            Request::builder()
                .uri("/products/1")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    // ...
}

  1. migrate featureを有効にする必要がある
  2. たぶん実装は https://github.com/launchbadge/sqlx/blob/v0.7.1/sqlx-core/src/testing/mod.rs#L201-L230 のあたり

『セキュア・バイ・デザイン 安全なソフトウェア設計』を読んだ

ドメイン駆動設計(DDD)の方法論をベースに、ドメインに対する深い理解を獲得し、その理解を設計に反映させてコードを書くことで、セキュアなソフトウェアの開発にも寄与するという主張を中心に据えた本。

ここで、この本では「設計」という言葉を「ソフトウェア開発のプロセス全体における能動的な意思決定」という意味で使っている。

この本を読むことで、セキュアコーディングという側面も考慮したDDDの活用法、実際に新規/既存のアプリケーションの開発にそれらのテクニックを導入して設計を改善する方法を学ぶことができる。

設計を中心に据える理由

開発において、コーディングを通じたモジュールやAPIの設計などに力を入れることが多い一方で、セキュリティについては後回しになりがちだ。そこで、この本で扱う方法論を取り入れることで、設計を通じてセキュリティも自然に向上できるという効果が得ることができる。結果として、セキュリティを単なる機能と捉え、バックログで後回しにされるというありがちな問題の解消に役立つ。

とはいえ、この本で紹介される手法は、あくまでもセキュリティにおける多層防御に役立つ方法の1つであり、システムに対してさまざまな側面からセキュリティ対策を施す必要があることには変わりない。

紹介されている方法

主に次のようなトピックが紹介されている。

  • セキュリティのCIA-T(機密性、完全性、可用性、追跡可能性)について
  • DDDの主要なコンセプト
  • 不変オブジェクト、契約による設計、妥当性検証などのテクニック
  • ドメイン・プリミティブやエンティティの導入
  • テスト自動化や機能フラグなどデリバリー・パイプラインでの考慮事項
  • 適切なエラーハンドリング
  • アプリケーション運用上のテクニック

興味深かったポイントをいくつか紹介する。詳しくは本を参照のこと。

ドメインに対する理解の深さ

浅いモデリングと深いモデリングという2つの観点でドメインモデリングを比較している。

浅いモデリングは、たとえば短絡的に商品の数量にint型を使うような、情報の意味が明示化されていないモデリングのことを指す。一方で、深いモデリングは、該当のコンテキストにおいてその数量自体にもルールが存在する(たとえば注文できる数量の上限下限が存在する、など)ことを理解し、そのルールを含めてモデリングすることを指す。

もちろん深いモデリングが望ましいとされていて、そのようなドメインモデルを作るにはドメインエキスパートとの協働が必要…というのはDDDの文脈でよく議論されているとおり。

設計のテクニック

ドメインに対する理解をもとに、最も基本的な構成要素として、特定のコンテキストで利用できるドメイン・プリミティブ1を作り出す。これは値オブジェクトをベースにしたもので、つねに不変条件を満たし、他のコンテキストの情報などがなくてもそれ自体で利用できる(完結した概念; conceptual whole)ドメインモデルである。

たとえば、「注文」のコンテキストにおいて、有効な数が1〜200個の「商品の数量」というドメインモデルはドメイン・プリミティブとして実現できる。

// Rustでのコード例

pub struct ProductQuantity {
    value: u8,
}

impl ProductQuantity {
    pub fn build(value: u8) -> Result<Self, &'static str> {
        if value == 0 || value > 200 {
            return Err("value must be between 1 and 200");
        }

        Ok(Self { value })
    }

    pub fn value(&self) -> u8 {
        self.value
    }

    pub fn add(&self, addend: u8) -> Result<Self, &'static str> {
        Self::build(self.value + addend)
    }
}

深いモデリングで得られたモデルの一部をドメイン・プリミティブにすることで、設計とセキュリティの両方でよい効果がある。

上記コードだと、ある関数に商品の数量を引数として渡すとき、このドメイン・プリミティブを使えば表現が明示的になるし、このモデルを通すことでバリデーションが実行されることを保証できるので、他の箇所にバリデーションロジックが分散するのを避けられる。また、外部から入力された整数値をそのまま商品の数量とみなさず、いったん不変条件を持つドメイン・プリミティブを通すことになるので、負の数を渡してカートの金額を減らそうとするかもしれない攻撃者にシステムの完全性を侵害される可能性を減らせる。

ドメイン・プリミティブは他のドメイン・プリミティブを持つこともある。エンティティを含め必ずドメイン・プリミティブを使うことで、データが正しい状態にあることを保証できる。

他のテクニックとしては、機密情報へのRead-Onceオブジェクトの利用、完全性を保証するエンティティの生成方法、複雑化したエンティティの状態管理などについて紹介されている。どれも読みやすく変更しやすいコードとセキュアなコードの両方に役立つものである。

安全なエラーハンドリング

エラーハンドリングについても、例外をビジネス例外と技術的例外に分類したのち、例外のデータに起因する情報漏洩に対する対策や、そもそも例外を使わないエラーハンドリング(ActiveModel::ErrorsやResult型などを想像するとわかりやすい)について紹介されている。

以前このブログでも書いたことがあるが、本当に例外的なエラーはバグや技術的に復旧できない問題のはずなので、できるだけ例外を使わずにエラーハンドリングすべしという主張は納得感がある。

blog.kymmt.com

それがセキュリティ向上にもつながるなら尚更だ。

安全なデリバリーや運用

ドメインモデルのコーディング以外の話題として、デリバリーや運用に関するテクニックも紹介されている。例えば、最近はデリバリーにおいて機能フラグを使い、未完成の機能も含めてトランクベース開発するという方法が一般的になっている。しかし、機能フラグが増えるとフラグ間の関係が煩雑になりがちで、かつ設定にミスがあり意図せず機能を公開してしまうと事故につながりかねない。そこで、テストで機能フラグやフラグを利用するロジックが適切に動いているか検証することが推奨されている。

また、システム管理サービス(要は社内管理画面)の重要性も説いている。運用で直接SQLを発行したりSSHでサーバに入るのではなく、顧客に提供しているサービス本体のアプリとは別のサービスとしてシステム管理サービスを運用するのがよいとされている。また、外から入ってきた文字列や機微情報を検証なくログに出力すると攻撃につながるという、いかにもやってしまいそうなケースについても、対象ドメインにおいて検証すべき外部入力や秘匿情報がなんなのかモデリングを通じて理解し、ドメイン・プリミティブやロガーを適切に使って防ぐべきだとしている。

所感

実事例やレガシーアプリケーションへの導入方法の章でも説明されている通り、まず取り組むべきは対象ドメインを詳しく理解するということだった。DDDではとかくコーディングに関するテクニックが着目されがちだが、ドメインの理解やコンテキストの分析が重要ということだろうと思う。ドメインで発生するイベントやリソースについて有識者も交えてしっかり開発者が理解し、そのあとにそれらを抽象化したドメインモデルをドメイン・プリミティブやエンティティなどコードとして落とし込むことが、セキュアなソフトウェア開発に必要な1つの要素なのだろうと思う。


  1. 書籍での表記どおりに中黒を入れている

Webサービスのソフトウェアエンジニアとしての転職活動で役立ったこと

Webサービス界隈のソフトウェアエンジニアになって初めて転職活動をした1ので、やったことのうち役に立ったことを書いておく。

前提

  • 経歴: https://www.linkedin.com/in/kymmt/
    • Webサービスを開発、運用するふつうのソフトウェアエンジニア
  • 転職活動時期は2023年1月〜6月
    • 1月〜2月でカジュアル面談
    • ちょっとあいて4月下旬〜6月上旬で選考
    • 6月に内定をもらって終了
  • この記事では一般的な技術力については触れません


役立ったこと

レジュメのメンテナンス

これは転職活動前から継続的にやっていたこと。

ふつうのソフトウェアエンジニアであり、「代表的プロダクト」2もないので、職歴や利用している技術スタック、また自分自身の能力を発揮したイベントについて明示的にレジュメにまとめておき、各種媒体に記載しておかないと、採用する側から見たときに声をかけようという動機が生まれにくいだろうと思っていた。また、細かいことを覚えているうちに継続的にレジュメにまとめておくほうが長期的に見て楽だろうし、自分で応募書類を書くときや面接するときに整理して説明できるだろうという考えがあった。

現在は次のようにレジュメを更新している。

  • LinkedInを主とみなし、英語で経歴を書いておく
    • サービスの性質的に英語で書いておいたほうが長期的にはお得そう
  • LinkedInに書いた内容を日本語に訳して微調整したものをFindyに転記する
  • 他のサービスについては、Findyのものをコピペして微調整する

書いている内容は業務経歴、経験のある技術スタック、ブログ記事やスライドなど自分のアウトプットの紹介などで、特別凝ったことを書いているわけではない。なんにせよ忘れないうちに書いておくのが大事だと思う。

これがどこまで効果を発揮したかはわからないが、結果としては次のようになった。

  • LinkedIn
    • いただいたメッセージから3件カジュアル面談し、いくつか応募
  • Findy
    • いいね100件以上いただいたなかから4件カジュアル面談し、いくつか応募
    • いいねが多いと閲覧しづらいという問題はあるが、どのような会社がソフトウェアエンジニアを募集しているのかの情報を大量に見るのには便利だった
  • その他媒体経由でいただいたメッセージから3件カジュアル面談

カジュアル面談の準備

どの企業に対してもいくつか同じ質問をすることで、各企業を自分の中で相対化するようにしていた。なにを重視するかによって変わるが、自分は次のような内容は必ず質問していた。

  • エンジニアリング組織の構成や各ラダーの人員の割合
  • エンジニア以外の職種もまじえたプロダクト開発の体制やプロセス
  • デプロイやリリースのフローや頻度
  • 障害やセキュリティ対応に関する体制
  • 社内の評価制度

同じ質問を軸にカジュアル面談することで、各社のカラーが自分の中で明確になり、応募するときの意思決定に役立った。

技術選考の準備

応募した企業には、技術選考を実施することがあらかじめ示されているところがあった。具体的にはデータベース設計などの技術課題、コーディング面接、システム設計面接など。これまで技術選考を受けてこなかった人生であり、とくに面接形式だと緊張することは目に見えていたので、できるだけ準備するつもりではいた。

コーディング面接については、企業からあらかじめ提示されていたサービスを利用して、あらためてコーディング試験的な問題が苦手なことに落ち込みつつ、1か月ほど繰り返し練習した。

システム設計面接については、ちょうど選考の時期に邦訳が出版された"System Design Inteview"(『システム設計の面接試験』)を読んだ。

この本を読んで、一般的なシステム設計面接では、時間内にとても難しいシステム設計ができるかを確認しているわけではなさそうということがわかった。それよりは、議論を通じて解くべき課題の抽出やパフォーマンスの見積もり、また設計の深掘りを適切に進められるかが重視されているようだ。これを踏まえて、それらの点を意識して事前に1人で数回練習した。30分ほど時間をとって、EコマースのサービスやTwitterっぽいソーシャルメディアのサービスを設計する際の質問や設計の提案、改善するための議論をテキストに書き出していく感じ。

結果として、どの企業でも純粋に技術的な選考で落ちることはなかったのでよかった3

ふつうの面接の準備

ここでは技術面接ではなくマッチングを見られる面接のことを「ふつうの面接」と呼んでいる。

レジュメについての節でも、あらかじめ経歴をまとめておくことで、整理して話せるということを書いた。しかし、自分は話すことが下手な自覚があるので、さらに準備した。具体的には、次のようなトピックについてそれぞれすぐに話せるようにメモにまとめていた。これらのトピックの選択については、業務で自分自身が選考していた経験も生きている。

  • 現在の職務
  • 担当しているサービスの規模感
  • 転職を考えている理由
  • すぐに貢献できること
  • これまでの仕事でうまくいかなかったこととその理由
  • 嫌なこと、避けたいこと

もし緊張しても、このメモを見ればある程度は話せるだろうという安心感があった。

感想

技術選考が近づくと、その準備やあまりこれまで経験してこなかったことに対する不安でそわそわしていた。こういうのはキャリアを通して慣れていくしかないのだろうと思う。とはいえ、技術選考に関しては一定の結果が出たことで、自分の能力が特定の環境に特化しているわけではないことが確かめられたのでよかった。一方でマッチングで見送られることもあり、そういうものとはいえ職を見つけるのは難しい。

また、自分自身が業務で選考するときは、できるだけフィードバックが早いほうがいいだろうと思って当日中に評価を記入するようにしていたが、受ける側になってみて、早めに結果を通知されるのが(主に精神的に)助かると改めて確信した。いろいろあるとは思いますが、各社できるだけ早い結果の通知をお願いします…


  1. 前回(2016年)の転職は新卒で入ったITベンダーであまり経験を積まない(積めない)まま、Webサービスのエンジニアに転職したという経緯だった
  2. 『「代表的プロダクト」について』
  3. どの企業でも内定が出たとは言っていない

運用しやすい社内用ライブラリを開発するときに考えること

以前から社内用ライブラリをホストするためにGitHub Packagesを運用できる体制を作ったりしていた。

tech.pepabo.com

ところで、そもそもアプリケーションとライブラリでは開発やメンテナンスにおいて気にする点が少し異なる。そのあたりに関する初歩的なことがらを共有するための社内用ドキュメントを書いていたが、基本的に社内事情はあまり関係なくWebに置いても問題ない内容だったので、ここに置くことにする。

一応、次のコンテキストがあることに注意。

  • RubyGemsのライブラリ (gem) をホストすることが多いのでRubyの話として書く
  • 社内用ライブラリなので、外部サービスのクライアントのように社内横断で共通するロジックをライブラリで切り出すケースが多い
  • (どこもそうだとは思うが)ライブラリの運用を専門に仕事をしている人はおらず、サービスの開発者が片手間でメンテナンスするので、省力化したい
  • 必ずしもOSSとしてのライブラリを開発、メンテナンスしたことがある人が触るとは限らない

はじめに

社内用ライブラリとして、外部決済サービスのクライアントのように複数サービス横断で必要な機能をgemとして実装しておくことで、開発コストを削減できるという利点がある。しかし、毎年Rubyの新バージョンはリリースされ、依存するgemの状況も刻一刻と変化するなかで、ライブラリが必ずしも新しい状況に追従しやすくなっていないこともある。

将来にわたって使いやすくメンテナンスしやすいライブラリにするために考慮すべきポイントをいくつか挙げる。

メンテナンスされているランタイムであれば動くようにする

gemはライブラリなので、利用者の状況(e.g. 各サービスが使っているRubyのバージョン)によって、さまざまなバージョンのRubyで動く可能性がある。最低限、その時点でメンテナンスされているバージョンのRubyであれば、どのバージョンでも動くようなコードにする。また、その範疇にとどまらず、社内の主要なアプリが使っているRubyのバージョンはできるだけサポートしておく。

たとえば、Ruby 3.0がメンテナンス対象である2023年の段階でgemのコードを書くときは、Ruby 3.1で入ったハッシュのキー省略記法は使わない、という感じになる。

foo, bar = 1, 2

# Ruby 3.0が社内で使われているあいだは、gemの中で次の記法は使わない
h = { foo:, bar: }

また、古いバージョンでdeprecatedになった機能などがある場合、どうしてもRubyのバージョンごとに機能の使い分けをしたいなら、バージョンで条件分岐する。が、これは最後の手段。サービスのアプリケーションで対応できるなら、そちらでランタイムをアップデートできたほうが長期的にはよいだろう。

if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.0.0")
  # Ruby 3.0.0以上のときのコード
else
  # Ruby 3.0.0未満のときのコード
end

CIでもマトリックスビルドを使うなどして、各バージョンでテストできるようにしておく。

# GitHub Actionsの場合
jobs:
  test:
    strategy:
      matrix:
        ruby:
          - 2.7
          - 3.0
          - 3.1
          - 3.2
# ...

ライブラリの依存先としてのライブラリがどのバージョンでもできるだけ動くようにする

ライブラリ自体が依存するライブラリ(gemだとgemspecでadd_dependencyを使って依存に追加するgem)のバージョンも利用者の状況によって変化する。たとえば、社内の各サービスで運用しているRailsアプリのGemfile.lockに書かれているすべてのgemのバージョンををgemの開発者が制御できるわけではない。

よって、基本的にはライブラリ開発者が細かく依存先のライブラリのバージョンを指定すべきではない。できるだけ、依存するライブラリがどのバージョンであっても動くようにコードを書く。また、gemの場合は、テストを実行するとき依存先のgemのバージョンが固定されないように、gemのリポジトリではGemfile.lockをバージョン管理から外す*1

とはいえ、依存するライブラリがメジャーアップデートして破壊的変更が入り、その変更には新しいバージョンを発行して対応するという判断になるときもある。そのような場合は依存先ライブラリのバージョンに制約を入れる。

また、移行措置として依存先のライブラリのバージョンによってコードを書き分けたいこともありうる。Rubyの場合は、依存先のgemのバージョンを参照して条件分岐するなどの方法が考えられる。

if Gem::Version.new(FooBar::VERSION) >= Gem::Version.new('2.0.0')
  # foo_bar gemがv2.0.0以上のときのコード
else
  # foo_bar gemがv2.0.0未満のときのコード
end

lib/foo_bar/version.rbに定義するバージョン定数は、gemリリースのたびにちゃんと更新すると利用者の役に立つ可能性があるということになる。

他のライブラリに依存するときはよく考える

上述した項目にも通ずる話として、作るライブラリの規模にもよるが、ライブラリ自体が依存するライブラリは少ないほうが利用者もライブラリ開発者も楽。

他のライブラリを使うにしても、できるだけAPIが安定しているものに依存するのがよい。よくも悪くも全開発者がちゃんと従っているルールではないが、Semantic Versioningだとv0.y.zなバージョンのライブラリは規約上APIが破壊的に変わりうる。

メジャーバージョンのゼロ(0.y.z)は初期段階の開発用です。いつでも、いかなる変更も起こりえます(MAY)。この時のパブリックAPIは安定していると考えるべきではありません(SHOULD NOT)。

なので、APIがまだ不安定なライブラリに気軽に依存することは推奨しない。とくに社内用だと依存先ライブラリのバージョンアップなどで改修に余計なコストをかけたくないという点で、この判断基準の重みが大きくなる。とはいえ、世の中には広く使われているものの長いあいだv0.y.zなままのライブラリもあるので、そのあたりは実際の利用状況をあらかじめリサーチして判断するのがよい。

また、npmなどエコシステムによっては依存が多くなりやすいこともある。そういう場合は、あまり無理に依存を減らそうとせずエコシステムの文化を考慮に入れるほうがいいかもしれない。


まとめ

書いてみて、やっぱり社内用に限った話ではない感じになった。要約は次のとおり*2

  • 社内用ライブラリは、さまざまなバージョンのランタイムで動作可能な設計が必要である。これは、異なるサービスや開発者が様々なバージョンのランタイムを使用しているためで、それぞれのバージョンでライブラリが動作するコードが必要となる。
  • 社内ライブラリが依存する他のライブラリも、そのバージョンに関わらず動作するように設計することが求められる。これにより、開発者は各ライブラリのバージョンを制御する必要がなくなり、また、どのバージョンでも動作するコードを書くことが可能となる。
  • ライブラリが他のライブラリに依存するときは十分に考慮が必要である。特に、APIがまだ安定していないライブラリに対しては依存を避けるべきだ。これは、依存関係のバージョンアップなどによる修正に余分なコストをかけたくないという観点が関係している。

*1:Railsエンジンなどで親アプリの環境を固定したいときにGemfile.lockをバージョン管理するケースもある

*2:ChatGPT (GPT-4)で生成したものを微調整