🌊

ChatGPTにイベントソーシングのフレームワークコードの使い方を教えたら日本語の指示文から動作するドメインコードを生成してしまった。

GPT-4がリリースされましたね

株式会社ジェイテックジャパン CTOの高丘 @tomohisaです。3/14にGPT-4がリリースされました。ChatGPTの段階ですでにプログラマーにとても役に立つツールとなってきましたが、GPT-4ではよりコンテキストを理解したコード生成ができるようになりました。
この時問題になるのが、自分の使っているフレームワーク、プロジェクトにおいて正しいコードを生成するにはどうしたら良いかということです。もしかするとGPT-4だと仕様を伝えたら生成してくれるのではないかと思い、試してみました。

ChatGPTがコードを生成させるためには、質問の仕様とルールを作成することが重要になります。以下はChatGPTにどのように未知のコードの生成するか聞いてみた結果です。

ステップ1: プログラムの仕様を決める

まず、ChatGPTに生成させたいプログラムの仕様を決めます。これには以下の要素を含まれることが一般的です。

  • 入力: プログラムが受け取るべきデータやパラメータ
  • 処理: プログラムが実行すべきアルゴリズムや手順
  • 出力: プログラムが生成するべき結果やデータ

仕様が明確であればあるほど、ChatGPTが正確なコードを生成する可能性が高まります。

ステップ2: ルールを設定する

次に、プログラムに適用すべきルールを決めます。これには以下の要素が含まれます。

コーディングスタイル: 変数名や関数名の命名規則、インデントのスタイル、コメントのスタイルなど。
言語やフレームワーク: 使用するプログラミング言語、フレームワークやライブラリのバージョン。
エラー処理: エラーや例外が発生した場合の処理方法。
テスト: プログラムの機能や性能を確認するためのテスト方法やテストケース。

これらのルールを明確に設定することで、ChatGPTが期待するコードを生成する可能性が高まります。

ステップ3: 指示文を書く

プログラムの仕様とルールが決まったら、次にChatGPTに対する指示文を書きます。この指示文は、プログラムの目的、仕様、ルールをできるだけ明確に説明するよう心掛けましょう。指示文は以下のような形式で書くことができます。

ChatGPT さん、以下の仕様とルールに従って、[プログラム名]というプログラムを[プログラミング言語]で生成してください。

仕様:
- 入力: ...
- 処理: ...
- 出力: ...

ルール:
- コーディング規約: ...
- 言語機能: ...
- エラーハンドリング: ...
- パフォーマンス: ...
- セキュリティ: ...

実際にやってみる

うまく指示文を書けば、ChatGPTさんにコードを書いてもらえそうです。今回は以下のコード生成を試してみます。

ステップ1プログラムの仕様を決める

  • 開発中のイベントソーシングフレームワークSekibanに対応したイベントソーシングの集約。現在フレームワークはプライベートリポジトリにあるため、こちらから仕様を伝えないとGPTから予測できない。
  • ただ、イベントソーシングの基本と、他のフレームワークでのイベントソーシングの記述に関してはGPT-4はある程度知っている。
  • イベントソーシングのコマンドおよびコマンドハンドラー(コマンドからイベントを作成する処理)
  • イベントソーシングのイベントおよび、イベントによって集約がどのように変わるかを書く
  • フレームワークに、集約やコマンドを登録する依存関係の定義
  • 簡易のコマンド実行テスト
  • 指示は日本語で出す。そのため、変数名を決めることや、フレームワークの作法などがわかっていなくてはいけない。

このようなプログラムを作成できれば、フレームワークを使っていくにあたり、まずChatGPTに書かせてから調整していく方法を取ることができそうです。

ステップ2ルールを設定する

作成中のドキュメントを読み込ませて、そのドキュメントを出力結果に応じて調整することにより、以下のように作成しました。


Sekiban はイベントソーシングのフレームワークで dotnet 7 と C# 11を使用します
# 集約
## 集約のペイロード (record, immutable)
クラスに `AggregateId` を入れる必要はありません。例)BranchクラスにBranchIdプロパティは不要です。
ペイロードはイミュータブルに生成して下さい。コレクションはイミュータブルなものを使って下さい。ImmutableList, ImmutableDictionary 
public record Branch : IAggregatePayload
{
    public string Name {get; init;}
    public DataType Data {get; init;} = new DataType(string.Empty, 0);
    public ImmutableList<ItemType> Items = ImmutableList<ItemType>.Empty;
}
public record DataType(string Column1, int Column2);
public record ItemType(string Column3, int Column4);

public record Client : IDeletableAggregatePayload
{
    public Guid BranchId { get; init; }
    public string ClientName { get; init; } = string.Empty;
    public string ClientEmail { get; init; } = string.Empty;
    public bool IsDeleted { get; init; }
}

## コマンドとコマンドハンドラー
コマンドは `Guid GetAggregateId`に値を返す必要があります。プロパティの名前は、集約にあったもの、例えば `BranchId` や `ClientId`とします
コマンドはプライマリコンストラクタは使用しません。プロパティを使用して ` { get; init; }` の形式で記述します
コマンドが新規に集約を作成する目的の場合、`Guid.NewGuid()`を返すこともできます
コマンド内にコマンドハンドラクラス `Handler` を含みます。コマンドハンドラーは`ICommandHandler<TAggregatePayload, TCommand>`を実装します。
コマンドハンドラーはDIを使用することができます。(IAggregateLoader, IQueryExecuter など)

CommandAndCommandHandlerExample.cs
public record CreateClient : ICommand<Client>
{
    [Required]
    public Guid ClientId { get; init; }
    [Required]
    public Guid BranchId { get; init; }
    [Required]
    public string ClientName { get; init; }
    [Required]
    public string ClientEmail { get; init; }
    public Guid GetAggregateId() => ClientId;

    // implementing command handler
    public class Handler : ICommandHandler<Client, CreateClient>
    {
        private readonly IAggregateLoader aggregateLoader;
        private readonly IQueryExecutor queryExecutor;
        public Handler(IAggregateLoader aggregateLoader, IQueryExecutor queryExecutor)
        {
            this.aggregateLoader = aggregateLoader;
            this.queryExecutor = queryExecutor;
        }

        public async IAsyncEnumerable<IEventPayloadApplicableTo<Client>> HandleCommandAsync(
            Func<AggregateState<Client>> getAggregateStateState,
            CreateClient command)
        {
            // Check if branch exists
            var branchExists
                = await queryExecutor
                    .ForAggregateQueryAsync<Branch, BranchExistsQuery, BranchExistsQuery.QueryParameter, bool>(
                        new BranchExistsQuery.QueryParameter(command.BranchId));
            if (!branchExists)
            {
                throw new SekibanAggregateNotExistsException(command.BranchId, nameof(Branch));
            }
            // Check no email duplicates
            var emailExists
                = await queryExecutor
                    .ForAggregateQueryAsync<Client, ClientEmailExistsQuery, ClientEmailExistsQuery.QueryParameter,
                        bool>(new ClientEmailExistsQuery.QueryParameter(command.ClientEmail));
            if (emailExists)
            {
                throw new SekibanEmailAlreadyRegistered();
            }
            yield return new ClientCreated(command.BranchId, command.ClientName, command.ClientEmail);
        }
    }
}
public record ChangeClientName : IVersionValidationCommand<Client>
{
    [Required]
    public Guid ClientId { get; init; }
    [Required]
    public string ClientName { get; init; }

    public ChangeClientName() : this(Guid.Empty, string.Empty) { }
    public override Guid GetAggregateId() => ClientId;
    public class Handler : IVersionValidationCommandHandler<Client, ChangeClientName>
    {
        public override ChangeClientName CleanupCommandIfNeeded(ChangeClientName command) => command with { ClientName = "stripped for security" };
        protected override async IAsyncEnumerable<IEventPayloadApplicableTo<Client>> HandleCommandAsync(
            Func<AggregateState<Client>> getAggregateState,
            ChangeClientName command)
        {
            await Task.CompletedTask;
            yield return new ClientNameChanged(command.ClientName);
        }
    }
}

## イベント
イベントは発生した事実を記録します。OnEventにはシンプルな変換を記述し、例外は発生しません。

EventSample.cs
public record BranchCreated(string Name) : IEventPayload<Branch, BranchCreated>
{
    public static Branch OnEvent(Branch aggregatePayload, Event<BranchCreated> ev) => new Branch(ev.Payload.Name);
}
public record BranchNameChanged(string NameToChange) : IEventPayload<Branch, BranchNameChanged>
{
    public static Branch OnEvent(Branch aggregatePayload, Event<BranchNameChanged> ev) => payload with { Name = ev.Payload.NameToChange };
}

# 依存関係
## ドメインの依存関係
ドメイン内の集約、コマンド、コマンドハンドラなどの関係を記します

public class CustomerDependency : DomainDependencyDefinitionBase
{
    public override Assembly GetExecutingAssembly() => Assembly.GetExecutingAssembly();
    protected override void Define()
    {
        AddAggregate<Branch>()
            .AddCommandHandler<CreateBranch, CreateBranch.Handler>()
            .AddAggregateQuery<BranchExistsQuery>();

        AddAggregate<Client>()
            .AddCommandHandler<CreateClient, CreateClient.Handler>()
            .AddEventSubscriber<ClientCreated, ClientCreatedSubscriber>()
            .AddSingleProjection<ClientNameHistoryProjection>()
            .AddSingleProjectionListQuery<ClientNameHistoryProjectionQuery>()
            .AddAggregateQuery<ClientEmailExistsQuery>()
            .AddAggregateListQuery<BasicClientQuery>()
            .AddSingleProjectionQuery<ClientNameHistoryProjectionCountQuery>();
    }
}

# テスト
## 集約テスト

public class LoyaltyPointTest : AggregateTest<LoyaltyPoint, FeatureCheckDependency>
{
    [Fact]
    public void DeleteClientWillDeleteLoyaltyPointTest()
    {
        var branchId = RunEnvironmentCommand(new CreateBranch() { Name = "Test"});
        var clientId = RunEnvironmentCommand(new CreateClient() { BranchId = branchId, ClientName = "Test Name", ClientEmail = "test@example.com" });
        WhenCommand(new CreateLoyaltyPoint() { ClientId = clientId, Point = 10 });
        RunEnvironmentCommandWithPublish(new DeleteClient() { ClientId = clientId });
        var timeStamp = GetLatestEnvironmentEvents().Where(m => m.GetPayload() is LoyaltyPointDeleted).FirstOrDefault()?.TimeStamp;
        ThenPayloadIs(new LoyaltyPoint(10, timeStamp, true));
    }
}
---

この中では言葉での説明もある程度行なっていますが、主にサンプルコードを貼りつけました。上記の部分は基本ルールとなっており、固定で使うことを想定しています。最初指示文は英語で書いていたのですが、英語で書いても日本語で書いても精度は変わりませんでした。

ステップ3 : 指示文を作成する

以下の指示文を作成しました。この指示文は作りたい機能によって変化させることを想定しています。今回は、ショッピングカートの集約を作成させてみます。

---
上記はSEKIBANを使用してイベントソーシングを実装する方法です。
上記を参考にして、以下の集約を実装して下さい。

ショッピングカートの集約 ShoppingCart このクラスにShoppingCartId は不要です。
以下のコマンドおよびコマンドハンドラー
 - アイテム追加
 - アイテム削除
 - クレジットカード追加 
 - 発送先住所追加
各コマンドには ShoppingCartIdが必要です。

上記に必要なイベントも定義して下さい
ドメイン依存関係を設定したクラスも作成して下さい
アイテムは ProductCodeをキーとして下さい

請求住所には以下の項目を含みます
- 郵便番号
- 都道府県
- 市区町村
- 市区町村以降の住所
- マンション、アパートなどの名前及び部屋番号

クレジットカードには以下の情報が必要です。
   - クレジットカード番号
   - 有効期限 年、月
   - CVV
   - 名前
   - 請求先住所

請求先住所と、発送先住所は別のrecordとして定義して下さい。

都道府県は日本の47都道府県をenumで定義して使用して下さい。

各コマンドに対する集約テストAggregateTestを継承して書いて下さい。

わからないことがあったら質問して下さい。

この中では基本的に日本語で説明していますが、以下の文の部分だけ希望する結果を出すために具体的なコードを指示しています。サンプルコードにはショッピングカートについての情報は含まれていないので、サンプルコードから英語の変数名を導き出す必要があります。

あとはステップ2のルールの分とステップ3の指示文をChatGPTに流してみましょう。

ChatGPT(GPT-4)の出力結果

picture 1

picture 2

picture 3

このように、ある程度打ったら途中で止まってしまいますが、続けるようにお願いすると続けてくれます。

picture 4

続けたあとはコードブロックが変になりますが、貼り付けがわで注意すればコードとしては問題ありません。

picture 5

picture 6

picture 7

picture 8

picture 9

picture 10

この結果はびっくりしました。もちろん、最初から完璧なものができたわけではありませんでした。それでも最初の段階から細かなエラーがあるだけという状態で、指示文の間違えを修正したり、わかりやすくすることによって安定してコンパイルの通る結果を出力するようになりました。

実行結果

今回はWeb APIを作るところまでは指示していないため、以下の簡単なWeb APIを作るコードを記述しました。


public class GptSampleWebDependency : ShoppingCartDependency, IWebDependencyDefinition
{
public AuthorizeDefinitionCollection AuthorizationDefinitions => new(new Allow<AllMethod>());
public SekibanControllerOptions Options => new();
public bool ShouldMakeSimpleAggregateListQueries => true;
public bool ShouldMakeSimpleSingleProjectionListQueries => true;
}


var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

// Sekiban Web Setting
builder.Services.AddSekibanWebAddon(new GptSampleWebDependency());
// Sekiban Core Setting
builder.Services.AddSekibanCoreWithDependency(new ShoppingCartDependency(), configuration: builder.Configuration);
// Sekiban Cosmos Setting
builder.Services.AddSekibanCosmosDB();

// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(
    config =>
    {
        config.CustomSchemaIds(x => x.FullName);
        config.SchemaFilter<NamespaceSchemaFilter>();
    });
var app = builder.Build();



// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

app.UseAuthorization();

app.MapControllers();

app.Run();

これはWeb APIのプロジェクトで自動生成したものに少しだけ追記した形です。あとはappsettings.jsonに接続の設定を記述しました。

これにより、実行すると以下の結果となりました。

picture 11

Sekibanはイベントソーシングの集約、コマンドとイベントを定義すると、デフォルトのAPIエンドポイントを自動作成する機能があります。この機能を使用して作ったコマンドがWeb APIになったため、すぐにフロントエンドからテストをできます。

まとめ

もちろん、この作成したコードでリリースできるわけではなく、データの検証や複数の集約を関係させてチェックする処理などは追記してアプリとして正しい形にしていく必要があります。
特にクラスの名前の付け方やコマンドハンドラーの書き方のルールなどは、慣れないと書き方が面倒な部分を一気に生成してきたのは非常に驚きでした。
今回は1ファイルにまとめてコードをはりつけましたが、C#の場合はクラスごと1ファイルにコピーして、変更が必要なときは現在のコードを貼り付けて修正させるなどが必要です。

これから、エディターと連携して、必要な部分のコードを共有して変更して自動でソースを作るなどの拡張機能が出てくるでしょう。APIを使用してこれを行う方法についても考えていきたいです。

Sekibanは現在オープンソース化に向けて準備中ですが、今年の早い段階でリリース予定です。ChatGPTを使うことにより、まずは日本語で集約の仕様を考えて、それをAIに指示することにより、たたき台を作成してくれてくれるようになります。それを動作させたり、テストの内容を調整して完成させていくことにより、今まで以上高速にイベントソーシングの開発ができるようになればいいなと考えています。

またSekibanがリリースされて、ChatGPTのデフォルトプロンプトなどが完成したらこちらのブログや私のTwitterで報告していきます。

ジェイテックジャパンブログ

Discussion