はじめに
Backlogの一部機能は長年Perlのコードで動いてきました。その実績の一方で新しい開発の課題となっていたのも事実です。そこで私たちが始めたのが、このPerlコードをモダンなGo言語へ移行するプロジェクト。今回は効率化のため、新たな試みとして生成AIも活用しています。
この記事は、生成AIを使いながらコードベースを移行した、実際のプロジェクトの記録です。
目次
移行への道のり:3つのステップ
コードベースの移行は、見通しの立てづらいプロジェクトです。慎重に計画を立てずに進めれば、手戻りや品質の低下を招く可能性があります。このプロジェクトを成功に導くため、私たちは羅針盤となるものを用意しました。その核心は主に以下の3つのステップに集約されます。
- Step 1: 分析と計画で移行の土台を作る
- AIと共に既存コードの謎を解き明かします。
- Step 2: 戦略を戦術に落とし込む
- 複雑な関数を分解して安全な移行ルートを設計し、PerlからGoへとコードを再構築します。
- Step 3. 品質の礎を築く
- 徹底的なテストで品質を担保します。
Step 1. 分析と計画で移行の土台を作る
生成AIは強力なツールですが、決して魔法の杖ではありません。最高の性能を発揮させるには、人間による的確な戦略が不可欠です。移行作業において、その戦略の核となるのが、この分析と計画のフェーズです。ここで移行対象の解像度をどれだけ上げられるかが、後工程の品質とスピードを、そしてプロジェクト全体の成否を左右します。
1. AIを効果的に活用するための「スコープ最小化」
まず取り組んだのは、移行対象の関数が依存している、他の関数群の洗い出しです。なぜなら、生成AIは一度に大量の複雑なコードを与えられると、その能力が著しく低下し、ハルシネーション(事実に基づかない情報や、文脈に合わない不自然な内容を、あたかも事実であるかのように生成する現象のこと)を起こしやすくなるからです。
AIの能力を最大限に引き出す鍵は、一度に考えさせる範囲(スコープ)を人間が意図して必要最小限に絞り込むこと。そして、依存関係の末端にある、最もシンプルな関数から一つずつ移行・テストしていく。この地道なアプローチこそが、コードベースを安全に移行するための、確実な一歩になると考えます。
2. コードの保守性を高める「責務の再設計」
分析を進める中で、私たちはある決断をします。それは単なる言語の置き換えではなく、未来のメンテナンス性を見据えた再設計を行うことでした。
例えば、一つの関数内に混在していたビジネスロジック(トランザクション管理)とデータアクセス(SQLクエリ)を明確に分離しました。この「責務の分離」は、コードの見通しを良くするだけでなく、単体テストを大幅に容易にし、未来の自分たちの助けとなります。
3. リスクを制御する「一点集中の原則」
一方で、私たちは「すべてを一度に大きく変えない」という原則も設けました。関数の分割やレイヤーの再設計だけでも、それなりのリスクを伴います。そこで、SQLクエリのロジック自体は、原則として大きく変更しない方針を採りました。
しかし、「大きく変えない」ことは「全く触らない」ことを意味するわけではありません。 むしろ、Goの作法に合わせるための微修正や、将来のメンテナンス性向上のための小さなリファクタリングは、この機会に積極的に行います。例えば、複雑なクエリを読みやすく整形したり、変数名を分かりやすく変更したりといった対応を進めました。
移行のリスクを「ロジックの再現性」という一点に集中させ、制御可能に保ちつつ、コードの健康状態は着実に改善していく。 このバランス感覚こそが、複雑な移行を成功させるための重要な戦略だと考えます。大規模なパフォーマンスチューニングは、移行が一段落した後のテーマとして捉えています。
Step 2. 戦略を戦術に落とし込む
ここからは、前段で触れた戦略が実際のコード上でどのように戦術として実行されていったのかを見ていきます。
複雑なロジックであればあるほど、まずは分析やコード変換の初手をAIに任せ、その結果を人間がレビュー&手直しするアプローチが有効です。今回は移行対象となった典型的な2つの関数を例に、私たちがPerlコードをGoのコードへと変換するために設けた、具体的な設計指針を解説します。
例1. HTTPリクエストハンドラの移植
Perlのハンドラ関数に見られる共通パターンを「明示的なデータ受け渡し」と「型安全」の哲学に則って再設計します。
// コード例:HTTPのPUTメソッドをハンドリングする関数(※実際のコードではありません) // -------------------------------------------------- sub handle_put { my $self = shift; my ($r, ...) = @_; // プロセスが持つユーザー情報を取得 -> 後述の「方針1」に従って移植 my $user = $r->pnotes('user'); // ユーザーのロールに基づく処理 -> 後述の「方針2」に従って移植 if ($self->is_admin($user)) { ... } // リクエストパスをアプリケーションが処理可能な形に変換する -> 後述の「方針2」に従って移植 my (undef, $path, $name) = File::Spec->splitpath($r->uri); $path = $self->trim_prefix($path, 'some_prefix'); $path = $self->convert_to_valid_path($path) // その他の処理 ... }
方針1:リクエストスコープの値はcontext.Context経由で伝搬させる
Perl(mod_perl)の$r->pnotesのように、リクエストの裏側で暗黙的に値を渡す仕組みは便利です。しかし、何が渡されているか明示されていない状態は好ましくありません。
そこで私たちは、Goの慣習に倣い、context.Contextを用いてリクエストスコープの値を明示的に引回す設計を採用しました。
// コード例(※実際のコードではありません) // -------------------------------------------------- // コンテキストに値を格納するためのキーとして使用する空の構造体 type someCtxKey struct{} // setSomeValue はコンテキストに値を設定する func setSomeValue(ctx context.Context, val string) context.Context { return context.WithValue(ctx, someCtxKey{}, val) } // getSomeValue はコンテキストから値を取得する func getSomeValue(ctx context.Context) (string, bool) { v := ctx.Value(someCtxKey{}) if v == nil { return "", false } val, ok := v.(string) return val, ok } // handlePut は HTTP リクエストを処理するハンドラ func handlePut(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // 別のハンドラーでセットされた値を取得する v, ok := getSomeValue(ctx) if !ok { // エラーハンドリング ... return } // 取得した値を使って何らかの処理を行う ... }
なお、ここでの「明示的」とは『リクエストスコープの値にアクセスする可能性があることを、関数のシグネチャが示している』ことを意味します。これによりデータの流れが追いやすくなり、コードの可読性が格段に向上します。
Perl(mod_perl)において、ある関数が$r->pnotesを使うかどうかは、関数の外側から見ただけでは分かりません。引数として$rを受け取っていても、その内部でpnotesを読み書きするかは実装の詳細に隠されています。これは一種の脇道であり、データの流れが追いにくくなるため「暗黙的」であると表現しました。
対して、Goの慣習ではコンテキストを引回す関数は必ず第一引数にcontext.Contextを取ります。func DoSomething(ctx context.Context, id string) というシグネチャを見ただけで、開発者は『この関数はタイムアウトやキャンセルを処理する可能性があるな』、『リクエストに紐づく何らかの値(認証情報など)を使っているかもしれないな』と推測できます。このように、関数がコンテキストに依存しているという「契約」そのものが明示されているため、「明示的」であると表現しています。
方針2:ヘルパー関数の再設計(責務の分離と集約)
特定のデータ構造に依存しない汎用的なヘルパー関数は、独立した関数として同じ粒度で移植するか、一連の処理を1つの関数にマージします。対して、特定のデータ(例:ユーザー情報)に関連する補助処理は、そのデータを表す構造体を定義し、そのメソッドとして移植します。
前述のPerlコードにおいて、リクエストパスを操作している一連の関数呼び出しは他の箇所でも頻出するパターンでした。これについては次のコードのように1つの関数に集約する方針としました。
// コード例(※実際のコードではありません) // -------------------------------------------------- // 処理済みのパスを表す型 type Path string func NewPath(u *url.URL) Path { // パスの取得とプリフィックスの除去 path := filepath.Dir(u.Path) path = strings.TrimPrefix(path, "/some_prefix/") // 絶対パスへの変換 absPath, err := filepath.Abs(path) if err != nil { // パス変換に失敗した場合のエラー処理 ... return } // 変換されたパスが安全なものであることを検証(パストラバーサル対策) ... // その他の処理を実施 ... }
また、分離されていたユーザーの情報とヘルパー関数については、これらを構造体で表現します。
// コード例(※実際のコードではありません) // -------------------------------------------------- // ユーザーのロールを表す型 type RoleType int const RoleTypeAdmin RoleType = 1 // ユーザーを表す構造体 type User struct { ID UserID Name string Role RoleType } // ユーザーのロールを判定するメソッド func (u *User) IsAdmin() bool { return u.RoleType == RoleTypeAdmin }
方針3:引数と戻り値には「専用の型」を与える
Goの静的型付けは、実行時エラーを未然に防ぐための強力な仕組みです。この恩恵を最大限に引き出すために、関数の引数設計において以下の手法が有効です。
一つは、同じ文脈で使われる複数の引数を構造体にまとめる方法です。関連するデータが一つの単位となるため可読性が向上し、引数の渡し間違いといったヒューマンエラーを減らす効果が期待できます。
// コード例(※実際のコードではありません) // -------------------------------------------------- // ユーザーIDを表す型 type UserID string // ユーザー情報の更新に必要な値を集約した構造体 type UpdateUserParams struct { ID UserID UserName *string Email *string Password *string } // UpdateUserParamsのファクトリ関数 func NewUpdateUserParams(id UserID, userName *string, email *string, password *string) (*UpdateUserParams, error) { // 必須パラメータのバリデーションを実施 ... // 全てのバリデーションを通過したら、構造体を生成して返す params := &UpdateUserParams{ ID: id, UserName: userName, Email: email, Password: password, } return params, nil }
もう一つは、stringやintのような汎用的な型に対し、UserIDのような専用の型(型エイリアス)を定義することです。これにより、UserIDを期待する関数に、意図せずただのstring型の変数を渡してしまうといった間違いをコンパイル時点で検出できます。さらにドメインロジックを持たせることで、パスを扱う上で必要な操作を型に集約します。
// コード例(※実際のコードではありません) // -------------------------------------------------- // ファイルやディレクトリのパスを表す型 type Path string // ファイル名もしくはディレクトリ名の取得に使用するメソッド func (p Path) Name() string { return filepath.Base(string(p)) } // 親ディレクトリのパスを取得するメソッド func (p Path) ParentDirPath() string { path, _ := filepath.Split(string(p)) return path }
このように、構造体による整理と専用型による型安全性の向上を組み合わせることが、堅牢なソフトウェアを築くための堅実な防御策となります。
例2. データアクセスを密結合から疎結合へ
次のコードはトランザクションを明示的に開始して、複数のテーブルを操作するコードを簡易的に表現したものです。このようにトランザクション管理とデータアクセスが一体化した関数を、責務の分離を徹底した形に再実装します。
// コード例:トランザクション内で特定のテーブルに対して更新系のクエリを発行する関数(※実際のコードではありません) // -------------------------------------------------- sub update_the_record_in_transaction { my $self = shift; my ($a, $b) = @_; // トランザクションを開始 -> 後述の「方針1」に従って移植 my $tm = DBIx::TransactionManager->new($self->dbh); my $txn = $tm->txn_scope; // トランザクション内での読み取り my $user_id = get_user_id($a); // トランザクション内での書き込み(ハードコードされたSQLクエリ) my $sth = $self->dbh->prepare_cached(<<'__SQL__'); ... __SQL__ $sth->execute($user_id, $b); $sth->finish; // トランザクションをコミット $txn->commit; ... }
方針1:トランザクションの責務をサービス層へ引き上げる
データアクセス層の関数内でトランザクションを開始するのではなく、トランザクションの開始(Begin)から終了(Commit/Rollback)までの責務は、上位のサービス層が持つように変更します。これにより、データアクセス層は「DBをどう操作するか」という単一責務に集中でき、状態を伴うテストの複雑さが大幅に低下します。
// コード例:トランザクション内でユーザー情報を更新するメソッド(※実際のコードではありません) // -------------------------------------------------- func (s *SomeService) UpdateUserWithTx(ctx context.Context, userID UserID, ...) error { return s.db.WithTx(ctx, func(ctx context.Context, txC Client) error { // ユーザー情報を更新する処理を実行 if err := txC.UpdateUser(ctx, userID, ...); err != nil { return err } // 更新履歴を記録 if err := txC.CreateUpdateUserHistory(ctx, userID); err != nil { return err } // ここまで来たらすべて成功 → トランザクションをコミット return nil }) }
前述のコードに含まれるWithTxは次のようにトランザクションの開始からコミット/ロールバックまでを担います。
// コード例:トランザクションの開始やコミット/ロールバックを行うメソッド(※実際のコードではありません) // -------------------------------------------------- func (d *dbClient) WithTx(ctx context.Context, f TxFunc) error { // トランザクション開始 tx, err := d.BeginTx(ctx, nil) if err != nil { return err } defer func() { if err = tx.Rollback(); err != nil && !errors.Is(err, stdsql.ErrTxDone) { log.Warnw("failed to rollback transaction", "err", err) } }() // トランザクション用のクライアントを作成 txClient := &dbClient{DB: tx} // 渡された関数(ビジネスロジック)を実行 if err = f(ctx, txClient); err != nil { return err } if err = tx.Commit(); err != nil { return err } return nil }
方針2:クエリ結果は構造体にマップする
クエリ結果を map[string]any のような曖昧な型で受け取ることはせず、必ず対応する構造体を定義して厳密にマッピングします。これにより型の不一致などをコンパイル時に検知でき、コードの安全性と可読性を大きく高めます。
// コード例:ユーザー情報を取得するメソッド(※実際のコードではありません) // -------------------------------------------------- func (d *dbClient) GetUser(ctx context.Context, name UserName) (*User, error) { stmt := `SELECT id, name, role, email FROM users WHERE name = ?` var user User // 結果を構造体にマップする err := d.QueryRowContext(ctx, stmt, name).Scan( &user.ID, &user.Name, &user.Role, &user.Email, ) if err != nil { return nil, err } return &user, nil }
Step 3. 品質の礎を築く
ここでは移植したGoコードの品質をいかにして担保するか、その鍵となる生成AIを活用したテスト設計のプロセスを解説します。
1. AIによるテストケース作成
品質保証の第一歩は、テストケースの網羅性をいかに高めるかにかかっています。私たちはこのプロセスに生成AIを導入し、勘や経験だけに頼らない、体系的なテスト設計を実施しました。
まず、移植後のGoコードを入力として、生成AIにデシジョンテーブルを作成させます。これにより、コード内のあらゆる条件分岐が可視化され、テストすべき項目が網羅的に洗い出されます 。
次に、このデシジョンテーブルを元に、具体的なテストケースを生成します 。もしPerl側に既存のテストコードがあれば、それも重要なインプットとしてAIに与えることで、過去の知見を活かしたテストケースを作成できます。もちろん、AIが生成したアウトプットは鵜呑みにせず、必ず開発者自身がレビューし、プロジェクトの要求に合わせて加筆修正することが不可欠です。
今回は次のコードを例にテストケースを作成してみます。
// ユーザー情報を表す構造体 type User struct { ID int Name string } // データベースクライアント type DBClient struct { db *sql.DB } // ユーザーIDからユーザー情報を取得するメソッド func (d *DBClient) GetUserByID(ctx context.Context, userID int) (*User, error) { var user User query := "SELECT id, name FROM users WHERE id = $1" err := d.db.QueryRowContext(ctx, query, userID).Scan(&user.ID, &user.Name) if err != nil { return nil, err } return &user, nil }
以下はテストケース生成用プロンプトの例です。次のツールを使用して実行してみます。
- コーディングエージェント:Cline 3.30.3
- モデル:Cline + anthropic.claude-sonnet-4-20250514-v1:0
# 指示 - あなたはGo言語のテストに精通したシニアソフトウェアエンジニアです - 後述の「手順」に従って、@sample.goのGetUserByIDメソッドのテストケースを作成してください - ...もし参照すべき既存テストがあれば、ここに記載します... # 手順 ## 1. パラメーター(因子)と水準(値)の定義 - テスト対象の ***** 関数の結果に影響を与える主要なパラメーター(因子)をすべて特定してください - 特定した各パラメーターが取りうる水準(値)を関数の内容から洗い出してください ### サンプルテーブル 特定したパラメーターと水準を元に、デシジョンテーブルを次の形式で作成してください。 |条件|ルール1|ルール2|ルール3|ルール4| |:---|:---|:---|:---|:---| |userIDの存在|存在する|存在しない|不正な形式|存在する| |DB接続|正常|正常|正常|接続エラー| |アクション|ルール1|ルール2|ルール3|ルール4| |:---|:---|:---|:---|:---| |ユーザー情報を返す|Y|N|N|N| |ErrNotFoundを返す|N|Y|N|N| |ErrInvalidInputを返す|N|N|Y|N| |ErrDBConnectionを返す|N|N|N|Y| ## 2. 作成すべきテストケースの提案 - 手順1の分析に基づき、作成すべき具体的なテストケースを提案してください。 - 特に、デシジョンテーブルの観点(重要なルールの抜け)と、ペアワイズ法の観点(因子間の相互作用の抜け)の両方からアプローチしてください。 ### サンプルテーブル テストケースは次のフォーマットで提案してください。 |ルール番号|テスト概要|テスト詳細|確認すべきこと(Assertion)| |:---|:---|:---|:---| |1|正常系:存在するユーザーIDを指定した場合|DBにテストユーザー(ID: 1)を登録, GetUserByID(1)を呼び出す|ユーザー情報が正しく返却されること, エラーがnilであること| |2|異常系:存在しないユーザーIDを指定した場合|DBに存在しないID(例: 999)でGetUserByID(999)を呼び出す |ユーザー情報がnilであること, エラーがErrNotFoundであること| |3|異常系:不正な形式のユーザーIDを指定した場合|不正なID(例: -1)でGetUserByID(-1)を呼び出す|ユーザー情報がnilであること, エラーがErrInvalidInputであること|
上述のプロンプトを与えたところ、次のテーブルが生成されました(AIのMarkdown出力をテーブルに変換し、そのまま記載しています。内容の正確性にご注意ください)。
デシジョンテーブル
条件 | ルール1 | ルール2 | ルール3 | ルール4 | ルール5 | ルール6 |
userIDの値 | 正の整数(存在) | 正の整数(不存在) | 0以下 | 正の整数(存在) | 正の整数(存在) | 正の整数(存在) |
DB接続状態 | 正常 | 正常 | 正常 | 接続エラー | 正常 | 正常 |
context状態 | 正常 | 正常 | 正常 | 正常 | キャンセル済み | タイムアウト |
データ存在 | 存在 | 不存在 | – | – | 存在 | 存在 |
アクション | ルール1 | ルール2 | ルール3 | ルール4 | ルール5 | ルール6 |
ユーザー情報を返す | Y | N | N | N | N | N |
sql.ErrNoRowsを返す | N | Y | N | N | N | N |
クエリ実行(不正値でも実行される) | N | N | Y | N | N | N |
DB接続エラーを返す | N | N | N | Y | N | N |
context.Canceledを返す | N | N | N | N | Y | N |
context.DeadlineExceededを返す | N | N | N | N | N | Y |
テストケース
ルール番号 | テスト概要 | テスト詳細 | 確認すべきこと(Assertion) |
1 | 正常系:存在するユーザーIDを指定した場合 | DBにテストユーザー(ID: 1, Name: “test_user”)を登録, GetUserByID(ctx, 1)を呼び出す | ユーザー情報が正しく返却されること, エラーがnilであること |
2 | 異常系:存在しないユーザーIDを指定した場合 | DBに存在しないID(例: 999)でGetUserByID(ctx, 999)を呼び出す | ユーザー情報がnilであること, エラーがsql.ErrNoRowsであること |
3 | 境界値:userID=0を指定した場合 | userID=0でGetUserByID(ctx, 0)を呼び出す | 現在の実装ではクエリが実行される(改善の余地あり) |
4 | 境界値:負のuserIDを指定した場合 | userID=-1でGetUserByID(ctx, -1)を呼び出す | 現在の実装ではクエリが実行される(改善の余地あり) |
5 | 異常系:DB接続エラーの場合 | DB接続を閉じた状態でGetUserByID(ctx, 1)を呼び出す | ユーザー情報がnilであること, DB接続エラーが返されること |
6 | 異常系:contextがキャンセルされた場合 | キャンセル済みcontextでGetUserByID(ctx, 1)を呼び出す | ユーザー情報がnilであること, context.Canceledエラーが返されること |
7 | 異常系:contextがタイムアウトした場合 | タイムアウト済みcontextでGetUserByID(ctx, 1)を呼び出す | ユーザー情報がnilであること, context.DeadlineExceededエラーが返されること |
ただし、この手順をすべての関数やメソッドに適用するのは、現実的でない場合もあると思います。その際は複雑な状態を持つ機能に限定するなど、AIを活用する範囲を絞ることも有効な選択肢です。
なお、AIのアウトプットに対しては内容を確認した上で、必要ならフィードバックを与えて軌道修正します。私は普段Clineというツールを利用しているため、次のPlan/Actモードを使って内容を詰めています。
- Planモード
- AIがコードベースを探索し、ユーザーの要求を達成するための具体的な作業計画を立てます。
- Actモード
- Planモードで立てられた計画に基づき、実際にファイルの作成、コードの記述、コマンドの実行などを行います。計画を一つずつ着実に実行していく段階です。
2. AIによるテストコードの自動生成
テストケースが確定すれば、残るは実装です。この定型的かつ時間を要する作業こそ、生成AIが最も得意とする領域の一つです 。
確定したテストケース(前述のテーブル)をAIに入力として与え、テストコードを生成してもらいます。この際、単に指示を出すだけでなく、「チームで定めたコーディング規約やサンプルコードをプロンプトに含めることが、生成物の品質を安定させる上で極めて重要」です。効果的なプロンプトをチームの共有財産として蓄積していくことで、誰が実行しても一貫した品質のテストコードを生み出すことが可能になります。以下はテストコードを生成するための簡易的なプロンプトです。
# 指示 - あなたはGo言語のテストに精通したシニアソフトウェアエンジニアです - 後述の「テストケース」に従い、@sample.goのGetUserByID メソッドのテストコードを作成してください - 作成したテストコードは@sample_test.goにTestDBClient_GetUserByID関数として含めてください # コーディングプラクティス - テスト手法はテーブルドリブンスタイルを採用してください - 各テスト名は BDD 形式で記述してください(例: should return an error if an invalid prefix is in the path) ...【その他のコーディング規約なども、ここに記載します】... # サンプルコード ...【テストコードのサンプルを入力として与えることで生成されるコードの精度が良くなります】... # テストケース ...【前述のテストケースをここに記載します】...
このアプローチにより、開発者は面倒な実装作業から解放され、より本質的な「何をテストすべきか」という設計業務に集中できます。
さいごに:先人への敬意と、AIと歩む未来
今回のPerlからGoへの移行プロジェクトは、単なる言語の置き換え作業ではありませんでした。それは私たちエンジニアが生成AIという強力なパートナーと共に、複雑な課題にどう立ち向かうかを学ぶ実践の場でもありました。
私たちが向き合ったPerlコードの一行一行には、長年にわたりプロダクトを支え、進化させてきた先人たちの知恵と努力の結晶が刻み込まれています。その魂を受け継ぎながら、未来のために新たな一歩を踏み出す。それが私たちの移行プロジェクトです。
不慣れなPerlコードの意図をAIに問いかけ、リファクタリングの壁打ち相手になってもらう。そして、単調になりがちなテストコードの実装を手伝ってもらう。このようなAIとの協業は、先人たちが築いた土台の上に、より堅牢で未来志向のシステムを構築するための、現代の新しい開発スタイルと言えるでしょう。
もちろん、最終的な設計の判断や品質への責任は、私たち人間にあります。しかし、定型的な作業をAIに委ねることで、エンジニアはより創造的かつ本質的な課題解決に集中できるようになるはずです。
過去の資産を活かし、プロダクトの可能性を広げようと挑戦する方々にとって、この記事が一助となれば幸いです。