🖌️

VSCodeのCopilot Edits機能とソフトウェア設計を意識したプラクティスの紹介

2024/12/02に公開

はじめに

こんにちは。大学院生をしながら副業的にエンジニアをしているid157と申します。
この記事は、大学のあるサークルのOB/OGで行っているアドベントカレンダーの初日の記事になります!

https://adventar.org/calendars/10235

要約

  • VSCodeのCopilot Editsにより、VSCodeでもLLMをフル活用した開発が行えます
  • ソフトウェア設計を意識したテクニックにより、そこそこ保守性の良さそうなコードを出力できます
  • このようなCopilotをフルに活用したコーディングが主流になるにつれ、求められる人材が変化するのではないでしょうか
    • コード設計能力やセキュリティやパフォーマンスの専門家の需要が高まるはずです
    • 生成AIによるコード生成の恩恵を受けやすい形のチームビルディング・コードベースの設計・生成AIインフラ・セキュリティの整備を行える、LLM Development Architect的なポジションのエンジニアが生まれるのではないでしょうか

VSCodeのCopilot Edits機能とは

早速ですが、VSCodeのCopilot Edits機能について紹介したいと思います。この機能は、GitHub Copilotのサービスの一種であり、次のことが可能となります。

  • プロンプトを用いて、複数ファイルを横断しコード生成を行えます
    • とくに、以下の点が従来のCopilotの使い方では実現できなかったことであり、非常に便利です
      • 複数ファイルに対して一括でコード生成を行えます
      • 新規ファイルの作成が可能です
      • 新規ファイルのパスもよしなに決定してくれます
  • プロンプトの内容から、生成の際にattachmentする参考コードを自動で検索することができます
    • つまり、コードベースに対するRAG(Retrieval Augmented Generation)機能がデフォルトで搭載されています
    • 「参考コード」として渡すファイルは、自動検索の他にも現在開いているエディタの内容を指定することや、ファイル単位での指定が行えて細かく調整することができます

百聞は一見にしかずということで、実際に使用している様子が以下になります。

導入方法

  1. VSCodeをバージョン1.95にアップデートします
    a. このCopilot Editsは、VSCodeのバージョン1.95から有効化されています!
    b. ※VS Codeの更新は、公式のダウンロードページか、メニューバーから「Check for Update 」を選択して行うことができます。
  2. VSCodeの設定画面で、github.copilot.chat.edits.enabledtrueにする
  3. (任意)GitHub Copilot Chat拡張機能の最新版をインストールする(この記事では、バージョン0.23.2024111901を使用しています)
    a. 今回はコード生成のタスクに強みを持つ(と自分個人の感覚では思っている)モデルとして、Claude 3.5 Sonnetを使用したかったので、GitHub Copilot Chatのベータ版をインストールしました。この記事では、コード生成にすべてClaude 3.5 Sonnetを使用しています。

ソフトウェア設計を意識したプラクティス集

ここからは、自分がCopilot Edits機能を利用して実際に開発を行っていった中で気がついたプラクティスについて紹介していきます。
※掲載しているコードはすべて公開用に作っているものです。

Instructionファイル

Copilot Editsでは、現在のUIでは右下の入力欄にプロンプトを入力していくことになるのですが、入力欄が単純に狭いため入力がしづらいです。また、attachmentを選択する際に、「Codebase」を選択することで検索を行って関連するコードを渡せるのですが、その制御は思うように行かないことが多々あります。
そこで、共通して使用するプロンプトをファイルに参照しやすくすることと、attachmentに渡すファイルを制御することを目的に、Instructionとなるファイルをマークダウン形式で作成しておくと便利です。


Instructionsファイルの例。ファイル名を、*.instruction.mdの形式とすることで、attachmentする際のファイル検索で引っかかりやすくしている

attachmentファイルには、コードベースに変更を加える際の指示を書いてあります。たとえば、architecture.instruction.mdには、以下のような内容を書いています。工夫として、ファイルの冒頭に関連ファイルのセクションを設けておくことで、高い確率でファイルの自動attachment時に意図したファイルを選択してくれるようになりました。

architecture.instruction.md
# 関連ファイル
 * `app/inversify.types.ts`
 * `app/inversify.container.ts`

# Architecture Overview
このアプリケーションは、以下のArchitectureを採用しています。

 * UseCase層 (app/use-case/**-use-case.server.ts)
   * 特定の機能や動作を定義します
 * Service層 (app/service/**-service.server.ts)
   * それぞれのドメインのビジネスロジックを実装しています
 * Repository層(app/repository/**-repository.server.ts)
   * データアクセスの詳細を隠蔽しています
 * Controller層(route内のloader/action)
   * UseCaseやServiceを使い、クライアントサイドとの通信を担います。
   * Remixのloader、action関数内に書かれます

それぞれのレイヤーの責務は、レイヤーごとのドキュメントに書かれています。

# 新しいモジュールを追加するときは
このアプリケーションでは、モジュールの取り扱いのためにDIコンテナを利用しています。
具体的なライブラリとして、inversifyを使用しています。

## Instruction
1. `app/inversify.types.ts`に以下の形式で型を登録します


    ```diff
export const TYPES = {
  UserCreateUseCase: Symbol.for("UserCreateUseCase"),
+ NewModule: Symbol.for("NewModule")
}
    ```

2. `app/inversify.container.ts`に以下の形式で型を登録します

    ```diff
import UserCreateUseCase from "~/app/use-case/user-create-use-case.server.ts";
+import NewModule from "~/app/???/new-module.server.ts";

<中略>

container.bind<UserCreateUseCase>(TYPES.UserCreateUseCase).to(UserCreateUseCase)
+container.bind<NewModule>(TYPES.NewModule).to(NewModule)
    ```

レイヤごとのInstructionを用いて、モジュール単位でコード生成を行う

上記のInstrcutionファイルは、Repository層、Service層、UseCase層といったレイヤーごとに作成しておくと効果的だと感じました。レイヤーごとにやっていいこと・やってはいけないことを具体的に指示することによって、各層の責務を守ったコードを生成してくれるからです。
たとえば、UseCase層のInstructionファイルは以下のような書き方をしました。

use-case.instruction.md
# 関連ファイル
 * `instructions/architecture.instruction.md`
 * `app/use-case/**-use-case.server.ts`

# UseCase層

## UseCase層の役割
UseCase層は、ユーザーを作成するなどのアプリケーションの具体的な手続きを実装する層です。

## UseCase層でできること
 * UseCase層では、複数のServiceを組み合わせてビジネスロジックを実装できます
 * UseCase層では、LoggingServiceやCacheServiceを用いて、システム横断的な事項を処理できます。
 * UseCase層では、ビジネスロジックに関連するエラーのみを処理します。
 * データベースエラーなどの技術的なエラーはService層で処理し、UseCase層では抽象化されたエラーとして扱います。

## UseCase層でやってはいけないこと
 * UseCase層では、prismaを直接使用してはいけません。代わりにService層を使用するべきです。
 * UseCase層では、Repository層を直接使用してはいけません。Service層にコードを追加する必要があります。
 * Service層では、ルーティングやHTTPリクエスト/レスポンスに関する処理を含めてはいけません。それはController層(Remixのloader、action関数内)で行います。

## 推奨される実装パターン
Good Example:
    ```typescript
@injectable()
class UserRegistrationUseCase {
  constructor(
    @inject(TYPES.UserService)
    private readonly userService: UserService
  ) {}
  
  async execute(userData: UserRegistrationData): Promise<User> {
    // ビジネスロジックの実装
    const validatedData = this.validateUserData(userData);
    return this.userService.createUser(validatedData);
  }
}
    ```

Bad Example:
    ```typescript
@injectable()
class UserRegistrationUseCase {
  constructor(private prisma: PrismaClient) {} // ❌ Prismaの直接使用

  async execute(userData: UserRegistrationData): Promise<User> {
    return this.prisma.user.create({ // ❌ データアクセス層への直接アクセス
      data: userData
    });
  }
}

export { UserRegistrationUseCase }
    ```

このようなInstructionファイルを用いて、以下のようなプロンプトでモジュール単位で出力を行わせたところ、高い確率でレイヤーごとの責務を守ってコード生成が行われました。結果として、ツッコミどことはありつつも、保守性が中々悪くなさそうなコードが得られています。

プロンプト

以下のモジュールを作成し、inversifyとのつなぎこみまで行ってください。

  • ユーザーの仮登録を受け付けるユースケース(UserPreregisterUseCase)
    • 入力として、ユーザーのメールアドレス(email)、氏名(firstNameとlastName)、電話番号(phoneNumber)、パスワード(password)を受け取る
      • それぞれのバリデーションのメソッドもprivateメソッドとして実装する
    • パスワードから、パスワードのハッシュ値を生成する。saltとして、process.env.PASSWORD_SALTを使用する
    • 受け取った内容をUserテーブルに保存する
      • もし同じメールアドレスが既に登録されている場合は、エラーを返す
    • 認証リンクは、次の形式のクエリパラメータを付与する?invitationHash=<発行されたユーザーのID、メールアドレスをハッシュ化したもの>&uid=<ユーザーのUUID>
      • invitationHashのsaltとしては、process.env.INVITATION_SALTを使用する
    • LoggingServiceを使用してロギングする
      • ロギングの内容は、event: 'user_preregistered', payload: { userId: <ユーザーのID> }
    • 保存が完了したら、ユーザーに認証リンクが書かれたメールを送信する
      • このメールの送信は、MailServiceを使用する
出力結果

↓ 出力結果(これに加えて、インターフェイスの定義とinversifyへの登録コードも生成されています)

user-preregister-use-case.ts
import { User } from "@prisma/client";
import { inject, injectable } from "inversify";
import * as crypto from "crypto";
import { TYPES } from "../inversify.types";
import { IUserService } from "../interfaces/services/user-service.interface";
import { ILoggingService } from "../interfaces/services/logging-service.interface";
import { IMailService } from "../services/mail-service.server";

interface UserPreregisterData {
  email: string;
  firstName: string;
  lastName: string;
  phoneNumber: string;
  password: string;
}

@injectable()
export class UserPreregisterUseCase {
  constructor(
    @inject(TYPES.UserService)
    private readonly userService: IUserService,
    @inject(TYPES.LoggingService)
    private readonly loggingService: ILoggingService,
    @inject(TYPES.MailService)
    private readonly mailService: IMailService
  ) {}

  async execute(data: UserPreregisterData): Promise<User> {
    this.validateData(data);

    const passwordHash = this.hashPassword(data.password);
    const user = await this.userService.create({
      ...data,
      passwordHash,
      isConfirmed: false,
    });

    const invitationHash = this.generateInvitationHash(user.id, user.email);
    const confirmationLink = this.generateConfirmationLink(user.id, invitationHash);

    await this.loggingService.log({
      event: "user_preregistered",
      payload: { userId: user.id },
    });

    await this.mailService.sendMail(
      user.email,
      "Confirm your registration",
      `Please click the following link to confirm your registration:\n${confirmationLink}`
    );

    return user;
  }

  private validateData(data: UserPreregisterData): void {
    if (!this.validateEmail(data.email)) {
      throw new Error("Invalid email format");
    }
    if (!this.validateName(data.firstName)) {
      throw new Error("Invalid first name");
    }
    if (!this.validateName(data.lastName)) {
      throw new Error("Invalid last name");
    }
    if (!this.validatePhoneNumber(data.phoneNumber)) {
      throw new Error("Invalid phone number");
    }
    if (!this.validatePassword(data.password)) {
      throw new Error("Invalid password format");
    }
  }

  private validateEmail(email: string): boolean {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
  }

  private validateName(name: string): boolean {
    return name.length >= 2 && name.length <= 50;
  }

  private validatePhoneNumber(phone: string): boolean {
    return /^\+?[\d\s-]{10,}$/.test(phone);
  }

  private validatePassword(password: string): boolean {
    return password.length >= 8;
  }

  private hashPassword(password: string): string {
    return crypto
      .createHmac("sha256", process.env.PASSWORD_SALT || "")
      .update(password)
      .digest("hex");
  }

  private generateInvitationHash(userId: string, email: string): string {
    return crypto
      .createHmac("sha256", process.env.INVITATION_SALT || "")
      .update(`${userId}:${email}`)
      .digest("hex");
  }

  private generateConfirmationLink(userId: string, invitationHash: string): string {
    const baseUrl = process.env.APP_URL || "http://localhost:3000";
    return `${baseUrl}/confirm-registration?invitationHash=${invitationHash}&uid=${userId}`;
  }
}

(小技)<generate>タグによる生成

ソフトウェア設計とは関係ないですが、小技としてファイルの一部分だけでCopilot Editsによる生成を行いたいときは、<generate></generate>のようなタグで関連箇所を囲い、以下の定型文のプロンプトを投げることで指定箇所のみの生成を行ってくれました。これは、プロンプトを入力欄に書いていくよりも入力が行いやすく、またコードの生成箇所をピンポイントで特定できる点で便利なテクニックです。

↓プロンプト

<generate>タグの中身を生成してください

↓生成前のコード

user-preregister-use-case.ts
// <generate task="パスワードを、数字+英語の2種類が必ず含まれる12字以上のものしか受け付けないようにしてください。正規表現をlib以下に切り出してimportするようにしてください。" relatedFile="~/lib/password.ts">
  private validatePassword(password: string): void {
    if (password.length < 8) {
      throw new UserPreregistrationError(
        "Password must be at least 8 characters long",
      );
    }
  }
// </generate>

↓生成後のコード

user-preregister-use-case.ts
  private validatePassword(password: string): void {
    if (!PASSWORD_REGEX.test(password)) {
      throw new UserPreregistrationError(PASSWORD_REQUIREMENTS.DESCRIPTION);
    }
  }
lib/password.ts
export const PASSWORD_REGEX = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{12,}$/;

export const PASSWORD_REQUIREMENTS = {
  MIN_LENGTH: 12,
  DESCRIPTION: "Password must be at least 12 characters long and contain both letters and numbers",
};

最後に

生成AIによるコード生成を使う際の大前提

https://x.com/t_wada/status/1859756507115684348

これまでCopilot Editsを用いて、アーキテクチャに従う保守性の高いコードを生成するテクニックについて体験ベースで書いてきましたが、そこには重要な前提があります。それは、@t_wada氏が述べるように、生成AIにより「労力は外注化できるが、能力は外注できない」ということです(元の記事はこちら)。

どんなにプロンプトエンジニアリングや上記にあるようなテクニックを駆使して、自由自在にコード生成を操れるようになったとしても、LLMは(例えば)セキュリティやパフォーマンス、ソースコードの抽象化、ビジネスロジックの正確さには気を配ってはくれません。これらの箇所こそが、人間が集中すべき箇所です。

生成AIの登場による求められるタレントの変化

また、ソフトウェア設計を意識したCopilot Editsの利用の最中に、今後はコードベースの設計品質が開発速度を直接的に左右するようになるのではないかという実感を得ました。各レイヤのルールをインストラクションとして定め、それをLLMに守らせることができるテクニックさえあれば、完璧ではなくとも保守性の高いコードを従来よりも圧倒的に速いスピードで得ることができます。逆に、密結合・ファイルの置き場所がバラバラでカオスなコードベースでは、LLMの出力するコードはさらなるカオスを生む、読解に時間を費やし手直しが多く必要なコードを生み出してしまうでしょう。つまり、生成AIのさらなる導入というのは、エンジニアにとって大きなルールチェンジであり、新しいルールの下では、コード設計を行う能力がコードベースの保守性のみならず開発速度に直結する非常に重要な能力となるのではないでしょうか。また、生成AIにより代替不能である、セキュリティやパフォーマンスの専門知識、コードの抽象化能力を持つエンジニアも自ずと需要の高い存在となっていくでしょう。

さらに、生成AIによるコード生成の恩恵を受けやすい形のチームビルディング・コードベースの設計・インフラ/セキュリティの整備・インストラクションの最適化を行えるエンジニアは、喉から手が出るほど求められる存在になるのではないでしょうか。このような人がチームに一人いるだけで、チーム全体の開発スピードが4倍・5倍と向上していくと考えれば、マネジメント層からしたら高い報酬を払ってでもこのような人をチームに招こうとするのは容易に想像ができます。

このようなエンジニアは、おそらく設計能力がLLMと伴走する開発基盤を整える上でおそらく不可欠であることから、LLM Development Architect(LLMDev) みたいな名称で呼ばれることになるのではないでしょうか。

Discussion