1
0

More than 1 year has passed since last update.

【第8回】ChatGPTで大規模システムは作れるか?(ドメイン駆動設計×クリーンアーキテクチャー編)

Posted at

経緯

この記事は以下の記事の続き。第7回です。
 第1回 全体計画~要件定義編
 第2回 アーキテクチャ設計~データベース設計編
 第3回 API設計~インフラ設計編
 第4回 設計工程の振り返り編
 第5回 コーディング~疎通編
 第6回 画面デザイン編
 第6.5回 画面デザイン編おまけ AAでATOMIC
 第7回 GPT API+Angularで画面作成編

前回からついにAPIを利用した大量生成をやり始めました。
まず始めにChatGPTで適当にユーザーシナリオを作成し、それを基に全16ステップの工程に分けてOpenAIのAPIを投げまくる自動化スクリプトを作成してAngularの画面生成を行いました。(あんまり上手くは行ってないので、気になる方は第7回の記事もご高覧下さい)

というわけで今回はサーバーサイドの実装をしていこうと思います。

余談ですが、ChatGPTはブラウザで使うもので、APIはgpt-3.5-turbo、gpt-4等と呼ぶのが正しいらしいのですが、わかりにくいのでAPIはChatAPIと書くことにします。

ポイント

今回のポイントは2点です。

  • タスク分割:大きすぎず小さすぎず、適切な粒度でタスク分割したい。
    ⇒ 分割の基準としてドメイン駆動設計(DDD)の用語を使う。

  • 生成範囲を限定:LLMの苦手分野と得意分野を切り分けて、得意分野だけに専念させたい。
    ⇒ 古典的なプログラミングで解決可能な課題はやらせない。推論に特化させる。

言い換えると、量的分割質的分割みたいな感じです。

量的分割は、単純にトークン上限1に行かないように制御する目的。
質的分割は、ChatAPIだと精度が悪いこと(例えばimport文等2)を古典的プログラミングで解消してやることで全体としての精度を向上することが目的。

この辺りをどのように処したかを纏めたのが今回の記事です。

※この記事は「ドメイン駆動設計の何たるか」には言及しないつもりです。
単に用語が便利だったからドメイン駆動設計という言葉を使うだけであり、ドメイン駆動設計についての知見を深めたい方向けの記事ではありません。あくまでChatGPT/APIをどう使うか、が関心事です。
逆に言うとドメイン駆動設計についてご存じない方、興味無い方でも前提知識無しで読んでいただけると思います。

タスク分割(量的分割)

タスク分割は本連載の一貫したテーマでもあります。

ChatGPT/APIはトークン数に制限があるので1回のやりとりで大量の生成はできません。
なので、タスクを適切に分割、実行して、結果を組み合わせることで大きなものを作ろう、ということです。
タスクをどのように分割するか、またそれをどのように文章化(プロンプト化)するかはとても難しいところで、これまでも色々と試行錯誤をしてきました。

世の中では各種のプロンプトエンジニアリングが喧伝されているように、タスクの規定を工夫することで結果の精度は上がります。
しかし、独自の規定を細かく書くよりも 最初から共通認識がある用語があるなら、それを使った方が早くて確実 と考えられます。
そのため今回は、システム設計周りの抽象概念が良い感じに言語化されている「ドメイン駆動設計」を下敷きにしてプロンプトを作成します。

共通言語があるとコミュニケーションがとりやすいというのは人間でも同じですね。

タスク分割でやったこと

まずChatGPTにドメイン駆動設計について聞いてみると、さすがによくご存じでした。

image.png

上記の一覧は「工程によるタスク分割」そのものです。
「ドメイン駆動設計でシステム開発して」では何もやってくれませんが、分割した工程ごとに指示を出せば工程に応じたアウトプットが得られます。得られたアウトプットを次の工程にインプットに利用することで一貫性を担保して工程を進めることができます。

上記の工程のうち、今回は2~5を対象に、実際にChatAPIでシステム開発を行っていきます。
ざっくり全体感を言うと、2でテーブル一覧等の各種の一覧を作って、3で一覧を「コンテキスト(後述します)」の単位で分割分類し、5で詳細化する、というワークフローになります。

下図の四角の一個がChatAPIの1回のプロンプトに該当するイメージです。

ChatAPIの出力を分割している個所については、APIの出力をChatAPIでJSON形式に変換してもらってからプログラム的に処理していきます。

例えば「3.コンテキストマッピング」の出力例は以下のような感じになります。
これをProductManagement、InvestmentGoal等に分割して次のChatAPIのプロンプトの入力情報にする、というのは古典的プログラムであれば造作もないことなので、そのように処理していく、ということです。

{"ProductManagement":{"Entities":["InvestmentTrust","TrustPrice","TrustFee"],"ValueObjects":["TrustDetails","PerformanceHistory","ExpenseRatio"],"Aggregates":["InvestmentTrustAggregate"],"DomainServices":["ProductManagementService"],"DomainEvents":["InvestmentTrustCreated","InvestmentTrustUpdated"]},
"OrderManagement":{"Entities":["Order","OrderStatus","OrderHistory"],"ValueObjects":["PurchaseOrder","SellOrder"],"Aggregates":["OrderAggregate"],"DomainServices":["OrderManagementService"],"DomainEvents":["OrderPlaced","OrderStatusChanged"]},
"CustomerManagement":{"Entities":["UserAccount"],"ValueObjects":["InvestmentGoal","RiskTolerance","Portfolio","Notification","UserInformation","InvestmentProfile"],"Aggregates":["UserAccountAggregate"],"DomainServices":["CustomerManagementService"],"DomainEvents":["UserAccountCreated","UserAccountUpdated"]},
"ResearchInformation":{"Entities":["ResearchReport"],"ValueObjects":["InvestmentAdvice","TrustInformation"],"Aggregates":["ResearchReportAggregate"],"DomainServices":["ResearchInformationService"],"DomainEvents":["ResearchReportCreated","ResearchReportUpdated"]},
"ReportGeneration":{"Entities":["TrustEvaluation","ProfitRate","PeriodicReport","LegalReport","ReportContent"],"Aggregates":["ReportAggregate"],"DomainServices":["ReportGenerationService"],"DomainEvents":["ReportGenerated"]},
"SecurityAndAuthentication":{"Entities":["AccessControl","StaffAccount","AgencyAccount","AuthenticationMethod","Credentials"],"Aggregates":["AuthenticationAggregate"],"DomainServices":["SecurityAndAuthenticationService"]},
"RewardCalculation":{"Entities":["Reward","Fee","PaymentHistory"],"ValueObjects":["RewardAmount","FeeAmount"],"Aggregates":["RewardCalculationAggregate"],"DomainServices":["RewardCalculationService"],"DomainEvents":["RewardCalculated","FeeCalculated"]}}

まず2について解説。

 2. ドメインモデルの設計

  • ドメインモデルを洗練させ、ビジネスルールやエンティティ、値オブジェクト、集約などを定義します。
  • ドメインエキスパートとの協力を通じて、モデルの正確性と完全性を確認します。
  • ビジネスルールとは、例えば以下のような、いわゆる機能とかAPI仕様とか呼ばれるような類のものです。

    • 「消費税は10%計算すること」等の何等か固有値を含むもの等
    • 「管理者はデータ更新が可能、利用者は参照のみ」等、何らかの状態に応じて選択的に機能を適用する等
  • エンティティ、値オブジェクト、集約は、大体DBのテーブルを表現するものだと思ってよいです。

    • エンティティ:DBのテーブル
    • 値オブジェクト:テーブルの列をグループ化したもの。姓と名を合わせて氏名という値オブジェクトにするとか。
    • 集約:名前の通り、セットで使うテーブルの紐づけ情報です。

ざっくり言い換えるとAPI一覧とテーブル一覧が出来る、と思って頂ければ大体合ってるかと思います。
ただし、API一覧は「APIの名前だけ」、テーブル一覧は「テーブルの名前だけ」等、次工程の「分類分割」に必要となる最低限の情報にとどめます。

何故最低限の情報に留めるかというと、この段階でAPIのエンドポイント等の情報もセットで生成してしまうと、ChatAPIのトークン上限に掛かってしまうからです。
この後必要になる情報だけに留めて生成することで、ある程度大規模な要件でもトークン上限エラーにならないよう調整できます。


続いて3について

 3. コンテキストマッピング

  • ドメインモデルと他のコンテキスト(サブドメインや外部システム)との関係性をマップします。
  • コンテキスト間の境界を定義し、相互作用を明確にします。

これは先ほど作成したAPI一覧とテーブル一覧を、コンテキストという単位で分割するという意味です。

何故このような分割をするかというと、以前、第4回辺りでは一覧化したものを一行ずつ詳細化する、ということをやってみましたが、それだと関連する情報(JOINすべきテーブルなど)が漏れてしまって正確な結果が得られない、ということがありました。
なので今回はこの「コンテキスト」という単位を使うことで、情報共有が必要な範囲でグルーピングする(情報共有が必要ない範囲は分割する)ということをしています。
メタ視点で考えると、それぞれのコンテキストがどういう意味を持つかは重要ではなく、「ドメイン駆動設計の"コンテキスト"の概念で分割して」という言葉を使うとChatAPIが適切に区切ってくれるよ、ということがポイントです。


4はアーキテクチャの話。これは無視でもOK

 4. アーキテクチャの設計

  • ドメインモデルに基づいて、システム全体のアーキテクチャを設計します。
  • ドメイン駆動設計のパターン(エンティティ、値オブジェクト、集約、リポジトリなど)を適用します。

アーキテクチャはChatGPTに検討してもらうまでもないことなので最初から決め打ちにしています。
一般的なWeb三層で、中身はこの辺↓を採用していることにします。

  • Server Side Framework: Spring Boot (JPA, Web, Lombok, etc.)
  • Frontend Framework: React (Chakra-UI, etc.)
  • Database: PostgreSQL
  • Infrastructure: AWS

ポイントはSpringBootでAPIサーバー(notJSP)だよ、ということが伝わればいいかなと思います。


5の話。詳細化。

 5. エリック・エヴァンスのタクティカル ドメイン駆動設計

  • ドメイン駆動設計のタクティカルなアプローチを使用して、個々のドメイン要素の詳細な設計を行います。
  • エンティティ、値オブジェクト、集約、サービス、ファクトリ、リポジトリなどのパターンを適用します。

ここでは2で作ったAPI一覧とテーブル一覧の詳細化していきます。
今はただの名前一覧なので、情報を補充するとそれっぽくなります。

  • API一覧:エンドポイントやRequest/Responseの型を定義する。
  • テーブル一覧:項目の名前と型等を定義する。

ここまででドメインモデルの生成が出来ました。
これ以降はソースコードの生成に入っていきます。

生成範囲を限定(質的分割)

ソースコードの生成にあたっては「生成範囲を限定」することが重要です。
まぁこれもタスク分割といえばそうではあるんですが、先ほどまでのようにトークン上限を気にしたものではなく、品質、精度に注目したものです。

例えば、この連載の以前の回でもChatGPT/APIでプログラム生成をやってきましたが、その知見としてimport文の生成が苦手だ、ということがわかっています。
やってみればすぐわかると思いますが、結構な頻度でimport文の抜け漏れや階層ずれ等を起こします。
これを毎回手で補正するのはダルすぎます。

一方、import文の自動補完は既存のIDE等ではごく当たり前の機能であることからわかるように、古典的プログラミングで解決可能な問題です。

そうです。ソースコード生成においては、1つのソースの中でもタスクの切り分けが必要ということです。
超面倒です。超面倒ですが、、これは一度やり方を確立すればあとは何度でも使えると思うので頑張って切り分けていきます。

import文以外にもシグネチャ系、ボイラープレート系は古典的プログラムの得意分野でもありますので、この辺りも対象になります。

クリーンアーキテクチャーでソース生成

ここからは既に作成済みのドメインモデルを基にソースを生成(javaコードに変換)していきます。

ドメインモデルはクリーンアーキテクチャーと親和性が高いので、Entity、Repository、Controller、Serviceの構成とします。
ドメインモデルさえ作成できていれば、Service以外のソースは古典的プログラムで簡単に作成できます。
実際の結果は以下の通りです。

Entityのサンプル
package com.example.demo.entity;

import com.example.demo.base.entity.BaseEntity;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;
import com.fasterxml.jackson.annotation.JsonUnwrapped;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;

@Builder
@Data
@AllArgsConstructor
@NoArgsConstructor
@EqualsAndHashCode(callSuper = false)
@Entity
@Table(name = "t_order_history")
public class OrderHistory extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    @Column
    private Integer orderId;

    @Enumerated(EnumType.STRING)
    private Status status;

    @Column(columnDefinition = "TIMESTAMP")
    private LocalDateTime timestamp;

}
Repositoryのサンプル
package com.example.demo.repository;

import com.example.demo.entity.*;
import java.util.List;
import java.util.Optional;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface OrderRepository extends JpaRepository<Order, Integer> {

}
Controllerのサンプル
package com.example.demo.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.validation.BindingResult;
import com.example.demo.entity.*;
import com.example.demo.service.OrderManagementService;
import jakarta.validation.Valid;
import java.math.BigDecimal;
import java.util.*;
import java.time.*;

@RestController
public class OrderManagementController {

    @Autowired
    private OrderManagementService orderManagementService;

    @PostMapping("/orders")
    public Order createOrder(@Valid @RequestBody OrderManagementService.CreateOrderRequest requestBody) {
        return orderManagementService.createOrder(requestBody);
    }

    @PutMapping("/orders/{id}/status")
    public OrderStatus updateOrderStatus(@PathVariable Integer id, @Valid @RequestBody OrderManagementService.UpdateOrderStatusRequest requestBody) {
        return orderManagementService.updateOrderStatus(id, requestBody);
    }

    @GetMapping("/orders/{id}/history")
    public List<OrderHistory> getOrderHistory(@PathVariable Integer id) {
        return orderManagementService.getOrderHistory(id);
    }

    @GetMapping("/orders/users/{id}")
    public List<Order> getOrdersByUser(@PathVariable Integer id) {
        return orderManagementService.getOrdersByUser(id);
    }

    @GetMapping("/orders/investment-trusts/{id}")
    public List<Order> getOrdersByInvestmentTrust(@PathVariable Integer id) {
        return orderManagementService.getOrdersByInvestmentTrust(id);
    }

}

古典的プログラムで作成できるのはここまでで、service の implement はChatAPIを使う必要があります。
ただし、serviceについて全量ChatAPIに投げるのではなく、先ほど解説した通り、古典的プログラムでひな形(import文やシグネチャ情報等、モデルから生成できるもの)を作成します。

どうでしょうか、これと要件定義書セットで渡したらChatAPIさんでも情報埋められそうな気がしませんか?

package com.example.demo.service.impl;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import com.example.demo.entity.*;
import com.example.demo.repository.*;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;
import jakarta.validation.constraints.*;
import lombok.Data;

@Service
public class OrderManagementServiceImpl {

    @Autowired
    private OrderRepository orderRepository;

    @Autowired
    private OrderStatusRepository orderStatusRepository;

    @Autowired
    private OrderHistoryRepository orderHistoryRepository;

    @Data
    public static class CreateOrderRequest {
        @NotNull
        private Integer userAccountId;
        @NotNull
        private Integer investmentTrustId;
        @NotNull
        private String orderType;
        @NotNull
        private Double amount;
    }
    public Order createOrder(CreateOrderRequest requestBody) {
        // TODO implementation
    }

    @Data
    public static class UpdateOrderStatusRequest {
        @NotNull
        private String status;
    }
    public OrderStatus updateOrderStatus(Integer id, UpdateOrderStatusRequest requestBody) {
        // TODO implementation
    }

    public List<OrderHistory> getOrderHistory(Integer id) {
        // TODO implementation
    }

    public List<Order> getOrdersByUser(Integer id) {
        // TODO implementation
    }

    public List<Order> getOrdersByInvestmentTrust(Integer id) {
        // TODO implementation
    }

}

こをひな形として、要件定義書とセットでプロンプトに入れて、TODO部分を埋めてください的なことを指示すると、ソースの中身を書いてくれます。

ポイントは、ソースコードの中身もJSON形式で返却してもらうことです。
何故なら、ここまで丁寧にひな形を渡しても、全量作成を依頼するとせっかく書いてあったimport文やメソッドシグネチャが滅茶苦茶になって返ってきたりするので、必要最小限の範囲のコードだけ生成させて、本体への適用は古典的プログラムでやる、という役割分担にしています。
こうすることでプログラム間のインターフェースは古典的プログラムでしか編集しないので、全体整合性が圧倒的にとりやすくなります。

{
    "additionalJPAMethods": {},
    "methods": {
        "createOrder": {
            "annotations": ["@Service","@Data","@Autowired","@NotNull","@PostMapping"],
            "body": "        Order order = Order.builder()\n                .userAccountId(requestBody.getUserAccountId())\n                .investmentTrustId(requestBody.getInvestmentTrustId())\n                .orderType(requestBody.getOrderType())\n                .amount(BigDecimal.valueOf(requestBody.getAmount()))\n                .orderStatus(Status.PENDING)\n                .createdAt(LocalDateTime.now())\n                .build();\n        return orderRepository.save(order);"
        },
        "updateOrderStatus": {
            "annotations": ["@Service","@Data","@Autowired","@NotNull","@PutMapping"],
            "body": "        Order order = orderRepository.findById(id)\n                .orElseThrow(() -> new RuntimeException(\"Order not found\"));\n        order.setOrderStatus(Status.valueOf(requestBody.getStatus()));\n        orderRepository.save(order);\n        OrderStatus orderStatus = OrderStatus.builder()\n                .orderId(id)\n                .status(order.getOrderStatus())\n                .updatedAt(LocalDateTime.now())\n                .build();\n        return orderStatusRepository.save(orderStatus);"
        },
        "getOrderHistory": {
            "annotations": ["@Service","@GetMapping"],
            "body": "        return orderHistoryRepository.findByOrderId(id);"
        },
        "getOrdersByUser": {
            "annotations": ["@Service","@GetMapping"],
            "body": "        return orderRepository.findByUserAccountId(id);"
        },
        "getOrdersByInvestmentTrust": {
            "annotations": ["@Service","@GetMapping"],
            "body": "        return orderRepository.findByInvestmentTrustId(id);"
        }
    },
    "additionalImports": [
        "java.util.List",
        "org.springframework.stereotype.Service",
        "org.springframework.beans.factory.annotation.Autowired",
        "com.example.demo.entity.Order",
        "com.example.demo.repository.OrderRepository",
        "com.example.demo.entity.OrderStatus",
        "com.example.demo.repository.OrderStatusRepository",
        "com.example.demo.entity.OrderHistory",
        "com.example.demo.repository.OrderHistoryRepository",
        "jakarta.validation.constraints.NotNull",
        "lombok.Data"
    ]
}

ちなみに、上記の例ではannotationisを作らせていますが無視しています(@GetMapping等はController層で指定するものだし、@Serviceはクラスにて指定済みなので)
ではなぜ書かせたか?というと、これを書かせないと謎にbody部に無理やりアノテーションから書き始める、ということが多々発生したからです。全体の文脈的にメソッド本体文の前にアノテーションを書きたくて仕方ないんだろうということでannotations用の枠を設けてやったところ、bodyには大人しく中身だけを書くようになったので、annotationsは書かせて捨てる、という運用にしています。こういうところがプロンプトエンジニアリング?的で面倒なところです。

まとめ

製作物のstats情報は以下の通りです。

分類 備考
サービス本数 7本
テーブル数 24個
API数 44本
プログラム本数 88本 古典的プログラムで作成分も含む
プログラム行数 3273行 コメント空行含む
コスト 約50円 ほんとは忘れた。でもだいたいこんなもん。

API44本でプログラム行数3,000行は明らかに中身スカスカなのでちょっとこれだと物足りないですね。

なお、言ってませんでしたが今回与えたお題は「投資信託の販売管理システムを作って」です。

自動生成されたものがそのまま動く状態まではいけませんでしたが、全体のデバッグは10分弱で終わったのでまぁ良しとします。どのくらい修正したかはココでdiffが見られます

生成物全体はこれ↓です。application.ymlでdbの設定だけ書き換えればとりあえず起動する状態にはなってます。

promptsというディレクトリに全プロンプトの履歴が入ってます。
domain-modelsに成型したドメインモデルのjsonが入っています。

画面と違ってAPIはそこまで難しくは無いので、前回と比べてエラー率もかなり低く、これは使おうと思えば使えなくはないか?という気はしました。

ただ、機能面ではバリデーション×JOIN機能付きのAccessみたいなものが出来ただけで、実際の複雑なビジネスロジックは別途作成する必要がありそうです。

まぁ最初に自動生成でここまで作って、後はcopilot使いながら開発とかすれば開発のスタートダッシュとしてはまんざらナシではないかな、、?どうかな?というレベルですね。

この後の企画としては画面もAPIも自動生成してどこまで行けるかな?というのをやってみようかな、どうしようかな、、ということで今回は終了とします。

紹介

以上のようなことを自動で実行するプログラムを以下に置いてあります。

ChatAPIを投げまくるだけのプログラムですが、stream化の対応や料金計算の機能も実装しているので、
似たようなことをしている方はどうぞ覗いてみてください。

動作イメージはこんな感じです。
runsample.gif
上段の二つがドメインモデルを自動生成している様子です。streamモードに対応しているのでリアルタイムで生成過程が見られます(リアルタイムで見ることに大した意味は無いですが、ロマンを感じます)。
下段は稼働ログです。トークン数と課金額がわかるようになっています。

プロンプトの詳細などが気になる方は src/app/for-spring/task-runner.ts を見てみてください。
地道なトライアンドエラーの結果、とても普通のプロンプトになっていますので、あんまり面白みは無いと思いますが。

  1. 2023年6月1日現在、GPT-4のAPIは8Kトークンを謳っているものの、出力できるトークン数は事実上1.5Kトークン程度に制限されている。これはタイムアウトが5分に設定されているためで、5分で生成できるトークン数が大体1.5Kトークンであり、それ以上は強制切断される。

  2. ChatGPT/APIにプログラムを書かせると、結構な頻度でimport文の抜け漏れや階層ずれ等を起こす。import文の自動補完は既存のIDE等ではごく当たり前の機能であることからわかるように、古典的プログラミングで解決可能な問題。ChatGPT/APIに書かせるべきではない部分の代表格と考えらえる。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0