Mermaid x AST x 生成AI = コードとドキュメントの完全同期への道

サムネイル

はじめに

この記事は、DMM グループ Advent Calendar 2024の 20 日目の記事です。

こんにちは。データ基盤開発部のCDP(Customer Data Platform)グループで、DMMサービス横断顧客データシステムの開発運用保守を担当している渋谷です。

皆さんはシステムのドキュメントをどのようにメンテナンスされていらっしゃいますでしょうか。

設計時には頻繁に更新していたドキュメントも、開発が進んで実装段階に入った後の仕様変更はコードに反映させるのに手一杯で、ドキュメントは更新されなくなっていく…。

私が経験してきたプロジェクトではそんな光景がよく見られました。

ですがドキュメントが無価値という訳ではなく、特に判定などのビジネスロジックは非エンジニアのステークホルダーとのコミュニケーション時に使用する場面も多いです。

ビジネスロジックのドキュメント化が適切に行われるようになれば、プロジェクトの説明も理解もより進み、結果的に開発速度の向上に繋がっていきます。

本記事では、 Go のビジネスロジックコードから Mermaid 式でフローチャートを生成するコードの解説および作成の流れを説明することを目的とします。

なおこの記事は、DMM.go #9 での発表を元にしています。

この記事で伝えたいこと

  • ビジネスロジックからフローチャートを生成する Go コード
  • ビジネスロジックを Mermaid 式で書くと、ロジックの可視化ができる
  • ビジネスロジックの可視化は、非エンジニアや新規加入メンバーなど、コードを見慣れていない相手とのコミュニケーションに活用できる

解決したい課題

  • コードを見慣れていない相手とコードベースで円滑にコミュニケーションを取りたい
  • ビジネスロジックとドキュメントの乖離を減らしたい
  • ドキュメントの更新の手間を減らしたい

用語解説

Mermaid

Mermaid は、Markdown ライクなテキスト定義を使用して図やチャートを動的に作成・編集できる JavaScript ベースのツールです。

作成できるダイアグラムは、フローチャートやシーケンス図、ガントチャートなどの多岐に渡ります。

ブラウザで簡単に利用可能で、GitHub の README や静的サイトジェネレーターにも統合できます。

Mermaid Official

AST

AST(Abstract Syntax Tree、抽象構文木)は、ソースコードの構造を階層的に表現するデータ構造です。

コードを解析して得られるこの木構造は、プログラムの構文や構造を抽象的に示し、コンパイラや静的解析ツール、コード変換ツールなどで広く利用されています。

AST を活用することで、コードのリファクタリング、自動生成、エラー検出などが効率的に行えるようになります。

Go の ast パッケージ

完成版コード

早速、Go のビジネスロジックから Mermaid 式を出力するコードの現時点での完成版をご覧ください。

このコードで実現できること

  • 特定の関数のみの指定出力
  • if - else の分岐出力
  • for (range) ループの出力
  • コメントの出力

このコードでまだ実現できないこと

  • else if 条件 (分岐のラインが重複)
  • switch 文
  • 再帰呼び出し

コード

package main

import (
    "fmt"
    "go/ast"
    "go/parser"
    "go/token"
    "log"
    "os"
    "strings"
)

func main() {
    // ファイルを開く
    file, err := os.Create("scripts/ast/mermaid.md")
    if err != nil {
        log.Fatal(err)
    }
    defer file.Close()

    for _, targets := range [][]string{
        {"usecase/member.go", "isMember"},
    } {
        path := targets[0]
        method := targets[1]

        // 入力Goコードを読み込む
        src, err := os.ReadFile(path)
        if err != nil {
            log.Fatal(err)
        }

        outputMermaid(src, method, file)
    }
}

// outputMermaid は指定された関数を AST で解析してコードの内容を Mermaid 式で出力します
func outputMermaid(src []byte, method string, file *os.File) {
    // ファイルセットを作成
    fset := token.NewFileSet()

    // ASTを生成
    f, err := parser.ParseFile(fset, "", src, parser.ParseComments)
    if err != nil {
        log.Fatal(err)
    }

    // ノードとエッジを格納するためのスライス
    var nodes []string
    var edges []string
    var doc string

    // エッジの重複を避けるためのマップ
    edgeSet := make(map[string]bool)

    // ノードIDを生成するためのカウンタ
    nodeCounter := 0
    genNodeID := func() string {
        nodeCounter++
        return fmt.Sprintf("N%d", nodeCounter)
    }

    // ASTを走査
    ast.Inspect(f, func(n ast.Node) bool {
        switch fn := n.(type) {
        case *ast.FuncDecl:
            if fn.Name.Name == method {
                startID := genNodeID()
                structName := ""

                if fn.Recv != nil && len(fn.Recv.List) > 0 {
                    recvType := fn.Recv.List[0].Type
                    switch t := recvType.(type) {
                    case *ast.Ident:
                        structName = fmt.Sprintf("%s.", t.Name)
                    case *ast.StarExpr:
                        if ident, ok := t.X.(*ast.Ident); ok {
                            structName = fmt.Sprintf("%s.", ident.Name)
                        }
                    }
                }

                doc = fn.Doc.Text()
                exprStr := fmt.Sprintf("%s([\"`**%s%s**`\"])", startID, structName, fn.Name.Name)

                nodes = append(nodes, exprStr)
                parseBlockStmt(f, fset, fn.Body, startID, &nodes, &edges, edgeSet, genNodeID, false)
            }
        }
        return true
    })

    // Mermaid形式でmdファイルに出力
    outputMermaidFormat(nodes, edges, doc, file)
}

func outputMermaidFormat(nodes []string, edges []string, doc string, file *os.File) {
    file.WriteString(fmt.Sprintf("## %s\n\n", doc))

    // ノードを書き込む
    file.WriteString("```\n")
    file.WriteString("flowchart TD\n")
    for _, node := range nodes {
        file.WriteString(fmt.Sprintf("    %s\n", node))
    }
    for _, edge := range edges {
        file.WriteString(fmt.Sprintf("    %s\n", edge))
    }
    file.WriteString("```\n\n\n")
}

func parseBlockStmt(
    f *ast.File,
    fset *token.FileSet,
    block *ast.BlockStmt,
    currentID string,
    nodes *[]string,
    edges *[]string,
    edgeSet map[string]bool,
    genNodeID func() string,
    suppressInitialEdge bool,
) string {
    for _, stmt := range block.List {
        // コメントの取得
        comments := getComments(f, fset, stmt)
        commentStr := strings.Join(comments, "\\n")

        switch s := stmt.(type) {
        case *ast.AssignStmt, *ast.ExprStmt, *ast.ReturnStmt:
            nodeID := genNodeID()
            var label string
            switch st := s.(type) {
            case *ast.AssignStmt:
                exprStr := stmtToString(st)
                if commentStr != "" {
                    exprStr = commentStr + "\\n" + exprStr
                }
                label = escapeString(exprStr)
                *nodes = append(*nodes, fmt.Sprintf("%s[\"%s\"]", nodeID, label))
            case *ast.ExprStmt:
                exprStr := exprToString(st.X)
                if commentStr != "" {
                    exprStr = commentStr + "\\n" + exprStr
                }
                label = escapeString(exprStr)
                *nodes = append(*nodes, fmt.Sprintf("%s[\"%s\"]", nodeID, label))
            case *ast.ReturnStmt:
                exprStr := stmtToString(st)
                if commentStr != "" {
                    exprStr = commentStr + "\\n" + exprStr
                }
                label = escapeString(exprStr)
                *nodes = append(*nodes, fmt.Sprintf("%s([\"%s\"])", nodeID, label))
            }
            if !suppressInitialEdge {
                addEdge(currentID, nodeID, edges, edgeSet)
            }
            suppressInitialEdge = false
            currentID = nodeID
        case *ast.IfStmt:
            // if 文
            cond := exprToString(s.Cond)
            if commentStr != "" {
                cond = commentStr + "\\n" + cond
            }
            condID := genNodeID()
            label := escapeString(cond)
            *nodes = append(*nodes, fmt.Sprintf("%s{{\"%s\"}}", condID, label))
            if !suppressInitialEdge {
                addEdge(currentID, condID, edges, edgeSet)
            }
            suppressInitialEdge = false

            // "Yes" 分岐
            yesID := parseBlockStmt(f, fset, s.Body, condID, nodes, edges, edgeSet, genNodeID, true)
            addLabeledEdge(condID, "Yes", yesID, edges, edgeSet)

            // "No" 分岐
            if s.Else != nil {
                elseID := parseElseStmt(f, fset, s.Else, condID, nodes, edges, edgeSet, genNodeID)
                addLabeledEdge(condID, "No", elseID, edges, edgeSet)
                currentID = elseID
            } else {
                noID := genNodeID()
                *nodes = append(*nodes, fmt.Sprintf("%s[\"処理続行\"]", noID))
                addLabeledEdge(condID, "No", noID, edges, edgeSet)
                currentID = noID
            }
        case *ast.RangeStmt:
            // for文
            key := exprToString(s.Key)
            value := exprToString(s.Value)
            x := exprToString(s.X)
            rangeLabel := fmt.Sprintf("for %s, %s := range %s", key, value, x)
            if commentStr != "" {
                rangeLabel = commentStr + "\\n" + rangeLabel
            }
            rangeID := genNodeID()
            label := escapeString(rangeLabel)
            *nodes = append(*nodes, fmt.Sprintf("%s{{\"%s\"}}", rangeID, label))
            if !suppressInitialEdge {
                addEdge(currentID, rangeID, edges, edgeSet)
            }
            suppressInitialEdge = false

            // サブグラフの開始
            subgraphID := genNodeID()
            *nodes = append(*nodes, fmt.Sprintf("subgraph %s [\"%s\"]", subgraphID, label))

            // ループ本体
            bodyID := parseBlockStmt(f, fset, s.Body, rangeID, nodes, edges, edgeSet, genNodeID, true)
            addLabeledEdge(rangeID, "Body", bodyID, edges, edgeSet)

            // サブグラフの終了
            *nodes = append(*nodes, "end")

            // ループの継続
            addEdge(bodyID, rangeID, edges, edgeSet)
            currentID = rangeID
        }
    }
    return currentID
}

func parseElseStmt(
    f *ast.File,
    fset *token.FileSet,
    elseStmt ast.Stmt,
    parentID string,
    nodes *[]string,
    edges *[]string,
    edgeSet map[string]bool,
    genNodeID func() string,
) string {
    switch stmt := elseStmt.(type) {
    case *ast.BlockStmt:
        return parseBlockStmt(f, fset, stmt, parentID, nodes, edges, edgeSet, genNodeID, true)
    case *ast.IfStmt:
        cond := exprToString(stmt.Cond)
        comments := getComments(f, fset, stmt)
        if len(comments) > 0 {
            cond = strings.Join(comments, "\\n") + "\\n" + cond
        }
        condID := genNodeID()
        label := escapeString(cond)
        *nodes = append(*nodes, fmt.Sprintf("%s{{\"%s\"}}", condID, label))
        addEdge(parentID, condID, edges, edgeSet)

        // "Yes" 分岐
        yesID := parseBlockStmt(f, fset, stmt.Body, condID, nodes, edges, edgeSet, genNodeID, true)
        addLabeledEdge(condID, "Yes", yesID, edges, edgeSet)

        // "No" 分岐
        if stmt.Else != nil {
            noID := parseElseStmt(f, fset, stmt.Else, condID, nodes, edges, edgeSet, genNodeID)
            addLabeledEdge(condID, "No", noID, edges, edgeSet)
            return noID
        }
        return condID
    default:
        elseID := genNodeID()
        stmtStr := stmtToString(stmt)
        comments := getComments(f, fset, stmt)
        if len(comments) > 0 {
            stmtStr = strings.Join(comments, "\\n") + "\\n" + stmtStr
        }
        label := escapeString(stmtStr)
        *nodes = append(*nodes, fmt.Sprintf("%s[\"%s\"]", elseID, label))
        addEdge(parentID, elseID, edges, edgeSet)
        return elseID
    }
}

func exprToString(expr ast.Expr) string {
    switch e := expr.(type) {
    case *ast.BinaryExpr:
        return exprToString(e.X) + " " + e.Op.String() + " " + exprToString(e.Y)
    case *ast.Ident:
        return e.Name
    case *ast.BasicLit:
        return e.Value
    case *ast.CallExpr:
        return exprToString(e.Fun) + "(" + argsToString(e.Args) + ")"
    case *ast.SelectorExpr:
        return exprToString(e.X) + "." + e.Sel.Name
    case *ast.StarExpr:
        return "*" + exprToString(e.X)
    case *ast.UnaryExpr:
        return e.Op.String() + exprToString(e.X)
    case *ast.ParenExpr:
        return "(" + exprToString(e.X) + ")"
    default:
        return ""
    }
}

func argsToString(args []ast.Expr) string {
    var argStrs []string
    for _, arg := range args {
        argStrs = append(argStrs, exprToString(arg))
    }
    return strings.Join(argStrs, ", ")
}

func stmtToString(stmt ast.Stmt) string {
    switch s := stmt.(type) {
    case *ast.AssignStmt:
        var lhs []string
        for _, expr := range s.Lhs {
            lhs = append(lhs, exprToString(expr))
        }
        var rhs []string
        for _, expr := range s.Rhs {
            rhs = append(rhs, exprToString(expr))
        }
        return fmt.Sprintf("%s %s %s", strings.Join(lhs, ", "), s.Tok.String(), strings.Join(rhs, ", "))
    case *ast.ReturnStmt:
        var results []string
        for _, expr := range s.Results {
            results = append(results, exprToString(expr))
        }
        return "return " + strings.Join(results, ", ")
    default:
        return ""
    }
}

func escapeString(s string) string {
    // ダブルクォーテーションを #quot; に置換
    s = strings.ReplaceAll(s, `"`, "#quot;")
    // 特殊文字をエスケープ
    s = strings.ReplaceAll(s, "{", "\\{")
    s = strings.ReplaceAll(s, "}", "\\}")
    s = strings.ReplaceAll(s, "<", "\\<")
    s = strings.ReplaceAll(s, ">", "\\>")
    return s
}

func addEdge(from, to string, edges *[]string, edgeSet map[string]bool) {
    edge := fmt.Sprintf("    %s --> %s", from, to)
    if !edgeSet[edge] {
        *edges = append(*edges, edge)
        edgeSet[edge] = true
    }
}

func addLabeledEdge(from, label, to string, edges *[]string, edgeSet map[string]bool) {
    edge := fmt.Sprintf("    %s --> |%s| %s", from, label, to)
    if !edgeSet[edge] {
        *edges = append(*edges, edge)
        edgeSet[edge] = true
    }
}

func getComments(astFile *ast.File, fset *token.FileSet, node ast.Node) []string {
    var comments []string
    if node == nil {
        return comments
    }
    pos := node.Pos()
    end := node.End()
    file := fset.File(pos)
    if file == nil {
        return comments
    }

    // ノードの直前および直後のコメントを取得
    for _, cgroup := range astFile.Comments {
        if cgroup.Pos() <= pos && cgroup.End() <= end && cgroup.End()+3 >= pos {
            for _, comment := range cgroup.List {
                text := strings.TrimSpace(strings.TrimPrefix(comment.Text, "//"))
                comments = append(comments, text)
            }
        }
    }
    return comments
}

工夫箇所

コメント

  • コードの直前もしくは直後に記載されているコメントをコードブロックに組み込む

comment statement

if 文

  • if 文の分岐ラインに「Yes」「No」の文字列を表示
  • 「No」の場合、「処理続行」と記載して、後続の処理に繋げる

if statement

for 文

  • for 文の中をサブグラフで括って、ループしている内容を区別しやすくする

for statement

出力結果 (if 文)

元となるビジネスロジック

// 会員IDの取得
func (u *UseCase) getMemberId(ctx context.Context, sessionId, loginId string) (string, error) {
    memberId, err := u.getMemberIdBySessionId(ctx, sessionId)
    if err == nil && memberId != "" {
        // セッションIDから会員IDが取得できた場合は、それを利用する
        return memberId, nil
    }

    // 取れなかった場合のみログインIDから会員IDを取得する
    memberId, err = u.getMemberIdByLoginId(ctx, loginId)
    if err != nil {
        return "", err
    }

    return memberId, nil
}

出力された Mermaid

flowchart TD
    N1(["`**UseCase.getMemberId**`"])
    N2["memberId, err := u.getMemberIdBySessionId(ctx, sessionId)"]
    N3{{"err == nil && memberId != #quot;#quot;"}}
    N4(["セッションIDから会員IDが取得できた場合は、それを利用する\nreturn memberId, nil"])
    N5["処理続行"]
    N6["取れなかった場合のみログインIDから会員IDを取得する\nmemberId, err = u.getMemberIdByLoginId(ctx, loginId)"]
    N7{{"err != nil"}}
    N8(["return #quot;#quot;, err"])
    N9["処理続行"]
    N10(["return memberId, nil"])
        N1 --> N2
        N2 --> N3
        N3 --> |Yes| N4
        N3 --> |No| N5
        N5 --> N6
        N6 --> N7
        N7 --> |Yes| N8
        N7 --> |No| N9
        N9 --> N10

Mermaid 式で生成されたフローチャート (if文)

出力結果 (for 文)

元となるビジネスロジック

func (u *UseCase) getPurchasesByMemberIds(ctx context.Context, memberIds []string) ([][]string, error) {
    var purchases [][]string
    for _, memberId := range memberIds {
        // 会員IDごとに購入情報を取得
        purchase, err := u.getPurchaseByMemberId(ctx, memberId)
        if err != nil {
            // エラーが発生した場合はnilを返す
            return nil, err
        }
        // 購入情報を追加
        purchases = append(purchases, purchase)
    }
    return purchases, nil
}

出力された Mermaid

flowchart TD
    N1(["`**UseCase.getPurchasesByMemberIds**`"])
    N2{{"for _, memberId := range memberIds"}}
    subgraph N3 ["for _, memberId := range memberIds"]
    N4["会員IDごとに購入情報を取得\npurchase, err := u.getPurchaseByMemberId(ctx, memberId)"]
    N5{{"err != nil"}}
    N6(["return nil, err"])
    N7["処理続行"]
    N8["購入情報を追加\npurchases = append(purchases, purchase)"]
    end
    N9(["return purchases, nil"])
        N1 --> N2
        N4 --> N5
        N5 --> |Yes| N6
        N5 --> |No| N7
        N7 --> N8
        N2 --> |Body| N8
        N8 --> N2
        N2 --> N9

Mermaid 式で生成されたフローチャート (for文)

コード完成までの道のり

生成 AI で Mermaid 式を出力する

ビジネスロジックのドキュメント化を行うにあたり、まず Mermaid 式フローチャートを書くことを考えました。

最初は手で書き始めたのですが、その面倒さにすぐにギブアップしました。

次に弊社グループの生成AIを使って、ビジネスロジックのコードからMermaidを書くことを試みましたが、期待したMermaid式がなかなか生成できませんでした。

また、同じ苦労をロジックの数だけ繰り返すのもしんどいな…というのが正直なところでした。

上司にもアドバイスをいただいて既存の仕組みなども調べましたが、 Go 言語における静的解析では skelton や analyzer などが主で、ビジネスロジックの流れを掴むという目的にはちょっと合わないな、と判断しました。

どうしたものかと悩んでいたときに、不意に過去のDMM.goの発表を思い出したのです。

AST の存在を思い出す

DMM.go #7 で弊社の n9te9 さんが「単体テストのスケルトンコードを自動生成するツールを作った話」を発表されました。

私はこの発表で、AST (抽象構文木) で Go のコードを構文として解釈して操作できることを知りました。

生成 AI でビジネスロジックから Mermaid 式を出力してもらうのが難しいのはすでに分かっていました。

であれば、生成 AI に書いてもらうのは、ビジネスロジックの解析から Mermaid 式を出力するコードの方が精度はいいかも?と思いついたのです。

早速、弊社グループの生成 AI で試してみたのですが、そうは問屋が卸しませんでした。

なかなかいい感じに Mermaid 式を組み立てるコードが生成できません。

ここで手詰まりか…と思ったのですが、当時発表されたばかりの ChatGPT o1-preview のことを思い出したのです。

ChatGPT o1-preview を触ってみる

ChatGPT o1-preview は 2024/09/12 にリリースされた、深い推論が得意な新しい学習モデルです。

ちょうどこのモデルが出たばかりの頃でしたので、社外に出しても問題ない形に抽象化したビジネスロジックを投げてみました。

プロンプト

goのASTパッケージを利用して、入力したロジックコードを解析して、ロジックのフローチャートをmermaid式で出力するgoコードを書きたいです。
ステップバイステップで落ち着いて考えてください

## 入力コード例

// 会員IDの取得
func (u *UseCase) getMemberId(ctx context.Context, sessionId, loginId string) (string, error) {
    memberId, err := u.getMemberIdBySessionId(ctx, sessionId)
    if err == nil && memberId != "" {
        // セッションIDから会員IDが取得できた場合は、それを利用する
        return memberId, nil
    }

    // 取れなかった場合のみログインIDから会員IDを取得する
    memberId, err = u.getMemberIdByLoginId(ctx, loginId)
    if err != nil {
        return "", err
    }

    return memberId, nil
}

## 出力mermaid例

flowchart TD
    A[Start]
    B[getMemberIdBySessionId]
    C{MemberId\nis not empty?}
    D[Return MemberId]
    E[getMemberIdByLoginId]
    F[Return MemberId]
    G[Error\nReturn empty]

    A --> B
    B --> C
    C --> |Yes| D
    C --> |No| E
    D --> G
    E --> F
    F --> G

出力されたコード

package main

import (
    "go/parser"
    "go/token"
    "io/ioutil"
    "log"
)

func main() {
    // 入力Goコードを読み込む
    src, err := ioutil.ReadFile("input.go")
    if err != nil {
        log.Fatal(err)
    }

    // ファイルセットを作成
    fset := token.NewFileSet()

    // ASTを生成
    f, err := parser.ParseFile(fset, "", src, parser.ParseComments)
    if err != nil {
        log.Fatal(err)
    }

    // ノードとエッジを格納するためのスライス
    var nodes []string
    var edges []string

    // ノードIDを生成するためのカウンタ
    nodeCounter := 0
    genNodeID := func() string {
        nodeCounter++
        return fmt.Sprintf("N%d", nodeCounter)
    }

    // ASTを走査
    ast.Inspect(f, func(n ast.Node) bool {
        switch fn := n.(type) {
        case *ast.FuncDecl:
            if fn.Name.Name == "getMemberId" {
                startID := genNodeID()
                nodes = append(nodes, fmt.Sprintf("%s[Start]", startID))
                parseBlockStmt(fn.Body, startID, &nodes, &edges, genNodeID)
            }
        }
        return true
    })

    // Mermaid形式で出力
    fmt.Println("flowchart TD")
    for _, node := range nodes {
        fmt.Printf("    %s\n", node)
    }
    for _, edge := range edges {
        fmt.Printf("    %s\n", edge)
    }
}

func parseBlockStmt(block *ast.BlockStmt, currentID string, nodes *[]string, edges *[]string, genNodeID func() string) string {
    for _, stmt := range block.List {
        switch s := stmt.(type) {
        case *ast.AssignStmt:
            // 代入文の処理(必要に応じて)
        case *ast.ExprStmt:
            // 式の処理(関数呼び出しなど)
            if call, ok := s.X.(*ast.CallExpr); ok {
                funcName := exprToString(call.Fun)
                nodeID := genNodeID()
                *nodes = append(*nodes, fmt.Sprintf("%s[%s]", nodeID, funcName))
                *edges = append(*edges, fmt.Sprintf("    %s --> %s", currentID, nodeID))
                currentID = nodeID
            }
        case *ast.IfStmt:
            cond := exprToString(s.Cond)
            condID := genNodeID()
            *nodes = append(*nodes, fmt.Sprintf("%s{%s}", condID, cond))
            *edges = append(*edges, fmt.Sprintf("    %s --> %s", currentID, condID))

            // "Yes"分岐
            yesID := parseBlockStmt(s.Body, condID, nodes, edges, genNodeID)
            *edges = append(*edges, fmt.Sprintf("    %s --> |Yes| %s", condID, yesID))

            // "No"分岐
            if s.Else != nil {
                elseID := genNodeID()
                *nodes = append(*nodes, fmt.Sprintf("%s[Else]", elseID))
                *edges = append(*edges, fmt.Sprintf("    %s --> |No| %s", condID, elseID))
                currentID = elseID
            } else {
                currentID = condID
            }
        case *ast.ReturnStmt:
            retID := genNodeID()
            *nodes = append(*nodes, fmt.Sprintf("%s[Return]", retID))
            *edges = append(*edges, fmt.Sprintf("    %s --> %s", currentID, retID))
            currentID = retID
        }
    }
    return currentID
}

func exprToString(expr ast.Expr) string {
    // 式を文字列に変換する簡易的な実装
    switch e := expr.(type) {
    case *ast.BinaryExpr:
        return exprToString(e.X) + " " + e.Op.String() + " " + exprToString(e.Y)
    case *ast.Ident:
        return e.Name
    case *ast.BasicLit:
        return e.Value
    case *ast.CallExpr:
        return exprToString(e.Fun)
    case *ast.SelectorExpr:
        return exprToString(e.X) + "." + e.Sel.Name
    default:
        return ""
    }
}

生成できた Mermaid 式

flowchart TD
    N1[Start]
    N2{err == nil && memberId != ""}
    N3[Return]
    N4{err != nil}
    N5[Return]
    N6[Return]
        N1 --> N2
        N2 --> N3
        N2 --> |Yes| N3
        N2 --> N4
        N4 --> N5
        N4 --> |Yes| N5
        N4 --> N6

Mermaid 式で生成されたフローチャート 1

初回出力時の感想

あんなざっくりとしたプロンプトでここまでコードが出力されることに、正直驚嘆しました。

if 文の分岐など直す必要がある箇所は何箇所がありましたが、90%のコードはそのまま使えそうだ、と思えるものでした。

ChatGPT o1-preview と真剣に向き合う

改善したかった点

初期コードから改善したかったのは主に下記の四点でした。

  1. ダブルクォーテーションなど特殊な記号のエスケープ処理ができていなかった
  2. if - else の分岐後のチャートのラインがうまく引けていなかった
  3. コード前後のコメントを Mermaid 式に組み込みたい
  4. ファイルの入出力処理

このうち、1番と2番は ChatGPT に追加のプロンプトを投げて解決できました。

1. 2. 解決後の Mermaid

flowchart TD
    N1(["`**UseCase.getMemberId**`"])
    N2["memberId, err := u.getMemberIdBySessionId(ctx, sessionId)"]
    N3{{"err == nil && memberId != #quot;#quot;"}}
    N4(["return memberId, nil"])
    N5["処理続行"]
    N6["memberId, err = u.getMemberIdByLoginId(ctx, loginId)"]
    N7{{"err != nil"}}
    N8(["return #quot;#quot;, err"])
    N9["処理続行"]
    N10(["return memberId, nil"])
        N1 --> N2
        N2 --> N3
        N3 --> |Yes| N4
        N3 --> |No| N5
        N5 --> N6
        N6 --> N7
        N7 --> |Yes| N8
        N7 --> |No| N9
        N9 --> N10

Mermaid 式で生成されたフローチャート 2

コメントの出力

コードの意図を示すため、コード前後のコメントをMermaidに組み込みたかったのですが、こちらは ChatGPT だけでは解決できませんでした。

理由としては、コードを構文化した時にコメントがどのように解析されるのかの知見が私自身になく、的確な指示ができなかったためです。

そのため、泥臭くはありますが実際にコメントが出てくるコード前後をプリントデバッグして、コメントが存在しうる範囲を確認して組み込みました。

ファイルの入出力

4番目の入出力処理は自分で書いた方が速かったのでそのまま書きました。

こうして出来上がったのが、最初に示したコードとその出力結果になります。

Mermaid式の変遷

このコードが苦手なこと

コードを AST で解析して Mermaid 式を出力するのは、すべてのコードにおいて有効、という訳ではありません。

例えば、DB や 外部API など、外部とのやり取りに関しては、手続きばかり記載されていて実際のコードでもフローチャートでも得られる情報量はさして変わらない…という結果になりました。

そのため、このコードで出力できる Mermaid 式の中で有益なのは主にビジネスロジックである、という結論に達しています。

将来の展望

今回のコードを現在は手動で実行していますが、将来的にはドキュメントとしての体裁を整えて CI に組み込み、PR をマージしたタイミングでビジネスロジックと同期したドキュメントを自動生成する予定です。

そして生成したドキュメントを用いて、非エンジニアや新規加入メンバーとのコミュニケーションに活用できるようにしていきたいと考えています。

Merry Christmas & Happy New Year !