golangでCLIツールを開発する
golangの勉強としてcliツールを開発します。
開発の備忘録みたいなのを、つらつらと記録していきます。
最近、クリーンアーキテクチャを読んだり、それに準じた開発ハンズオンを行うものも行ったので、モジュールの疎結合、拡張性のある設計は心がけてみたいと思います。拡張はしないのでメリットはありませんが。
しょうもないものですが最終的にはリリースする予定です。
Refacgo
機能面
- コマンドラインで特定のファイルを打ち込んでコードの評価、リファクタリング案を返す。
- コード評価のコメントはコマンドライン上に返し、リファクタリング案は、実際にファイルを書き換える形にする
- リファクタリング案を保存するか否かはyes or no でユーザーが判断
- noならそのファイルは保存されない
- 言語設定は日本語か英語で設定する
使用技術を考える
cliライブラリ
- 多分大体この3つ
- cobra
- urfave/cli
- flag(Go標準)
今回はurfave/cli を使用する
- cobraは、リッチな機能が豊富そう
- cobra-cli(generator)がある
- flagの型が豊富
- 関連ライブラリとして、設定ファイル導入支援のライブラリであるviper等がある
ただ、個人的にシンプルに開発したいのでurfave/cliを採用
- ドキュメントが見やすくて開発しやすそう
- ただ、v3がそろぼちリリースされる??から仕様が変わるっていう懸念点もある
- ほぼ学習のアウトプットなのでいい!
-
cobra使わない理由
cobraのコマンド一つでボイラーテンプレートによる雛形作成はありがたいが、rails new みたく(そこまで複雑なものができるわけではないと思うけど)ファイルが自動で作成されるのに若干抵抗がある(柔軟に自分で構成を考えたいな〜)。
あとは、golangによる開発が初めてなので、機能が豊富すぎるとcobra 自体のキャッチアップが若干ノイズに感じそう。
urfave/cliでとりあえず動かしてみる
ドキュメント見ながら、開発したい機能は関係なしにとりあえず動くものを見てみる。
package main
import (
"context"
"fmt"
"log"
"os"
"github.com/urfave/cli/v2"
)
func main() {
app := &cli.App{
Name: "Boom",
Usage: "repeat what you said",
Action: func(ctx *cli.Context) error {
arg := ctx.Args().Get(0)
fmt.Printf("You said %q\n", arg)
return nil
},
}
if err := app.RunContext(ctx, os.Args); err != nil {
log.Fatalln(err)
}
}
適当に、引数をそのまま返すものを書いた。ドキュメントの例とほぼ変わらない。
以下のように、ビルドすると、
go build -o repeat
% ./repeat Good night
You said "Good"
なんとなくの動かし方はわかった。
README駆動開発??
- cliをどういった構成で作っていけばいいのかなーと調べるうちに、聞いたことない言葉が。
- なんとなくの仕様を決めてからやるのは確かにいいと思ったので、READMEをとりあえず書いた。
- README駆動開発に言及した記事
https://qiita.com/syossan27/items/38e2f4b7f0dc74207dc9
ディレクトリ構成を考える
- なるべく責務わけだけしておく
- 大規模だろうが小規模だろうが、ディレクトリ/パッケージで責務を分けないと気持ち悪いので(ちゃんと責務分けできているかはさておき)。
./
├── LICENSE
├── README.md
├── cmd/
│ ├── eval.go
│ └── refactor.go
├── go.mod
├── go.sum
├── internal/
│ ├── application/
│ │ ├── api_interface.go
│ │ ├── evaluation/
│ │ │ └── evaluate.go
│ │ ├── presenter_interface.go
│ │ └── refactoring/
│ │ └── refactoring.go
│ ├── gateway/
│ │ └── api/
│ │ └── gemini_client.go
│ └── presenter/
│ ├── eval_presenter.go
│ ├── progress/
│ │ └── progress_bar.go
│ └── refactor_presenter.go
├── main.go
└── pkg/
機能を整理して抽象的なレイヤーに。
-
eval
に関しても似たようなものです。とりあえずrefactorコマンドの挙動を整理しておきます。 - 大きな問題と下位レベルの問題にざっくり切り分けて、モジュールを分けたりするのに役立てる目的です
refactor
-
大きな問題:指定したファイルのコードをAIによってリファクタリングする
-
小さな問題
- コマンドラインからファイルパスを受け取る
- ファイル内のコードを読み取り、生成AI(gemini)へプロンプトとしてリファクタリングを要求する
- ファイル内のコードを読みとる
- 生成AIへプロンプトを投げ、結果を受け取る
- リファクタリング結果を、コード差分を可視化して一時的にファイルに上書きし、変更の確定を判断する
- コード差分を見つける
-
+ -
で差分を付けて一時的にファイルに上書きする - y/nの選択を要求、yならファイルを保存する
-
小さな問題
- geminiを動かすモジュールは疎結合にしておきたいので、振る舞いをインターフェースで定義しておく
- アプリケーション層におけるビジネスロジックで使用する
- cmdでアプリケーションロジックのオブジェクトを初期化する際にgeminiのClientをコンストラクタインジェクションでDIする
- geminiに関することはほぼ調べてないので、また使うときに以下を参照する
- アプリケーション層におけるビジネスロジックで使用する
構造を少し変更する
-
レイヤーの境界線を変える?イメージ
-
EvaluationやRefactioringといったビジネスロジックは生成AIありきなものになるので、ここを切り離すと歪なものになると感じた
- 変更可能性があるとしたら、「AIモデル」になる。
- 将来的にもしかしたら自前でそういうAIに任せてる処理を実装する可能性もあると言えばある(ない)
- Evaluatuionロジックの振る舞いを抽象的に定義しておいて、具体の実装を交換可能にする
-
ここら辺、ずっと悩んでいたけどこの本読んでてなんとなくこの考えに至った。
-
変更する可能性はあるけど一旦考えついた方針としてメモ
https://www.amazon.co.jp/Good-Code-Bad-~持続可能な開発のためのソフトウェアエンジニア的思考/dp/4798068160
-
-
Evaluation(Refactoringも同様)はinterfaceとして振る舞いを定義しておく
- application層に、Evaluationの持つ振る舞いを抽象的に定めておき、(
application/evaluation/interface.go
)- 具体的な実装を同じディレクトリに記述していく
- Evaluationインターフェースを満たす
EvaluationByGemini
を実装- ここでgeminiクライアントをDIする?
- 具体の実装を、cmdにてDIする
- Evaluationインターフェースを満たす
- 具体的な実装を同じディレクトリに記述していく
- application層に、Evaluationの持つ振る舞いを抽象的に定めておき、(
-
AIとの接続は外部APIとの接続になるのでgatewayにクライアントの初期化などは書いて隔離する方針は変えない
でもテストするのにgeminiに直接依存してるとビジネスロジックのテストがなぁ、、
- いくら密接に結びついているとはいえ、ビジネスロジックと外部APIは疎結合にしたほうがmockで置き換えたりできる。
- やっぱgeminiClient インターフェースを定義しておこう!
- EvaluateByGemini(ビジネスロジック)はそいつに依存させておこう。
こんな感じで書いていくか
-
eval
コマンドはこんな感じの流れで書いていく(詳細はあんまり書いていない)- ビジネスロジックはapplication層に記述
- gatewayとして外部APIとの接続の責務を持たせたgemini_clientをDIする
- cmdディレクトリ
- それぞれのコマンドは個別に記述し、
root.go
ファイルでまとめる。 - presenterとapplicationを利用する
- それぞれのコマンドは個別に記述し、
- ビジネスロジックはapplication層に記述
func main() {
if err := cmd.Execute(context.Background()); err != nil {
fmt.Fprintf(os.Stderr, "Error : %v\n", err)
}
}
- 若干cobraの構成を参考にしている
const (
version = "v1.0"
)
func Execute(ctx context.Context) error {
evalCmd := newEvalCmd(evaluation.NewEvaluation())
// refactorCmd:=newRefactorCmd(refactoring.NewRefactoring())
app := &cli.App{
Name: "refacgo",
Version: version,
Description: "A Go-based command-line tool that evaluates the code in a specified Go file and provides refactoring suggestions powered by AI",
Commands: []*cli.Command{
evalCmd.add(),
// refactorCmd.add()
},
}
if err := app.RunContext(context.Background(), os.Args); err != nil {
return err
}
return nil
}
type evalCmd struct {
Evalueation *evaluation.Evaluation
// EvalPresenter *presenter.EvalPresenter
}
func newEvalCmd(evaluation *evaluation.Evaluation) *evalCmd {
return &evalCmd{
Evalueation: evaluation,
}
}
func (cmd *evalCmd) add() *cli.Command {
return &cli.Command{
Name: "evaluate",
Aliases: []string{"eval"},
Description: "Evaluate code in the specifield file",
Usage: "Evaluate code in the specifield file",
UsageText: "refacgo eval [option] <filepath>",
HelpName: "eval",
ArgsUsage: "<filepath> is a path relative to the current directory where the command will be executed",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "japanese",
Aliases: []string{"j"},
},
&cli.StringFlag{
Name: "description",
Aliases: []string{"desc"},
Value: "",
Usage: "description of code in the specified file",
},
},
// 適当
Action: func(ctx *cli.Context) error {
fmt.Println("Evaluate your code !!")
return nil
},
}
}
- この状態でビルドして、動くか試してみる
$ ./refacgo
NAME:
refacgo - A new cli application
USAGE:
refacgo [global options] command [command options]
VERSION:
v1.0
DESCRIPTION:
A Go-based command-line tool that evaluates the code in a specified Go file and provides refactoring suggestions powered by AI
COMMANDS:
evaluate, eval Evaluate code in the specifield file
help, h Shows a list of commands or help for one command
GLOBAL OPTIONS:
--help, -h show help
--version, -v print the version
- 動いてる
$ ./refacgo eval
Evaluate your code !!
引数からファイルパスを受け取ってファイル内を読み込む
- refactorでもevalでも使用するのでapplication/utilsに配置
func LoadFile(filepath string) ([]byte, error) {
f, err := os.Open(filepath)
if err != nil {
log.Fatal(err)
}
defer f.Close()
var buf bytes.Buffer
// ファイルを読み込む
scannar := bufio.NewScanner(f)
// 一行ごとにループ
for scannar.Scan() {
// bufに書き込み
buf.Write(scannar.Bytes())
// 文末に改行
buf.WriteByte('\n') //1バイト書き込む
}
return buf.Bytes(), nil
}
- 一応テストも書く
- 読み込むようのファイルを用意
func TestLoadFile(t *testing.T) {
t.Parallel()
tests := []struct {
name string
filepath string
want []byte
wantErr bool
}{
{
name: "ファイルを読み込み、正しいバイトスライスを返す",
filepath: "./testdata/sample.txt",
want: []byte("This is Sample File.\n"),
wantErr: false,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := LoadFile(tt.filepath)
if (err != nil) != tt.wantErr {
t.Errorf("utils.LoadFile() error = %v ,wantErr = %v", err, tt.wantErr)
}
if diff := cmp.Diff(got, tt.want); diff != "" {
t.Errorf("loadFile return byte mismatch(-want +got):\n%s", diff)
}
})
}
}
- *cli.CommandのActionに渡す関数
- ファイルを読み込む 👈ここやった
- フラグ処理?
- メインのコード評価処理?
- 表示関連
func (cmd *evalCmd) run(ctx *cli.Context) error {
// 引数のファイルを読み込む
b, err := utils.LoadFile(ctx.Args().Get(0))
if err != nil {
return err
}
// 以降、処理の続きを書いていく...
}
(補足)bufioって?
- 入出力にバッファ処理を追加した機能がまとめられたpkg
- バッファ処理があると基本的に実行効率は上がる
-
bufio.NewScannar
-
io.Reader
型の引数からbufio.Scannar
型を生成-
io.Reader
型からの入力をバッファリングしつつ、改行を区切りとしてスキャン処理 - 標準では行単位で読み込むが、ワード単位で読み込むことも可能
※os.File
型のメソッドRead
は一度に全て読み込む(バイトサイズを指定することはできるけど)
-
-
この開発の中でこの先もI/O処理は行う。
開発の途中ではあるけど、I/O処理に関して知識が薄いのでまずこれ読んでおきたいと思います。
読んだ後
- I/Oには基本バッファ処理を挟んだほうが効率的
- ファイルのReadにScannarを使うと、行単位でバッファ処理が行われるため、bufio.NewReaderで読み取るよりも利便性が高い
- 読み込むバイトサイズが大きくても効率的にバッファを使用することができる。後者だと、ファイルが大きすぎたらそのまま書き込まれるのでバッファを経由しない可能性がある。
- 空のバイトスライスにWriteする場合は、
- 上のLoadFile()のように、
bytes.Buffer
型の変数を宣言- bytes.Bufferは初期化の必要がないらしい
- io.Writerを満たす
- []byte型を宣言するだけではio.Writerを満たさない
- bytes.Buffer型のメソッド
Write
で宣言した変数(バッファ)に格納する-
scanner.Bytes()
でバイトを出力
-
- bytes.Buffer型のBytes()でバイトスライスを返す
- 上のLoadFile()のように、
- os.FileなどにWriteする場合は、
-
bf:=bufio.NewScanner(io.Writer)
で引数にos.File型などを入れる -
bf.Write(scanner.Bytes())
でバッファに書き込む -
bf.Flush()
でバッファ内のデータをファイルに書き込む
といった流れになると思われる
-
これ読んでまとめたら(ここにはしない、Notionあたりにまとめる)理解したら、
- geminiのインターフェースを定義
- geminiの実装はせずにどんなもんかはドキュメントとか見て把握する
- Evaluation(ビジネスロジック)を記述
- プレゼンターを実装
- プログレスバー(後回し?)
- 表記について
- cmdでそれぞれ実装したモジュールを利用してコマンドのActionを仕上げる
- refactorの方が若干複雑やけど、基本同じ流れ。
振る舞いだけ定義
- ビジネスロジック
type Evaluate interface {
Evaluate(ctx context.Context, src []byte) ([]byte, error)
}
//具体実装
type EvaluateByGemini struct {
// geminiクライアント
}
func NewEvaluateByGemini() *EvaluateByGemini {
return &EvaluateByGemini{
// gemini: gemini,
}
}
// インターフェースを満たすメソッドを定義
func (ev *EvaluateByGemini) Evaluate(ctx context.Context, src []byte) ([]byte, error) {
}
- プレゼンター
type EvalPrinter struct {
// プログレスバー
}
func NewEvalPrinter() *EvalPrinter {
return &EvalPrinter{}
}
func (ep *EvalPrinter) EvalPrint(ctx context.Context, evalb []byte) error {
return nil
}
- cmd
- フラグとかいったん置いといて、ファイル読み込み⇨評価⇨出力 の流れを書いた
- それぞれの処理は独立している
func (cmd *evalCmd) run(cCtx *cli.Context) error {
// 引数のファイルを読み込んで、バイトスライスを格納
src, err := loadfile.LoadFile(cCtx.Args().Get(0))
if err != nil {
return err
}
// 読み取ったソースファイルを評価する
evalb, err := cmd.Evalueation.Evaluate(cCtx.Context, src)
if err != nil {
return err
}
// 評価コメントをプレゼンターに渡して出力
if err := cmd.EvalPresenter.EvalPrint(cCtx.Context, evalb); err != nil {
return err
}
return nil
}
- cmd/rootにて、evalコマンドオブジェクトに評価ロジック、プレゼンターをDIする
func Execute(ctx context.Context) error {
evalCmd := newEvalCmd(evaluation.NewEvaluateByGemini(), presenter.NewEvalPrinter())
app := &cli.App{
Name: "refacgo",
Version: version,
Description: "A Go-based command-line tool that evaluates the code in a specified Go file and provides refactoring suggestions powered by AI",
Commands: []*cli.Command{
evalCmd.add(),
},
}
if err := app.RunContext(ctx, os.Args); err != nil {
return err
}
return nil
}
Geminiをセットアップしてみよう
- geminiクライアントをコンストラクタで立ち上げます
- 以下のような構成にしています。
const (
geminiModel = "gemini-1.5-flash"
)
type GeminiClient struct {
geminiConfig config.GeminiConfig
client *genai.Client
model *genai.GenerativeModel
}
func NewGeminiClient(geminiConfig config.GeminiConfig, ctx context.Context) *GeminiClient {
client, err := genai.NewClient(ctx)
defer client.Close()
model := client.GenerativeModel(geminiModel)
if err != nil {
log.Fatal(err)
}
return &GeminiClient{
geminiConfig: geminiConfig,
client: client,
model: model,
}
}
func (gc *GeminiClient) GenerateText(ctx context.Context, src []byte) ([]byte, error) {
// 実行が終わったらクライアントをクローズしておく
defer gc.client.Close()
// 受け取ったバイト配列を文字列にしたものをラップ
text := genai.Text(string(src))
resp, err := gc.model.GenerateContent(ctx, text)
if err != nil {
return nil, err
}
// respをどうしよう??
}
モデルからのレスポンスをどう処理するか
resp, err := gc.model.GenerateContent(ctx, text)
で得られる第一返り値の型は、
*genai.GenerateContentResponse
のようです。
EvaluateByGemini(ビジネスロジック)文字列型でわたしたいのですが、、
というわけで*genai.GenerateContentResponse`型の中身をのぞいてみることにします。
- レスポンスはCandidateスライスに入ってるっぽいですね。
type GenerateContentResponse struct {
// Candidate responses from the model.
Candidates []*Candidate
// Returns the prompt's feedback related to the content filters.
PromptFeedback *PromptFeedback
// Output only. Metadata on the generation requests' token usage.
UsageMetadata *UsageMetadata
}
- Candidateの中のContentを取り出せれば良さそうですかね?
// Candidate is a response candidate generated from the model.
type Candidate struct {
// Output only. Index of the candidate in the list of candidates.
Index int32
// Output only. Generated content returned from the model.
Content *Content
// Optional. Output only. The reason why the model stopped generating tokens.
//
// If empty, the model has not stopped generating the tokens.
FinishReason FinishReason
// List of ratings for the safety of a response candidate.
//
// There is at most one rating per category.
SafetyRatings []*SafetyRating
// Output only. Citation information for model-generated candidate.
//
// This field may be populated with recitation information for any text
// included in the `content`. These are passages that are "recited" from
// copyrighted material in the foundational LLM's training data.
CitationMetadata *CitationMetadata
// Output only. Token count for this candidate.
TokenCount int32
}
- Partスライスが....!!!
- どうやら、MIMEタイプが異なる場合はPartスライスの中で別々に分けられているようですね。
-
type Content struct {
// Ordered `Parts` that constitute a single message. Parts may have different
// MIME types.
Parts []Part
// Optional. The producer of the content. Must be either 'user' or 'model'.
//
// Useful to set for multi-turn conversations, otherwise can be left blank
// or unset.
Role string
}
// A Part is a piece of model content.
// A Part can be one of the following types:
// - Text
// - Blob
// - FunctionCall
// - FunctionResponse
// - ExecutableCode
// - CodeExecutionResult
type Part interface {
toPart() *pb.Part
}
- 独自に定義されたText型を見ると、確かに実装されていますね
- 今回返ってくるのはText型で間違いなさそうです。
- 中身はstringなのでstringにキャストして返すことにします。
type Text string
func (t Text) toPart() *pb.Part {
return &pb.Part{
Data: &pb.Part_Text{Text: string(t)},
}
}
- 実装は以下のようになりました
func (gc *GeminiClient) GenerateText(ctx context.Context, src []byte, prompt string) (string, error) {
// client&modelが何らかの理由でnilの場合は早期リターン
if gc.client == nil || gc.model == nil {
return "", errors.New("connection to gemini failed")
}
// 実行が終わったらクライアントをクローズしておく
defer gc.client.Close()
// 受け取ったバイト配列を文字列にしたものをラップ
code := genai.Text(string(src))
// プロンプトをラップ
promptText := genai.Text(prompt)
resp, err := gc.model.GenerateContent(ctx, code, promptText)
if err != nil {
return "", err
}
var respString string
for _, cand := range resp.Candidates {
if cand.Content == nil {
continue
}
for _, part := range cand.Content.Parts {
// Text型の場合のみレスポンス文字列に格納する
switch p := part.(type) {
case genai.Text:
respString = string(p)
}
}
}
return respString, nil
}
(超悩み中)ビジネスロジックにフラグ引数は渡したくない!!!
-
-j
フラグを受け取った際、デフォは英語なので、ビジネスロジックから返される値を日本語にしたいですが....- フラグ引数を渡してしまうと、ビジネスロジックが下位概念に依存してしまう!!!1
- 理想は、インターフェースに基づいて具体実装を差し替えるようにコンストラクタの中で分岐させ流ことでしょうか。。
- フラグは、おそらくAction内でしか受け取れないので、現行の構成だとrun()にあたる部分でDIする必要がありそうですね。また今夜やることにします。
現在の構成
var (
eval *evalCmd
)
func cmdInit(ctx context.Context, cfg *config.Config) {
eval = newEvalCmd(
evaluation.NewEvaluateByGemini(
gemini.NewGeminiClient(cfg.GeminiConfig, ctx),
),
presenter.NewEvalPrinter(),
)
}
func Execute(ctx context.Context, cfg *config.Config) error {
// コマンドを初期化
cmdInit(ctx, cfg)
app := &cli.App{
Name: "refacgo",
Version: version,
Description: "A Go-based command-line tool that evaluates the code in a specified Go file and provides refactoring suggestions powered by AI",
Commands: []*cli.Command{
eval.add(),
},
}
if err := app.RunContext(ctx, os.Args); err != nil {
return err
}
return nil
}
type evalCmd struct {
Evalueation evaluation.Evaluate
EvalPresenter presenter.EvalPresenter
}
func newEvalCmd(evaluation evaluation.Evaluate, evalPresenter presenter.EvalPresenter) *evalCmd {
return &evalCmd{
Evalueation: evaluation,
EvalPresenter: evalPresenter,
}
}
func (cmd *evalCmd) add() *cli.Command {
return &cli.Command{
Name: "evaluate",
Aliases: []string{"eval"},
Description: "Evaluate code in the specifield file",
Usage: "Evaluate code in the specifield file",
UsageText: "refacgo eval [option] <filepath>",
HelpName: "eval",
ArgsUsage: "<filepath> is a path relative to the current directory where the command will be executed",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "japanese",
Aliases: []string{"j"},
},
&cli.StringFlag{
Name: "description",
Aliases: []string{"desc"},
Value: "",
Usage: "description of code in the specified file",
},
},
Action: cmd.run,
}
}
func (cmd *evalCmd) run(cCtx *cli.Context) error {
// ファイル名(パス)を引数から取得
filename := cCtx.Args().Get(0)
// 引数のファイルを読み込んで、バイトスライスを格納
src, err := loadfile.LoadFile(filename)
if err != nil {
return err
}
// 読み取ったソースファイルを評価する
eval, err := cmd.Evalueation.Evaluate(cCtx.Context, src, filename)
if err != nil {
return err
}
// 評価コメントをプレゼンターに渡して出力
if err := cmd.EvalPresenter.EvalPrint(cCtx.Context, eval); err != nil {
return err
}
return nil
}
解決?
-
evalコマンドオブジェクトに全てをDIしていたが、それをやめました
-
Evaluation(ビジネスロジック)やプレゼンターは、コマンドにおけるアクションを構成するものだと捉えることにしました。
-
*cli.Command
構造体には、コマンド実行による処理を担う関数型であるAction
フィールドがあります。 - Actionフィールドには関数型を満たす匿名関数を書いておき、その中で、-jフラグがある・ないでコンストラクタインジェクションによるDIにおけるEvaluation抽象型を差し替えることができます。
-
-
これで、ビジネスロジック側はコマンド処理という下位概念を知らずに済むようになり、疎結合を保てるようになりました。
解決後のコード
type evalCmdAction struct {
Evalueation evaluation.Evaluation
EvalPresenter presenter.EvalPresenter
}
func newEvalCmdAction(evaluation evaluation.Evaluation, evalPresenter presenter.EvalPresenter) *evalCmdAction {
return &evalCmdAction{
Evalueation: evaluation,
EvalPresenter: evalPresenter,
}
}
func (eca *evalCmdAction) run(cCtx *cli.Context) error {
// ファイル名(パス)を引数から取得
filename := cCtx.Args().Get(0)
// 引数のファイルを読み込んで、バイトスライスを格納
src, err := loadfile.LoadFile(filename)
if err != nil {
return err
}
// 読み取ったソースファイルを評価する
eval, err := eca.Evalueation.Evaluate(cCtx.Context, src, filename)
if err != nil {
return err
}
// 評価コメントをプレゼンターに渡して出力
if err := eca.EvalPresenter.EvalPrint(cCtx.Context, eval); err != nil {
return err
}
return nil
}
func EvalCmd(cfg *config.Config) *cli.Command {
return &cli.Command{
Name: "evaluate",
Aliases: []string{"eval"},
Description: "Evaluate code in the specifield file",
Usage: "Evaluate code in the specifield file",
UsageText: "refacgo eval [option] <filepath>",
HelpName: "eval",
ArgsUsage: "<filepath> is a path relative to the current directory where the command will be executed",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "japanese",
Aliases: []string{"j"},
},
&cli.StringFlag{
Name: "description",
Aliases: []string{"desc"},
Value: "",
Usage: "description of code in the specified file",
},
},
Action: func(cCtx *cli.Context) error {
var evalCmdAction *evalCmdAction
if <jフラグがあったら> {
evalCmdAction = newEvalCmdAction(
evaluation.NewEvaluationWithGenAI(
gemini.NewGemini(cfg.GeminiConfig, cCtx.Context),
),
presenter.NewEvalPrinter(),
)
} else {
evalCmdAction = newEvalCmdAction(
// 日本語版
evaluation.NewEvaluationWithGenAiInJap(
gemini.NewGemini(cfg.GeminiConfig, cCtx.Context),
),
presenter.NewEvalPrinter(),
)
}
if err := evalCmdAction.run(cCtx); err != nil {
return err
}
return nil
},
}
func Execute(ctx context.Context, cfg *config.Config) error {
app := &cli.App{
Name: "refacgo",
Version: version,
Description: "A Go-based command-line tool that evaluates the code in a specified Go file and provides refactoring suggestions powered by AI",
Commands: []*cli.Command{
eval.EvalCmd(cfg),
},
}
if err := app.RunContext(ctx, os.Args); err != nil {
return err
}
return nil
}
evalコマンドが出力面以外、仮完成(テストまだ)
- evalコマンドによりコードが評価され、ターミナルに表示されるようになりました
- テストコードは、まだutils関数やpkgに書いた汎用的な処理だけになってます。
- refactorコマンドに手をつける前にテストを固めていく予定です。
- プレゼンターのコード以下のようになっていて適当です。
type EvalPrinter struct {
// プログレスバー
}
func NewEvalPrinter() *EvalPrinter {
return &EvalPrinter{}
}
func (ep *EvalPrinter) EvalPrint(ctx context.Context, text string) error {
fmt.Println(text)
return nil
}
- geminiから、どうしてもマークダウンで出力されてしまうようなので、ここで**を全部取り除くとかをしてもいいかもしれません。ただそれによってなんか逆にシンプルすぎて読みにくくなる可能性もあるかと思うのでそこはまた考えます。
- また、プログレスバーを実装します
- それと、プロンプトで絵文字だったりフォーマットをきっっちりと決めるようにしたらもっと良くなりそうです。後回し。
実際の出力
$ refacgo eval -j ./pkg/load_file/load_file.go
## Code Evaluation: `loadfile.go`
**Readability:** ⭐️⭐️⭐️⭐️⭐️ (5/10)
* **Good:** Variable names (`filepath`, `buf`, `scannar`) are descriptive and understandable.
* **Good:** Comments explain the purpose of each section and specific actions (like adding a newline).
* **Room for Improvement:** The logic could be slightly improved by using `ioutil.ReadFile` instead of manual scanning for better readability and conciseness.
**Maintainability:** ⭐️⭐️⭐️⭐️ (4/10)
* **Good:** The function is self-contained and performs a single, well-defined task.
* **Room for Improvement:** The code is not very flexible. It assumes a single file path. Consider making it more flexible by allowing multiple paths or using a Reader interface for greater versatility.
**Performance:** ⭐️⭐️⭐️⭐️ (4/10)
* **Good:** The code is efficient for reading small files.
* **Room for Improvement:** For large files, `ioutil.ReadFile` would be more performant as it uses buffered I/O internally. The `for` loop with manual newline additions might become a bottleneck with large files.
**Error Handling:** ⭐️⭐️⭐️⭐️⭐️ (5/10)
* **Good:** The function handles errors gracefully by returning an error value and using `log.Fatal` if an error occurs while opening the file.
* **Room for Improvement:** `log.Fatal` should generally be avoided in library code. Instead, return the error to the caller for more control.
**Testability:** ⭐️⭐️⭐️⭐️ (4/10)
* **Good:** The function is simple enough to test.
* **Room for Improvement:** Consider using a `testing.T` argument to the function for more comprehensive testing and use `t.Fatal` for test failures instead of `log.Fatal`.
**Security:** ⭐️⭐️ (2/10)
* **Major Room for Improvement:** There is no input validation. The code blindly accepts a file path. This makes it vulnerable to path traversal attacks. Use `filepath.Clean` and sanitize input to prevent these vulnerabilities.
**Documentation:** ⭐️⭐️⭐️ (3/10)
* **Good:** The function has a basic docstring explaining its purpose.
* **Room for Improvement:** Provide more detailed documentation for the function, including its arguments, return values, error cases, and potential usage examples.
**Reusability:** ⭐️⭐️⭐️ (3/10)
* **Good:** The function can be reused in other projects, but it's tightly coupled to file paths.
* **Room for Improvement:** Consider abstracting the function to use a `Reader` interface for more generic use cases and broader reusability.
**Consistency:** ⭐️⭐️⭐️ (3/10)
* **Good:** The coding style is generally consistent.
* **Room for Improvement:** Consider following established Go style guides for consistent indentation and naming conventions (e.g., camelCase for variables and function names).
**Selection of Appropriate Algorithms:** ⭐️⭐️⭐️ (3/10)
* **Good:** The code uses appropriate algorithms for reading a file.
* **Room for Improvement:** For large files, consider using more optimized methods like `ioutil.ReadFile` to reduce memory overhead and improve performance.
**Total Score:** ⭐️⭐️⭐️⭐️ (40/100)
**Advice:**
* **Simplify and improve performance:** Use `ioutil.ReadFile` for reading files, especially for larger files.
* **Improve Security:** Validate and sanitize file paths to prevent vulnerabilities.
* **Enhance Reusability:** Abstract the function to use a `Reader` interface.
* **Improve Documentation:** Provide detailed documentation for the function, including arguments, return values, error cases, and potential usage examples.
* **Improve Error Handling:** Avoid `log.Fatal` in library code and return errors to the caller.
* **Consider Testing:** Write unit tests for the function.
This code demonstrates a basic understanding of reading files in Go. However, it needs improvement in terms of performance, security, reusability, and documentation. By addressing these areas, the code will be more robust, maintainable, and efficient.
日本語で行うフラグを追加して実行した際の出力
-
-j
フラグをつける
$ refacgo eval -j ./cmd/eval/eval.go
## コードレビュー: ./cmd/eval/eval.go
**可読性 (Readability): 9/10**
* 👍 関数名 `EvalCmd` と `initEvalCmdAction` は明確で理解しやすいです。
* 👍 変数名 `cCtx` は文脈から理解できますが、 `ctx` の方が一般的かもしれません。
* 👍 コメントは簡潔で分かりやすく、コードの意図を説明しています。
**保守性 (Maintainability): 8/10**
* 👍 コードはモジュール化され、 `EvalCmd` 関数は `initEvalCmdAction` 関数を呼び出すことで、処理を分離しています。
* 👍 `config.Config` をパラメータとして受け取ることで、外部との依存性を減らし、テストしやすくなっています。
* 🤔 `initEvalCmdAction` 関数の内部処理の詳細が不明なため、将来の修正や拡張が少し難しくなる可能性があります。
**パフォーマンス (Performance): 9/10**
* 👍 特にパフォーマンスに影響を与えるようなコードは含まれていません。
* 👍 必要最低限の処理のみ行っているため、実行効率は良好です。
**エラーハンドリング (Error Handling): 7/10**
* 👍 `evalCmdAction.run` で発生するエラーを適切に処理しています。
* 🤔 `evalCmdAction.run` の内部で発生するエラーの詳細が不明です。より詳細なエラーハンドリングが必要かもしれません。
**テスト可能性 (Testability): 7/10**
* 👍 `config.Config` をパラメータとして受け取っているため、モックオブジェクトを使ってテストすることができます。
* 🤔 `initEvalCmdAction` 関数の内部処理の詳細が不明なため、ユニットテストが書きにくい可能性があります。
**セキュリティ (Security): 8/10**
* 👍 特にセキュリティ上の問題は見当たりません。
* 🤔 入力値の検証やサニタイズ処理は行われていません。セキュリティを考慮した設計が必要かもしれません。
**ドキュメント (Documentation): 7/10**
* 👍 `cli.Command` の `Description` と `UsageText` に機能の説明と使用方法が記載されています。
* 🤔 `initEvalCmdAction` 関数などの内部処理の詳細なドキュメントがありません。
**再利用性 (Reusability): 8/10**
* 👍 `EvalCmd` 関数は、他のプロジェクトでも再利用可能な汎用的なコードです。
* 👍 `config.Config` をパラメータとして受け取っているため、様々な環境で利用できます。
**一貫性 (Consistency): 9/10**
* 👍 コーディングスタイルは一貫しており、読みやすいです。
**適切なアルゴリズムの選定 (Selection of appropriate algorithms): N/A**
* 該当なし
**総合評価: 78/100**
**アドバイス:**
* 👍 `initEvalCmdAction` 関数の内部処理を明確化し、ドキュメントを追加することで、保守性とテスト可能性を向上させることができます。
* 🤔 エラーハンドリングの詳細を検討し、より詳細なエラーメッセージを提供できるように改善してください。
* 🤔 入力値の検証とサニタイズ処理を導入することで、セキュリティを向上させることができます。
**コメント:**
全体的には可読性が高く、保守性も良好なコードです。
いくつかの改善点がありますが、基本的な設計はしっかりしており、良いコードと言えるでしょう。
特に `initEvalCmdAction` 関数の内部処理の詳細を公開することで、より優れたコードになります。
現在のEvalコマンドに関するモジュールを図式化してみた
-
現在のEvalコマンド機能を構成するモジュールはこんな感じになっています。
-
今回は、冒頭でも述べていますが、モジュール間の疎結合・拡張性(交換容易性)を考えて設計してみています。モックによるテスト容易性も得られると思います。
-
EvalCmdは、つまり
*cli.Command
型の構造体です。- この中のActionフィールドがコマンドの動作に当たるものになり、evalCmdActionとしています。
プラグインアーキテクチャ???
名前をつけたいわけではありませんが、結果的にプラグインアーキテクちゃに近いものとなった気がします。認識が違ってたら申し訳ないですが。。
-
コマンド実行によるEvalという評価を行うモジュールは、Evaluation(ビジネスロジック)とEvalPresenter(プレゼンター)になっており、EvalCmdActionはそれら二つ(の具象型)を利用する形になっています。
- 利用者側・利用されるモジュールの両者ともインターフェースに依存しているため、疎結合であることが言えそうです。
- 利用者側のコンポーネントに2つが集結している(プラグイン)形になっています。
-
このような構造によって、ビジネスロジックの交換が容易になります
-
現在はEvalWithGenAIが主に使われていますが、この先、自身で評価ロジックを実装する可能性もあるかとは思います(ない)。
- 日本語版のモデルとも差し替えが可能であり、それで持って、ビジネスロジックはevalCmdActionのことなんて知りません。インターフェースによる抽象化によってこのようなことも実現できています。
-
さらに、生成AIに対しても、抽象型に依存しているので、具体であるGeminiは、例えばOpenAIに交換可能です。
- 生成AIモデルを変更・追加(オプション)していくことはまぁ将来的に考えられなくもないです。
-
プレゼンテーション層の実装とそれに伴う各インターフェースの変更
初めからちゃんと考えてから書くべきだったが,,,
GenAIからのレスポンスはストリームで受け取りたい
この投稿よりも前では、
- EvaluationWithGenAI.Evaluate
メソッドの内部では、genai.Query
メソッドからレスポンス文字列を得て、それを返り値として返し、プレゼンターに渡すようにしていた。
しかし、、geminiのdocを見ていると、ストリーム処理がありました。
https://ai.google.dev/gemini-api/docs/text-generation?hl=ja&_gl=1kwq0xg_upMQ.._gaNjIzNzg3ODMzLjE3MzE0MzMwOTg._ga_P1DBVKWT6V*MTczMTQzMzA5Ny4xLjAuMTczMTQzMzIxMy4wLjAuMTM0NjI3MjI0Nw..&lang=go#generate-a-text-stream
「間違いなくストリーミングの方がいいな」と思いました。
生成AIからの評価コメントは長いです(あくまでEvaluation自体は生成AIに依存しているわけではなく、自身で何らかの実装を行うようにすることも可能です←無駄な拡張性)。
全文の表示を待っていては、かなり待たされる。
大体5秒ほどは待たされる気がしています。長くてそれくらい。
ということで、genAIの具体実装であるGemini.Queryメソッドにて、ストリーム処理に対応させていきました。
しかし、疎結合を守っていくため、ビジネスロジックのコンポーネントとプレゼンターのコンポーネントは隔てている構成にしていたため、簡単にはいきません。かといって、ここを崩したくはなかったので、それぞれインターフェースを書き換えました。
// ビジネスロジックコンポーネントの振る舞い
type Evaluation interface {
Evaluate(ctx context.Context, src []byte, filename string, ch chan<- string) error
}
//生成AIコンポーネントの振る舞い
type GenAI interface {
Query(ctx context.Context, src []byte, prompt string, ch chan<- string) error
}
//プレゼンターの振る舞い
type EvalPresenter interface {
EvalPrint(ctx context.Context, ch <-chan string) error
}
Evaluateの引数が多い、、のはアンチパターンな気がするけど一旦忘れます。
chan string
型を追加し、どれも返り値がエラーのみになっています。
これはどういうことかというと、
ストリームをゴルーチンで受け取り、ビジネスロジックコンポーネント内(生成AI内)にてチャネルに送信、プレゼンター層で受け取り、逐次出力できるようにしたということです。
ストリームの処理はゴルーチンで非同期処理に任せる方がいいですよね。多分。
以下、コード詳細です。
- 利用者側でチャネルを用意して、それをビジネスロジックには送信専用、プレゼンターには受信専用として渡します
- ビジネスロジックから生成AIへいわばチャネルをバケツリレーしていますが、これは「評価ロジック」の中に生成AIを隠蔽するために必要かなと思ってます。
func (eca *evalCmdAction) run(cCtx *cli.Context, ctx context.Context) error {
if cCtx.NArg() != 1 {
return errors.New("only one argument, the filename, is required")
}
// ファイル名(パス)を引数から取得
filename := cCtx.Args().Get(0)
// 引数のファイルを読み込んで、バイトスライスを格納
src, err := loadfile.LoadFile(filename)
if err != nil {
return err
}
// descフラグから文字列を取得し、ソースに追加
desc := cCtx.String("description")
// フラグから""が帰ってきた時はそのままソースはそのまま返る
src = utils.AddDescToSrc(src, desc)
// Evaluateの結果をモジュール間で逐次出力するためのチャネル
ch := make(chan string)
// ビジネスロジック
// 結果をストリームでチャネルに送信する
err = eca.Evalueation.Evaluate(ctx, src, filename, ch)
if err != nil {
return err
}
// チャネルからストリームで受信する
if err := eca.EvalPresenter.EvalPrint(ctx, ch); err != nil {
return err
}
return nil
}
- ビジネスロジック
func (ev *EvaluationWithGenAI) Evaluate(ctx context.Context, src []byte, filename string, ch chan<- string) error {
path := filepath.Join("internal", "application", "evaluation", "genai_instruction.txt")
instruction, err := loadfile.LoadFile(path)
if err != nil {
panic(err)
}
prompt := fmt.Sprintf("The name of this file is %q.\n\n%v\n\n", filename, string(instruction))
//受信チャネルを渡す
err = ev.genAI.Query(ctx, src, prompt, ch)
if err != nil {
return err
}
return nil
}
- geminiで受け取ったストリーム文字列を、逐次チャネルに送信します
unc (gc *Gemini) Query(ctx context.Context, src []byte, prompt string, ch chan<- string) error {
// client & modelが何らかの理由でnilの場合は早期リターン
if gc.client == nil || gc.model == nil {
return errors.New("connection to Gemini failed")
}
// 実行が終わったらクライアントをクローズしておく
defer func() error {
if err := gc.client.Close(); err != nil {
return err
}
return nil
}()
// 受け取ったバイト配列を文字列にしたものをラップ
code := genai.Text(string(src))
// プロンプトをラップ
promptText := genai.Text(prompt)
// ストリーミングで逐次的に文字列を受け取れるようにする
iter := gc.model.GenerateContentStream(ctx, code, promptText)
//レスポンス文字列を送信するチャネル
go func() error {
defer close(ch)
for {
// ストリーミング
resp, err := iter.Next()
if err == iterator.Done {
break
}
if err != nil {
return err
}
// レスポンスを文字列にキャストしてチャネルに送信
for _, cand := range resp.Candidates {
if cand.Content == nil {
continue
}
for _, part := range cand.Content.Parts {
// Text型の場合のみレスポンス文字列に格納する
switch p := part.(type) {
case genai.Text:
ch <- string(p)
}
}
}
}
return nil
}()
return nil
}
- プレゼンターにてチャネルから受け取って、逐次出力します
- また、インジケータスピナーを出しています
- チャネルから送られてこない時に表示したいためです。(何も表示されないのを防ぐ)
- ただ、レスポンスが早いので、2秒スリープさせています(何となく、見せたいんです笑)
- また、インジケータスピナーを出しています
func (ep *EvalPrinter) EvalPrint(ctx context.Context, ch <-chan string) error {
is := ep.indicater.Spinner
is.Suffix = " Waiting for evaluating..."
is.Start()
defer is.Stop() // 処理の最後に必ずスピナーを停止する
for {
select {
case <-ctx.Done():
return ctx.Err() // キャンセル通知がされた場合
case text, ok := <-ch:
if !ok {
return nil // チャネルが閉じられた場合
}
if is.Active() {
time.Sleep(2 * time.Second)
is.Stop()
}
fmt.Println(text)
}
}
}
ゴルーチン処理はアプリケーションレイヤー(呼び出しもと)で行うべきか?
GenAIにおけるQueryは、ストリーミングを処理するために、非同期処理(ゴルーチン)の中で、チャネルを通してプレゼンターに送信し、逐次出力してもらうようにしています。
そこで、結構悩んでいたのが、
ゴルーチンは呼び出しもとで管理するべきか?それともGenAI.Query()の中で隠蔽するべきか? ということです。
考え方として、
- 非同期処理にするかどうかはアプリケーション層の責務である。
- エラーハンドリングの観点から、呼び出しもとでゴルーチンを管理するべき。
というのがあります。
ただ、Query処理におけるchanelへの送信処理は、ゴルーチンでないと成り立たないものでもあります。なので、こちら側が気にならずに済むように、Query内で隠蔽してしまった方がいいのかもしれないなぁとも思いました。この上の記事の段階では、Query内でチャネルの送信処理にのみゴルーチンを適用させていました。
散々悩んだ結果....
ゴルーチンは呼び出しもとで管理することにしました
func (ev *EvaluationWithGenAiInJap) Evaluate(ctx context.Context, src []byte, filename string, ch chan<- string) error {
path := filepath.Join("internal", "application", "evaluation", "instruction_text", "genai_instruction_in_jap.txt")
instruction, err := loadfile.LoadFile(path)
if err != nil {
panic(err)
}
prompt := fmt.Sprintf("このファイルの名前は%qです。\n\n%v\n\n", filename, string(instruction))
go func() error {
defer close(ch)
err = ev.genAI.Query(ctx, src, prompt, ch)
if err != nil {
return err
}
return nil
}()
return nil
}
この結果に至ったのは、やはり、非同期処理かどうかのを決めるは、呼び出し側の責務であるということです。
それに、これによって、並行処理のロジックとアプリケーションのビジネスロジックをまとめてテストすることもできます。Queryメソッドをモックで置き換えれば済むことです。
その結果、何だかわからないけどUXも体感で向上した感じがあります。
Query全体を非同期処理で行うようにしたせいか、Evaluateをすぐ抜けて、プレゼンターへ処理がすぐに移行するようになり、コマンドを実行してすぐにスピナーが回るようにもなりました。
func (eca *evalCmdAction) run(cCtx *cli.Context, ctx context.Context) error {
// 省略
//Evaluate内のQueryが非同期に移行するので、すぐにこの下のEvalPrinterに処理が移る
err = eca.Evalueation.Evaluate(ctx, src, filename, ch)
if err != nil {
return err
}
// すぐに処理が移るので、すぎにインジケータスピナーも実行される
if err := eca.EvalPresenter.EvalPrint(ctx, ch); err != nil {
return err
}
return nil
}
LoadFile関数の仕様変更
以前のLoadFile関数だと、かなり不都合が生じるようになってきました。
- 当時の(数日前)パーマリンクを貼っておきます。
https://zenn.dev/link/comments/8650e95805aef1
以前はこんな感じでした。
これはpkgにて用意している関数です。つまり、汎用的な処理を置いているので、結構多用してます。
func LoadFile(filepath string) ([]byte, error) {
f, err := os.Open(filepath)
if err != nil {
log.Fatal(err)
}
defer f.Close()
var buf bytes.Buffer
// ファイルを読み込む
scannar := bufio.NewScanner(f)
// 一行ごとにループ
for scannar.Scan() {
// bufに書き込み
buf.Write(scannar.Bytes())
// 文末に改行
buf.WriteByte('\n') //1バイト書き込む
}
return buf.Bytes(), nil
}
このCLIアプリでは、プロンプトを投げるためのテキストファイルを読み取ったり、内部的にファイルを読み込む操作があります。
そうなるとテストの時、実際にコマンドを実行する時と、基点となるディレクトリが違ったりして、プロンプトのファイルが見つからないことがあり、テスト内でディレクトリを移動したりする必要が出てきました。
- わかりにくくて恐縮ですが、
os.Open
はこのLoadFile()
が呼ばれた階層とかではなくて、ワーキングディレクトリを基点とします- refacgoディレクトリの配下とかは関係なく、異なるパソコンのappディレクトリでコマンドを実行(つまりワーキング、作業している階層)すると、appディレクトリが基点となるので、相対パスを渡しても問題なく動作するわけです。
- しかし、開発においては、コマンドを実行するときはいいものの、
evaluation/evaluation_test.go
をテスト実行したとすると、このLoadFile()が動くワーキングディレクトリがevaluation/
になってしまい、内部的にプロンプトファイルをappliation/evaluation/instructioun_text/~~.txt
からLoadFile
で読み取るようにしても、evaluation/
配下でapplication/evaluation...
と探そうとするので、見つからないよ!とエラーが吐かれてしまいます。
そのため、アプリケーション内部においては、呼び出しもとの階層を起点に、指定したパスを読み取れるようにする必要がありました。
// アプリケーション内で利用する内部リソースを読み込む関数
func LoadInternal(relativePath string) ([]byte, error) {
// 呼び出しもとのパスを得る
_, caller_path, _, ok := runtime.Caller(1)
if !ok {
return nil, fmt.Errorf("failed to get caller information")
}
basePath := filepath.Dir(caller_path)
absPath := filepath.Join(basePath, relativePath)
f, err := os.Open(absPath)
if err != nil {
log.Fatal(err)
}
defer f.Close()
// バッファを持った空のバイト配列
var buf bytes.Buffer
// 行単位でファイルを読み込む
scannar := bufio.NewScanner(f)
// 一行ごとにループ
for scannar.Scan() {
// bufに書き込み
buf.Write(scannar.Bytes())
// 文末に改行
buf.WriteByte('\n') //1バイト書き込む
}
return buf.Bytes(), nil
}
しかしながら、これだけでは終わりませんでした。当初はLoadFile
関数の中身を書き換えるに終わったのですが、コマンドを実行してみると、、
// ワーキングディレクトリからの相対パスを指定すると、
MacBook-Air refacgo % refacgo eval ./cmd/eval/eval_cmd_action.go
// LoadFileを呼び出した階層+相対パスを探してしまう
2024/11/15 01:53:36 open /Users/xxxxxxx/my_portfolio/refacgo/cmd/eval/cmd/eval/eval_cmd_action.go: no such file or directory
はい、ここで、気づきました。コマンド引数として受け取るファイルは、os.Open
によって、ワーキングディレクトリを基点として相対パスで探してもらう必要がある...!!
ここで、内部用と外部用で分ける必要が出てきたことに気づきました。
二つでは、、基点としてほしいディレクトリが違ったのです。
- 内部ファイルを読み込む場合は、ロジック内やユニットテスト内でLoadFileを呼び出す階層を基点にしてほしい
- 外部ファイルを読み込む場合は、コマンドを実行したワーキングディレクトリを基点にして欲しい
というわけで、用途別に分けるようにしました。
// アプリケーション内で利用する内部リソースを読み込む関数
func LoadInternal(relativePath string) ([]byte, error) {
// 呼び出しもとのパスを得る
_, caller_path, _, ok := runtime.Caller(1)
if !ok {
return nil, fmt.Errorf("failed to get caller information")
}
basePath := filepath.Dir(caller_path)
absPath := filepath.Join(basePath, relativePath)
f, err := os.Open(absPath)
if err != nil {
log.Fatal(err)
}
defer f.Close()
// バッファを持った空のバイト配列
var buf bytes.Buffer
// 行単位でファイルを読み込む
scannar := bufio.NewScanner(f)
// 一行ごとにループ
for scannar.Scan() {
// bufに書き込み
buf.Write(scannar.Bytes())
// 文末に改行
buf.WriteByte('\n') //1バイト書き込む
}
return buf.Bytes(), nil
}
// コマンドラインの入力から受け取ったファイルパスからファイルを読み込むための関数
func LoadExternal(filename string) ([]byte, error) {
f, err := os.Open(filename)
if err != nil {
log.Fatal(err)
}
defer f.Close()
var buf bytes.Buffer
// ファイルを読み込む
scannar := bufio.NewScanner(f)
// 一行ごとにループ
for scannar.Scan() {
// bufに書き込み
buf.Write(scannar.Bytes())
// 文末に改行
buf.WriteByte('\n') //1バイト書き込む
}
return buf.Bytes(), nil
}
余談
LoadFile関数が呼び出される階層を基点にするというのは個人的にすぐに理解できました。
以下のようにすれば実装できることです。
// 呼び出しもとのパスを得る
_, caller_path, _, ok := runtime.Caller(1)
ただ、ならば、なぜ今までos.Open
に相対パスを渡すだけでファイルを読み込めていたんだ??と疑問に思いました。別のポートフォリオの階層で試してもしっかり読み込めていたので、さらにわからなくなりました。
ワーキングディレクトリを基点にするって、なんでなん。。と考え込みました。
ただ、よく考えてみると、os.Open
は、OSに対してシステムコールにより、ファイルを読み取り専用で開くものなんですよね。
つまり、LoadFileの階層とかまじで関係ないんですよね..。開いて!!と実際にお願いするのはOSカーネルに対してとなります。
だから、ワーキングディレクトリを基点にできるのですね。
ちなみに、LoadInternal
で仕様したruntime
パッケージは、そのなの通り、goのランタイムシステムとのやり取りを可能にします。
使用したruntime.Caller()
は、コールスタック上の関数呼び出しに関するファイルの情報を返します。
引数にはskip intとなります。0を入れると、Caller自体の呼び出し元の情報を返します。コールスタックを一度も飛ばさずに、純粋にCaller関数が呼ばれた地点の情報を返すということだと思います。
LoadInternal関数内で、runtime.Caller(0)
としてしまうと、このloadfile/ディレクトリを基点としてしまうのです。
// アプリケーション内で利用する内部リソースを読み込む関数
func LoadInternal(relativePath string) ([]byte, error) {
// 呼び出しもとのパスを得る
_, caller_path, _, ok := runtime.Caller(1)
if !ok {
return nil, fmt.Errorf("failed to get caller information")
}
basePath := filepath.Dir(caller_path)
absPath := filepath.Join(basePath, relativePath)
f, err := os.Open(absPath)
if err != nil {
log.Fatal(err)
}
defer f.Close()
// バッファを持った空のバイト配列
var buf bytes.Buffer
// 行単位でファイルを読み込む
scannar := bufio.NewScanner(f)
// 一行ごとにループ
for scannar.Scan() {
// bufに書き込み
buf.Write(scannar.Bytes())
// 文末に改行
buf.WriteByte('\n') //1バイト書き込む
}
return buf.Bytes(), nil
}****
実際、0を引数に入れてみると失敗します。
/instruction_text.....
からがLoadInternal関数に入れた相対パスです。load_fileディレクトリを基点に相対パスを探していることがわかります。
open /Users/xxxxxxx/my_portfolio/refacgo/pkg/load_file/instruction_text/genai_instruction_in_jap.txt: no such file or directory
公式には以下のように書いています。
Callerは、呼び出し元のgoroutineのスタック上の関数呼び出しに関するファイルと行番号の情報を報告する。引数skipは、上昇するスタック・フレーム数であり、0はCallerの呼び出し元を示す。(歴史的な理由により、skipの意味はCallerとCallersで異なる)。戻り値は、プログラム・カウンタ、ファイル名、対応する呼び出しのファイル内の行番号を 報告する。情報を復元できなかった場合、真偽値okは偽である。
公式ドキュメントより(https://pkg.go.dev/runtime#Caller)
つまり、欲しい基点となるディレクトリ情報は、LoadInternalを呼び出した階層です。コールスタックを1つ上昇させ、Caller()を呼び出しているLoadInternal
の呼び出しというコールスタックまでスキップするために1を引数に入れるのです。
アプリケーション層のユニットテスト
評価ロジックを書いたアプリケーション層のモジュールのテストを書きました。
- 日本語版も同じく実装しています
- GenAI.Queryをモック化し、その中で、ソースコード・プロンプトを正常に渡せているかをその中で検証するようにしています。
- 同じくモックの中で、チャネルに文字列を送信するようにしています。
- チャネルから受信し、受け取った文字列と、送信した文字列を比較して正常にgoroutineに送信できているかを確かめます。
- プレゼンターでは同様の処理でチャネルから受け取っています。それに寄せました。
func TestEvauationWithGenAI(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockGenAI := application.NewMockGenAI(ctrl)
srcArg := []byte("This is sample code.")
respString := []string{"This is comments of evalutated code!!!", "This is response from Mock!!!"}
type args struct {
src []byte
filename string
}
tests := []struct {
name string
mockFunc func()
args args
wantErr bool
want string
}{
{
name: "GenAIにソースコード・プロンプトを正常に渡し、非同期的にチャネルに文字列を送信できる",
mockFunc: func() {
mockGenAI.EXPECT().Query(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Do(
func(ctx context.Context, src []byte, prompt string, ch chan<- string) {
// 正確にプロンプト・ソースコードをQueryに渡しているか
expectedPrompt, err := loadfile.LoadInternal("./testdata/prompt/with_genai_prompt.txt")
if err != nil {
t.Error(err)
}
if diff := cmp.Diff(string(expectedPrompt), prompt); diff != "" {
t.Errorf("prompt received from EvaluationWithGenAI mismatch (-want +got):\n%s", diff)
}
if diff := cmp.Diff(srcArg, src); diff != "" {
t.Errorf("src received from mismatch EvaluationWithGenAI mismatch (-want +got):\n%s", diff)
}
defer close(ch)
// 文字列をチャネルに送信
for _, rs := range respString {
ch <- rs
}
},
)
},
args: args{
src: srcArg,
filename: "test.go",
},
want: respString[0] + respString[1],
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
tt.mockFunc()
ctx := context.Background()
evaluation := NewEvaluationWithGenAI(mockGenAI)
ch := make(chan string)
evaluation.Evaluate(ctx, tt.args.src, tt.args.filename, ch)
// チャネルから文字列を受信
var ss []string
for text := range ch {
ss = append(ss, text)
}
got := ss[0] + ss[1]
if got != tt.want {
t.Errorf("evaluated response not match,want: %q,got: %q", tt.want, got)
}
})
}
}
コマンドによる振る舞いのユニットテスト
テスト容易性を考慮した設計変更
あくまでコマンド操作を確かめるために、プレゼンターや生成AIモジュールはモックを使いました。そのため統合テストとは呼べなさそうですが、コマンド実行による振る舞いを定義するcmdモジュール
のユニットテストとなっています。
-j
フラグあり・なしによって、Evaluationインスタンスの呼び出しが変わってくるため、そこはコマンドの振る舞いとしてテストすべきです。そのため、Evaluationに関しては実際のモジュールを使用しています。
タイトルに書いてある、設計変更の話になりますが、以前は、コマンドを受け付けるエントリーポイントとなるcmd.Execute()
(main関数から呼び出されています)の中で、EvalCmdを呼び出していました。
func Execute(ctx context.Context, cfg *config.Config) error {
app := &cli.App{
Name: "refacgo",
Version: version,
Description: "A Go-based command-line tool that evaluates the code in a specified Go file and provides refactoring suggestions powered by AI",
Commands: []*cli.Command{
eval.EvalCmd(cfg, ctx),
},
}
if err := app.RunContext(ctx, os.Args); err != nil {
return err
}
return nil
}
そしてその中のinitEvalCmdAction
にて、全てのモジュールをコンストラクタインジェクションによってDIする形にしていました
func EvalCmd(cfg *config.Config, ctx context.Context) *cli.Command {
return &cli.Command{
//省略
Action: func(cCtx *cli.Context) error {
evalCmdAction := initEvalCmdAction(cCtx, cfg)
if err := evalCmdAction.run(cCtx, ctx); err != nil {
return err
}
return nil
},
}
}
// コマンドアクションを初期化する
// japaneseフラグがあれば、日本語対応のEvaluationをDIする
func initEvalCmdAction(cCtx *cli.Context, cfg *config.Config) *evalCmdAction {
var evalCmdAction *evalCmdAction
if cCtx.Bool("japanese") {
evalCmdAction = newEvalCmdAction(
evaluation.NewEvaluationWithGenAiInJap(
gemini.NewGemini(cfg.GeminiConfig, cCtx.Context),
),
presenter.NewEvalPrinter(),
)
} else {
evalCmdAction = newEvalCmdAction(
evaluation.NewEvaluationWithGenAI(
gemini.NewGemini(cfg.GeminiConfig, cCtx.Context),
),
presenter.NewEvalPrinter(),
)
}
return evalCmdAction
}
こうしていたのは、コマンドの振る舞いを実際に決めているevalCmdActionが、Evaluation型(抽象型)のどの具象型を使用するのかが決定するタイミングがフラグの有無だったためです。日本語が選択されればEvaluationWithGenAiInJapをDIします。そうでなければEvaluationWithGenAIをDIします。
それに伴って、他のGenAIやPresenterも一緒のタイミングでDIするようにしていました。
しかし、テストをいざ書こうとすると、かなりテストがしにくくなっていることに気づきます。
fmt.Println
によるターミナルへのとなっているEvalPrinter使いにくいのでモックで置き換えたいですし、geminiに関しては実際にリクエストを送るわけにもいかないのでビジネスロジックと同様、モック化すべきです。
このままのコードだと、エントリーポイントのかなり奥深くでcmdActionの初期化がなされており、モジュールをモックで置き換えるのも困難を極めます。
かなり悩みましたが、ある言葉を思い出しました。
テストしやすくするための構造が、本番コードにあっていい、というくらい価値観の転換が必要です。それぐらい、テストは重要です。
(上田勲. プリンシプル オブ プログラミング 3年目までに身につけたい 一生役立つ101の原理原則 (p.160). 株式会社秀和システム)
テストがしにくいソースコードに引っ張られることなく、テストのしやすい設計に変える方が重要だということです。
そこで、以下のようにしました
エントリーポイントでgemini,evalPrinterを初期化しています。
これはバケツリレーになってしまいますが、これらのインスタンスはinitEvalCmdAction()
に渡してDIします。
先にも書きましたが、Evaluationに関しては、コマンドラインからフラグを受け取ってからでないと初期化できません。なんか気持ち悪いしこれであってるのかわからなくなってきましたが、Evaluate()にフラグ引数を渡して日本語と英語を切り替えるのはcmdに依存しすぎています。
インスタンスを作り分けている方が、疎結合なはず...。
func Execute(ctx context.Context, cfg *config.Config) error {
// geminiを初期化
gemini := gemini.NewGemini(cfg.GeminiConfig, ctx)
app := &cli.App{
Name: "refacgo",
Version: version,
Description: "A Go-based command-line tool that evaluates the code in a specified Go file and provides refactoring suggestions powered by AI",
Commands: []*cli.Command{
eval.EvalCmd(ctx, gemini, presenter.NewEvalPrinter()),
},
}
if err := app.RunContext(ctx, os.Args); err != nil {
return err
}
return nil
}
そして、テストは以下になります。少し見にくいので書き方に工夫がありそうです。
とりあえず、こんな感じだということで載せておきました。テストテーブルがクソ長いです。3ケース分用意しました。
- フラグなしで実行
- -jフラグありで実行
- -jフラグ&-descフラグ引数ありで実行
func TestEvalCmd(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mockGenAI := application.NewMockGenAI(ctrl)
mockEvalPresenter := evaluation.NewMockEvalPresenter(ctrl)
srcArg := []byte("This is sample code.\n")
respString := []string{"This is comments of evalutated code!!!", "This is response from Mock!!!"}
respStringInJap := []string{"とてもいいコードです!!", "とてもいいテストコードです!!"}
srcArgWithDesc := []byte("これはテストで用いるためのものです。 :\n\n\nThis is sample code.\n")
// チャネルから受信した文字列を格納する配列
var got []string
tests := []struct {
name string
args []string
mockFunc func()
want string
wantErr bool
}{
{
name: "フラグなしでコマンドを叩くと評価コメントが返る",
args: []string{"refacgo", "eval", "./testdata/src/sample.txt"},
mockFunc: func() {
mockGenAI.EXPECT().Query(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Do(
func(ctx context.Context, src []byte, prompt string, ch chan<- string) {
// 正確にプロンプト・ソースコードをQueryに渡しているか
expectedPrompt, err := loadfile.LoadInternal("./testdata/prompt/eval/with_genai_prompt.txt")
if err != nil {
t.Error(err)
}
if diff := cmp.Diff(string(expectedPrompt), prompt); diff != "" {
t.Errorf("prompt received from EvaluationWithGenAI mismatch (-want +got):\n%s", diff)
}
if diff := cmp.Diff(srcArg, src); diff != "" {
t.Errorf("src received from mismatch EvaluationWithGenAI mismatch (-want +got):\n%s", diff)
}
// 文字列をチャネルに送信
for _, rs := range respString {
ch <- rs
}
defer close(ch)
},
)
mockEvalPresenter.EXPECT().EvalPrint(gomock.Any(), gomock.Any()).Do(
func(ctx context.Context, ch <-chan string) {
for text := range ch {
got = append(got, text)
}
},
)
},
want: respString[0] + respString[1],
wantErr: false,
},
{
name: "-jフラグをつけてコマンドを叩くと日本語による評価コメントが返る",
args: []string{"refacgo", "eval", "-j", "./testdata/src/sample.txt"},
mockFunc: func() {
mockGenAI.EXPECT().Query(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Do(
func(ctx context.Context, src []byte, prompt string, ch chan<- string) {
// 正確にプロンプト・ソースコードをQueryに渡しているか
expectedPrompt, err := loadfile.LoadInternal("./testdata/prompt/eval/with_genai_in_jap_prompt.txt")
if err != nil {
t.Error(err)
}
if diff := cmp.Diff(string(expectedPrompt), prompt); diff != "" {
t.Errorf("prompt received from EvaluationWithGenAI mismatch (-want +got):\n%s", diff)
}
if diff := cmp.Diff(src, src); diff != "" {
t.Errorf("src received from mismatch EvaluationWithGenAI mismatch (-want +got):\n%s", diff)
}
defer close(ch)
// 文字列をチャネルに送信
for _, rs := range respStringInJap {
ch <- rs
}
},
)
mockEvalPresenter.EXPECT().EvalPrint(gomock.Any(), gomock.Any()).Do(
func(ctx context.Context, ch <-chan string) {
for text := range ch {
got = append(got, text)
}
},
)
},
want: respStringInJap[0] + respStringInJap[1],
wantErr: false,
},
{
name: "-jフラグをつけ、-descフラグをつけてコマンドを叩くと日本語による評価コメントが返り、ソースコードに説明が追加される",
args: []string{"refacgo", "eval", "-j", "-desc", "これはテストで用いるためのものです。", "./testdata/src/sample.txt"},
mockFunc: func() {
mockGenAI.EXPECT().Query(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Do(
func(ctx context.Context, src []byte, prompt string, ch chan<- string) {
// 正確にプロンプト・ソースコードをQueryに渡しているか
expectedPrompt, err := loadfile.LoadInternal("./testdata/prompt/eval/with_genai_in_jap_prompt.txt")
if err != nil {
t.Error(err)
}
if diff := cmp.Diff(string(expectedPrompt), prompt); diff != "" {
t.Errorf("prompt received from EvaluationWithGenAI mismatch (-want +got):\n%s", diff)
}
if diff := cmp.Diff(srcArgWithDesc, src); diff != "" {
t.Errorf("src received from mismatch EvaluationWithGenAI mismatch (-want +got):\n%s", diff)
}
defer close(ch)
// 文字列をチャネルに送信
for _, rs := range respStringInJap {
ch <- rs
}
},
)
mockEvalPresenter.EXPECT().EvalPrint(gomock.Any(), gomock.Any()).Do(
func(ctx context.Context, ch <-chan string) {
for text := range ch {
got = append(got, text)
}
},
)
},
want: respStringInJap[0] + respStringInJap[1],
wantErr: false,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Cleanup(func() {
got = []string{}
})
tt.mockFunc()
ctx := context.Background()
app := &cli.App{
Name: "refacgo",
Description: "A Go-based command-line tool that evaluates the code in a specified Go file and provides refactoring suggestions powered by AI",
Commands: []*cli.Command{
EvalCmd(ctx, mockGenAI, mockEvalPresenter),
},
}
if err := app.RunContext(ctx, tt.args); err != nil {
t.Errorf("Error in Running CLI : %v", err)
}
if diff := cmp.Diff(tt.want, got[0]+got[1]); diff != "" {
t.Errorf("expected output mismatch (-want +got):\n%s", diff)
}
})
}
}
refactorコマンド
evalコマンドと同じような流れで実装をしていきますが、出力にあたってはかなり違いがあります。
ざっと、コマンド実行から出力まで、E2Eの流れを書き出して整理していきます。
- コマンドを実行(
refacgo refactor <option> filename
) - 引数にとったファイルをリファクタリングして上書きする
- ファイルを読み書き権限で開く
- プロンプトとともに、ソースコードを生成AIに投げる
- 生成AIからのレスポンスを受け取り、
- リファクタしたコードはファイルに上書き
- ポイント/差分をターミナル上に出力
- 上書きを確定するかどうかをyes/noで選択し、上書きを確定する
大体こんな感じでしょうか。
振る舞いを定義する
evalの時もそうしましたが、とりあえず、各モジュールのインターフェースを定義していくところから始めます。
なんとなくの挙動を構想しただけなので、引数・返り値はこの先変わるかもしれません。
- ビジネスロジック(リファクタリング)
type Refactoring interface {
Refactor(ctx context.Context, src []byte, filename string, ch chan<- string) ([]byte, error)
}
- ビジネスロジック (差分検出)
- 差分の検出を、リファクタリングモジュールの方に統合させようか迷いました。
- 責務が異なるが、密接に関わっていてrefactor機能でしか使用しないため、refactoringモジュールとは疎結合であるが、refactoringディレクトリに配置しています
type Differ interface {
Diff(originSrc, refactSrc string) string
}
- プレゼンター(コメントの出力、evalと似たような感じ)
type RefacPrinter interface {
Print(text string)
}
- プレゼンター(ファイルの上書き)
type RefacOverWriter interface {
OverWrite(w io.Writer, src string)
}
CLIのエントリーポイントでは、このようになっています
Refactoringモジュールに限っては、evalの時と同様、-jフラグの有無を知って初めて初期化できるので、エントリーポイントでは初期化しません。
逆にrefactoringもそれ以外はここで初期化して、最終的にはinit関数でrefacCmdActionを初期化、モジュールをDIするようにします。
func Execute(ctx context.Context, cfg *config.Config) error {
// geminiを初期化
gemini := gemini.NewGemini(cfg.GeminiConfig, ctx)
app := &cli.App{
Name: "refacgo",
Version: version,
Description: "A Go-based command-line tool that evaluates the code in a specified Go file and provides refactoring suggestions powered by AI",
Commands: []*cli.Command{
eval.EvalCmd(ctx, gemini, presenter.NewEvalConsolePrinter()),
// refacコマンドを追加
refac.RefacCmd(ctx, gemini, diff.NewCmpDiffer(), presenter.NewRefacConsolePrinter(), presenter.NewRefacFileOverWriter()),
},
}
if err := app.RunContext(ctx, os.Args); err != nil {
return err
}
return nil
}
func RefacCmd(ctx context.Context, genAI application.GenAI, differ diff.Differ, refacPrinter refactoring.RefacPrinter, refacOverWiter refactoring.RefacOverWriter) *cli.Command {
return &cli.Command{
Name: "refactor",
Aliases: []string{"refac"},
Description: "refactor code in the specifield file",
Usage: "refactor code in the specifield file",
UsageText: "refacgo refac [option] <filepath>",
HelpName: "refac",
ArgsUsage: "<filepath> is a path relative to the current directory where the command will be executed",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "japanese",
Aliases: []string{"j"},
},
&cli.StringFlag{
Name: "description",
Aliases: []string{"desc"},
Value: "",
Usage: "description of code in the specified file",
},
},
Action: func(cCtx *cli.Context) error {
refacCmdAction := initRefacCmdAction(cCtx, genAI, differ, refacPrinter, refacOverWiter)
if err := refacCmdAction.run(cCtx, ctx); err != nil {
return err
}
return nil
},
}
}
func initRefacCmdAction(cCtx *cli.Context, genAI application.GenAI, differ diff.Differ, refacPrinter refactoring.RefacPrinter, refacOverWiter refactoring.RefacOverWriter) *refacCmdAction {
var refacCmdAction *refacCmdAction
// -jフラグによってRefacotringインスタンスを切り替える
// ここでcmdActionを初期化する
if cCtx.Bool("japanese") {
// refacCmdAction = newRefacCmdAction(
// refactoring.NewRefactoringWithGenAiInJap(
// genAI,
// ),
// diff.NewCmpDiffer(),
// refacPrinter,
// refacOverWiter,
// )
} else {
refacCmdAction = newRefacCmdAction(
refactoring.NewRefactoringWithGenAI(
genAI,
),
differ,
refacPrinter,
refacOverWiter,
)
}
return refacCmdAction
}
refacコマンドに関するモジュールを図式化
これが大きく変更されることはないでしょう。
整理のために置いておきます。
refacCmdAction.run()での処理を整理
evalコマンドの時もそうですが、コマンド実行による振る舞いはcli.Command
構造体におけるActionフィールドにおきます。
今回は、コマンドアクションオブジェクトとしてそれを管理しています。
つまり、ビジネスロジックモジュールや、プレゼンターモジュールはこのコマンドアクションオブジェクトに利用されるわけなので、機能の流れはここに記述されることになります。
モジュールなどを意識しつつ、大体の流れを改めてまとめてみます。それから、各モジュールの実装に入ろうと思います。
コマンド実行〜処理終了の流れ
- コマンドラインの引数にとったファイル名を読み混んでバイトスライスに格納
- 既存のpkgにある
LoadExternal()
が使える
- 既存のpkgにある
- descフラグがあれば、上で格納したバイトスライスに説明文を追加する
- utilsに実装した
AddDescToSrc()
が使える
- utilsに実装した
- バイトスライスを
Refactoring.Refactor()
に渡し、リファクタされたソースコード(ポイントのコメントも込み)の文字列を受け取る-
Refactoring.Refactor()
の内部ではGenAI.QueryResult()
を非同期で呼び出す
※今回、逐次的に出力していくことが難しいので、ストリーム処理を行わずに生成AIにリクエストを送る処理を新たに定義する。そのため、GenAIインターフェースはStreamQueryResults()
とQueryResults()
の二つの振る舞いを持つものと変更するようにした
-
-
Refactoring.Refactor()
から返ってきた結果の文字列を「リファクタリングされたソースコード」と「リファクタリングしたポイントを含むコメント」に分離する-
divideTextAndCode()
を定義して、コード・テキストをそれぞれ別の文字列スライスに分けて返すようにする- ここでしかつかわない(あとビジネス概念から外れる気がする)ので、同ファイルのプライベート関数として定義
- マークダウンでは、「```(ソースコード)```」と言ったように囲まれているため、それを目印に抜き出すようにすればいい?
-
- 分離されたソースコード文字列と、「1.」で読み込んだ元のソースコードの差分を検出し、それを文字列で返す
-
Differ.Diff()
を使用する(この結果はターミナルに出力し、どこが書き変わったのか?というリファクタリング関連のものなのでビジネス概念に類した)
-
- コマンドライン引数で受け取ったファイルパスを書き込み専用で開く
- 上で書き込み権限を付与したファイルを
RefacFileOverWriter.OverWrite()
に渡し、上書きする -
divideTextAndCode()
で分けたテキストと差分を合わせて、RefacConsolePrinter.Print()
に渡し、出力する- 尚、これが出力されるまではスピナーが回るようにする
- ターミナル上で、このリファクタリングによる変更を適用させるかをy/Nで入力させる
- 真偽値を返すような
decideToApply()
をプライベート関数を用意する
- 真偽値を返すような
- リファクタリングを適用させる
-
decideToApply()
がtrueの場合は、fileをクローズしてを処理を終了する -
decideToApply()
がfalseの場合は、RefacFileOverWriter.OverWrite()
に元々のソースコードを渡してさらに上書きし、fileをクローズして処理を終了する
-
あげてみるとevalコマンドより遥かにやることが多そうです。
この処理の上から実装しながら、モジュールも実装していきます。
applicationディレクトリ→ domainディレクトリへ名前変更
ここまで、特に違和感なく、ビジネスロジックをapplicationディレクトリにおいて、アプリケーション層という扱いにしていましたが、RefactoringやEvaluationは、このCLIアプリケーションにおいて最上位の概念であることが言えます。
ということは、あえてディレクトリ名として責務を与えるならばドメインではないかという答えに達しました。
今回のCLIアプリはエンティティがありませんが、このアプリケーション固有の、核となるビジネスロジックが持つ責務を表すのにドメインという名前は適していると思います。
というわけで、アプリケーション(web,cli)に依存しない、最上位の概念であるドメインロジックとして置くことにしました。正確にはただディレクトリ名を置き換えただけになります。
形作られつつある現在のアーキテクチャを整理する
現在のディレクトリ構成は以下のとおりです。
.
├── cli // CLIアプリケーションのエントリーポイントとコマンド
│ ├── eval // evalコマンド関連ファイル
│ │ ├── eval_cmd.go // evalコマンドオブジェクト
│ │ ├── eval_cmd_action.go // evalコマンドのアクション(ロジック)
│ │ ├── eval_test.go // evalコマンドのテスト
│ │ └── testdata // evalコマンド用のテストデータ
│ │ ├── prompt // プロンプト用テストデータ
│ │ │ └── eval
│ │ │ ├── with_genai_in_jap_prompt.txt // 日本語でのプロンプト
│ │ │ └── with_genai_prompt.txt // 英語でのプロンプト
│ │ └── src
│ │ └── sample.txt // サンプルデータ
│ ├── refac // refacコマンド関連ファイル
│ │ ├── refac_cmd.go // refacコマンドオブジェクト
│ │ ├── refac_cmd_action.go // refacコマンドのアクション(ロジック)
│ │ └── utils // リファクタリングユーティリティ
│ │ └── devide_code_and_text.go // コードとテキストの分割ロジック
│ ├── root.go // CLIアプリケーション全体のエントリーポイント
│ └── shared // eval, refac 両コマンドで共有するロジック
│ ├── add_desc_to_src.go // ソースコードに説明を追加するロジック
│ └── add_desc_to_src_test.go // そのテスト
├── go.mod // モジュール依存関係
├── go.sum // 依存関係のバージョン固定
├── internal // アプリケーション内部ロジック
│ ├── config // 設定管理
│ │ └── config.go // 設定ファイル処理
│ ├── domain // ドメインロジック
│ │ ├── evaluation // 評価ロジック
│ │ │ ├── evaluation_interface.go // 評価ロジックの抽象型インターフェース
│ │ │ ├── evaluation_with_genai.go // 英語対応評価ロジック
│ │ │ ├── evaluation_with_genai_in_jap.go // 日本語対応評価ロジック
│ │ │ ├── evaluation_with_genai_in_jap_test.go // 日本語評価ロジックのテスト
│ │ │ ├── evaluation_with_genai_test.go // 英語評価ロジックのテスト
│ │ │ ├── instruction_text // 評価指示用テキスト
│ │ │ │ ├── genai_instruction.txt // 英語の評価指示
│ │ │ │ └── genai_instruction_in_jap.txt // 日本語の評価指示
│ │ │ └── testdata // 評価用テストデータ
│ │ │ └── prompt
│ │ │ ├── with_genai_in_jap_prompt.txt // 日本語プロンプト
│ │ │ └── with_genai_prompt.txt // 英語プロンプト
│ │ ├── genai_interface.go // GenAIサービスの抽象型
│ │ ├── genai_mock.go // GenAIモック実装
│ │ └── refactoring // リファクタリングロジック
│ │ ├── diff // 差分検出ロジック
│ │ │ ├── cmp_differ.go // 差分比較ロジック
│ │ │ └── differ_interface.go // 差分検出インターフェース
│ │ ├── refactoring_interface.go // リファクタリング抽象型
│ │ └── refactoring_with_genai.go // GenAIを使ったリファクタリングロジック
│ ├── gateway // 外部サービスへのアダプター
│ │ └── api // 利用する外部API
│ │ └── gemini
│ │ └── gemini.go // Gemini APIクライアント
│ └── presenter // プレゼンター(出力ロジック)
│ ├── eval_console_printer.go // eval結果のコンソール出力
│ ├── eval_printer_interface.go // eval結果の出力インターフェース
│ ├── eval_printer_mock.go // eval出力のモック
│ ├── indicater // スピナーなどのインジケーター
│ │ └── indicater.go // インジケーター表示
│ ├── refac_console_printer.go // refac結果のコンソール出力
│ ├── refac_file_overwriter.go // refac結果のファイル上書き
│ ├── refac_overwriter_interface.go // refac出力の上書きインターフェース
│ └── refac_printer_interface.go // refac結果の出力インターフェース
├── main.go // アプリケーションのメインエントリーポイント
└── pkg // 汎用的なロジック
└── load_file
├── load_file.go // ファイル読み込みロジック
├── load_file_test.go // そのテスト
└── testdata // ファイル読み込み用のテストデータ
└── sample.txt // サンプルデータ
大きく、次のようなモジュールに分けて考ています。
cli
コマンドラインツールアプリケーションを実現します。
実際には、urfave/cli
のApp
構造体を持っていて、その中に各コマンドオブジェクト(cli.Command
)を持っています。
ここがCLIのエントリポイントとなり、main.goから実行されます。
func Execute(ctx context.Context, cfg *config.Config) error {
// geminiを初期化
gemini := gemini.NewGemini(cfg.GeminiConfig, ctx)
app := &cli.App{
Name: "refacgo",
Version: version,
Description: "A Go-based command-line tool that evaluates the code in a specified Go file and provides refactoring suggestions powered by AI",
Commands: []*cli.Command{
eval.EvalCmd(ctx, gemini, presenter.NewEvalConsolePrinter()),
refac.RefacCmd(ctx, gemini, diff.NewCmpDiffer(), presenter.NewRefacConsolePrinter(), presenter.NewRefacFileOverWriter()),
},
}
if err := app.RunContext(ctx, os.Args); err != nil {
return err
}
return nil
}
cli - Cmd
cli.App
構造体の中で、コマンドを登録するためのcli.Command
構造体を表します。
さらにこの中に、コマンドの振る舞いを定義するActionフィールドが存在します。
自分は、コマンドオブジェクトをハンドラーのようなものと捉えています。
-
eval
コマンドが叩かれたら、evalコマンドオブジェクトの中に定義されたActionを実行 -
refac
コマンドが叩かれたら、refacコマンドオブジェクトの中に定義されたActionを実行 - さらに
-j
フラグがあったら、Actionに伝達して適切に振る舞うようにする
func EvalCmd(ctx context.Context, genAI domain.GenAI, evalPresenter evaluation.EvalPrinter) *cli.Command {
return &cli.Command{
Name: "evaluate",
Aliases: []string{"eval"},
// 省略
// アクション
Action: func(cCtx *cli.Context) error {
evalCmdAction := initEvalCmdAction(cCtx, genAI, evalPresenter)
if err := evalCmdAction.run(cCtx, ctx); err != nil {
return err
}
return nil
},
}
}
cli - Cmd - Action
Actionフィールドにてコマンド実行による振る舞いを定義するのに独自にcmdActionオブジェクトを実装しています。
コマンドオブジェクトをハンドラーのようなものとすれば、cmdActionはユースケースのようなものを表すようにしています。
各モジュールは、コンストラクタインジェクションによって、ここでDI(依存性注入)されます。
cmdAction.run()関数を見れば、各コマンドが持つ機能が見えるようになっています。独立した各モジュールを呼び出し、コマンドが果たすべき機能を表しています。
- フラグによる挙動の分岐を実装
- コマンドから渡ってくる引数の処理
- ドメインロジックを呼び出す
- プレゼンターを呼び出す
- 出力は任せる
func (eca *evalCmdAction) run(cCtx *cli.Context, ctx context.Context) error {
if cCtx.NArg() != 1 {
return errors.New("only one argument, the filename, is required")
}
// ファイル名(パス)を引数から取得
filename := cCtx.Args().Get(0)
if strings.HasPrefix(filename, `"`) || strings.HasSuffix(filename, `'`) {
filename = filename[1 : len(filename)-1]
}
// 引数のファイルを読み込んで、バイトスライスを格納
src, err := loadfile.LoadExternal(filename)
if err != nil {
return err
}
// descフラグから文字列を取得し、ソースに追加
desc := cCtx.String("description")
// フラグから""が帰ってきた時はそのままソースはそのまま返る
src = shared.AddDescToSrc(src, desc)
// Evaluateの結果をモジュール間で逐次出力するためのチャネル
ch := make(chan string)
// ビジネスロジック
// 結果をストリームでチャネルに送信する
err = eca.Evalueation.Evaluate(ctx, src, filename, ch)
if err != nil {
return err
}
// チャネルからストリームで受信する
eca.EvalPresenter.Print(ctx, ch)
return nil
}
domain
CLIに依存しない、最上位のビジネス概念を表すものです。evaluation(コード評価)やrefactoring(リファクタリング)を、ドメインロジックとして実装しています。
なお、データと紐づく値がない(DBを使用しない)ため、ドメインモデルは存在していません。単にビジネスロジックが実装されているということになります。
- ドメインロジックの振る舞いを定義するインターフェース
type Evaluation interface {
Evaluate(ctx context.Context, src []byte, filename string, ch chan<- string) error
}
- ドメインロジック
type EvaluationWithGenAI struct {
genAI domain.GenAI
}
func NewEvaluationWithGenAI(genAI domain.GenAI) *EvaluationWithGenAI {
return &EvaluationWithGenAI{
genAI: genAI,
}
}
func (ev *EvaluationWithGenAI) Evaluate(ctx context.Context, src []byte, filename string, ch chan<- string) error {
instruction, err := loadfile.LoadInternal("./instruction_text/genai_instruction.txt")
if err != nil {
panic(err)
}
prompt := fmt.Sprintf("The name of this file is %q.\n\n%v\n\n", filename, string(instruction))
go func() error {
err = ev.genAI.StreamQueryResults(ctx, src, prompt, ch)
if err != nil {
return err
}
return nil
}()
return nil
}
presenter
出力は全てこのpresenterモジュールに任せるようにしています。
evalコマンドを例にとると、以下のようになっています。
- プリンターインターフェース
type EvalPrinter interface {
Print(ctx context.Context, ch <-chan string) error
}
- コンソールにプリント出力する
func (ep *EvalConsolePrinter) Print(ctx context.Context, ch <-chan string) error {
is := ep.indicater.Spinner
is.Suffix = " Waiting for evaluating..."
is.Start()
defer is.Stop() // 処理の最後に必ずスピナーを停止する
<-ch // チャネルから受信するまではブロッキング
is.Stop()
for text := range ch {
fmt.Println(text)
}
return nil
}
cmdActionにプラグインしたプラグインアーキテクチャ
cmdAction(ユースケースレイヤ相当)には、各レイヤーのモジュールがプラグインされています。
インターフェースによって境界線が引かれているので、cmdActionは実装の詳細を知らずに利用できます。
また、各レイヤーにおけるモジュールは独立しているので、cliアプリケーションに依存しているわけではありません。
このようなアーキテクチャは、クリーンアーキテクチャを参照すると、プラグインアーキテクチャと言及されています。
ソフトウェア開発技術の歴史は、いかに都合よくプラグインを作成するかの物語だ。プラグインによって、スケーラブルで保守可能なシステムアーキテクチャを確立するのである。コアとなるビジネスルールは、選択式またはそのほかの形式で実装されたコンポーネントから分離・独立している(
Robert C.Martin; 角 征典; 高木 正弘. Clean Architecture 達人に学ぶソフトウェアの構造と設計 (アスキードワンゴ) (pp.216-217). 株式会社ドワンゴ. Kindle 版.
必要な機能が含まれているそのほかのコンポーネントは、コアのビジネスには直接関係しないので、プラグインにしておく。次に、コンポーネントにコードを配置して、そこから一方向にコアのビジネスに向かって矢印を描く。
Robert C.Martin; 角 征典; 高木 正弘. Clean Architecture 達人に学ぶソフトウェアの構造と設計 (アスキードワンゴ) (p.220). 株式会社ドワンゴ. Kindle 版.
最初からこのプラグインアーキテクチャを意識したわけではありませんが、結果的に出来上がったアーキテクチャをみて、そう呼ぶことができそうだなと考えました。
このアーキテクチャには次の利点があります。
-
疎結合
- 各レイヤー(Presenter、Domain)は抽象型を介して接続されており、依存関係が限定的。プラグインの追加・変更が容易。
-
拡張性
- 新しいモジュール(例: 日本語対応の評価ロジック EvaluationWithGenAIInJap)を追加しても、既存コードの修正はほとんど必要ない。
-
テスト可能性
- 各モジュールをスタブやモックに置き換えられるため、単体テストが容易。
ただ、「プラグインアーキテクチャ」というのは、設計における考え方だとも思えるので、「クリーンアーキテクチャ」「オニオンアーキテクチャ」のようにアーキテクチャを名付けるようなものでもないかもしれませんね。
(今更)思い出した外部ファイルの読み込み方
プログラムの中で別ディレクトリのファイルを読み込む場合は、go:embedマジックコメントが有用です
以前はすごく回りくどいことをしていました。
わざわざランタイムをどうこうとか.....
閃いたかのようにスクラップにもまとめていました。
遡ると以下のような実装をしています。
// アプリケーション内で利用する内部リソースを読み込む関数
func LoadInternal(relativePath string) ([]byte, error) {
// 呼び出しもとのパスを得る
_, caller_path, _, ok := runtime.Caller(1)
if !ok {
return nil, fmt.Errorf("failed to get caller information")
}
basePath := filepath.Dir(caller_path)
absPath := filepath.Join(basePath, relativePath)
f, err := os.Open(absPath)
if err != nil {
log.Fatal(err)
}
defer f.Close()
// バッファを持った空のバイト配列
var buf bytes.Buffer
// 行単位でファイルを読み込む
scannar := bufio.NewScanner(f)
// 一行ごとにループ
for scannar.Scan() {
// bufに書き込み
buf.Write(scannar.Bytes())
// 文末に改行
buf.WriteByte('\n') //1バイト書き込む
}
return buf.Bytes(), nil
}
まぁ、これはこれでOSの話やランタイム、コールスタックの話を理解できたので良かったと思っています。
そんで、ある日、 goの技術書を振り返りでパラパラめくっていて「そういえば..」って感じで思い出したのが、、
go:embed によるファイルの読み込み
以下のように、マジックコメントを書いてその下にバイトスライス型の変数を宣言しておけば、ビルド時に読み込まれます。
実行ファイルに他のファイルを埋め込めるようにするものです。マジックコメントで結構目立つので、別ファイルを読み込んでんだな、とわかりやすいのもいい点と思います。知っていればの話ですが。
記述も簡単ですし、こっちに統一しました。
//go:embed testdata/prompt/with_genai_prompt.txt
var expectedPrompt []byte
リファクタリング結果をコード部分とテキストに分離する
以下にまとめた流れの「4.」にあたる処理をutils関数として書いていきます。
方針
- 引数にとった文字列を、正規表現でパターンマッチして、コード部分を抜き出す。
- コード部分はマークダウンで返ってくることを考えれば、``` ``` で始まることがわかるので、正規表現は用意できる
- 正規表現にマッチする文字列をコードとする
- 混合の文字列からコードにマッチする部分を空文字で置き換えたものをテキストとする
実装は以下のようになりました。
なお、直近で見た記事(トレンド)では、正規表現のコンパイルをトップレベルで行うべきと書いていました。関数内に含んだ場合、それを複数箇所で使用する場合にコンパイルをその回数分行わなければならなくなってしまい非効率です。
// マークダウンテキストにおけるコードブロックの内容を抜き出す正規表現
var codeBlockRegex = regexp.MustCompile("```[a-zA-Z ]*\n*([\\s\\S]*?)```")
func DevideCodeAndText(mix string) (code, text string, err error) {
m := codeBlockRegex.FindStringSubmatch(mix)
// コードブロックが含まれていなかった場合はエラーを返す
if len(m) == 0 {
return "", "", errors.New("failed to match codeblock in md text")
}
// コードブロックにマッチした文字列
code = m[1]
// mixからコードブロックを削除した文字列
text = codeBlockRegex.ReplaceAllString(mix, "")
return code, text, nil
}
テストコードも載せておきます。
//go:embed testdata/mix_of_code_and_text.txt
var mix string
//go:embed testdata/only_text.txt
var onlyText string
//go:embed testdata/devided_code.txt
var devidedCode string
//go:embed testdata/devided_text.txt
var devidedText string
func TestDecideCodeAndText(t *testing.T) {
t.Parallel()
type want struct {
code string
text string
}
tests := []struct {
name string
arg string
want want
wantErr bool
}{
{
name: "引数にとった文字列をコードとテキストに分離して返す",
arg: mix,
want: want{
code: devidedCode,
text: devidedText,
},
wantErr: false,
},
{
name: "引数にとった文字列にコードブロックがない場合、エラーをかえす",
arg: onlyText,
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
code, text, err := DevideCodeAndText(tt.arg)
fmt.Println(code)
if (err != nil) != tt.wantErr {
t.Error(err)
return
}
if diff := cmp.Diff(tt.want.code, code); diff != "" {
t.Errorf("DevideCodeAndText() return code mismatch (-want +got): %s", diff)
}
if diff := cmp.Diff(tt.want.text, text); diff != "" {
t.Errorf("DevideCodeAndText() return text mismatch (-want +got): %s", diff)
}
})
}
}
cmdAction.run()の流れを実装
詳細は実装していません。全てレイヤー別のモジュールはインターフェースに依存させているためです。
utils関数は実装しています。
func (rca *refacCmdAction) run(cCtx *cli.Context, ctx context.Context) error {
if cCtx.NArg() != 1 {
return errors.New("only one argument, the filename, is required")
}
// ファイル名(パス)を引数から取得し読み込む
filename := cCtx.Args().Get(0)
if strings.HasPrefix(filename, `"`) || strings.HasSuffix(filename, `'`) {
filename = filename[1 : len(filename)-1]
}
originSrc, err := loadfile.LoadFile(filename)
if err != nil {
return err
}
// descフラグから文字列を取得し、ソースコードに追加
desc := cCtx.String("description")
originSrcWithDesc := shared.AddDescToSrc(originSrc, desc)
// リファクタリングする
result, err := rca.refactoring.Refactor(ctx, originSrcWithDesc, filename)
if err != nil {
return err
}
// リファクタリング結果をテキスト/コードに分ける
code, text, err := utils.DevideCodeAndText(result)
if err != nil {
return err
}
// 差分を検出
diff := rca.differ.Diff(string(originSrc), code)
// ファイル元を書き込み権限で開く
f, err := os.Create(filename)
if err != nil {
return err
}
defer f.Close()
// ファイルへの上書き
// ヘッダーコメントを付加して上書きする
rca.refacOverWriter.OverWriteWithHeaderComment(f, code)
// テキスト・差分を表示
rca.refacPrinter.Print(text, diff)
// 上書きを確定するかどうか
if utils.DecideToApply() {
rca.refacOverWriter.OverWrite(f, code)
} else {
rca.refacOverWriter.OverWrite(f, string(originSrc))
}
return nil
}
Refactoringドメインロジック
ユースケース相当のcmdAction側で非同期に呼び出すことにします。
そのため、チャネルを受け取り、genAIに渡すようになっています。
非同期にする理由は、スピナーに回ってもらうようにするためです。
そういえば、 Evalの時はドメインロジックに持たせましたが非同期処理を任せましたが、ユースケースで行った方が良さそうですね。前回の記事とは捉え方が違ったので、(ドメインとアプリケーション層)。
今は、cmdActionをユースケースと捉えているので、近々そこら辺のリファクタリングはしていこうと思っています。気持ち悪いので。
//go:embed instruction_text/genai_instruction.txt
var instruction string
func (rf *RefactoringWithGenAI) Refactor(ctx context.Context, src []byte, filename string, ch chan<- string) error {
prompt := fmt.Sprintf("The name of this file is %q.\n\n%v\n\n", filename, instruction)
if err := rf.genAI.QueryResuluts(ctx, src, prompt, ch); err != nil {
return err
}
return nil
}
func (gc *Gemini) QueryResuluts(ctx context.Context, src []byte, prompt string, ch chan<- string) error {
// client & modelが何らかの理由でnilの場合は早期リターン
if gc.client == nil || gc.model == nil {
return errors.New("connection to Gemini failed")
}
// 実行が終わったらクライアントをクローズしておく
defer func() error {
if err := gc.client.Close(); err != nil {
return err
}
return nil
}()
// 受け取ったバイト配列を文字列にしたものをラップ
code := genai.Text(string(src))
// プロンプトをラップ
promptText := genai.Text(prompt)
resp, err := gc.model.GenerateContent(ctx, promptText, code)
if err != nil {
return err
}
// 送信チャネルをクローズ
defer close(ch)
for _, cand := range resp.Candidates {
if cand.Content != nil {
for _, part := range cand.Content.Parts {
// Text型の場合のみレスポンス文字列に格納する
switch p := part.(type) {
case genai.Text:
ch <- string(p)
}
}
}
}
return nil
}
その他もろもろを行い、refacコマンド(英語版)が完成
特に難しいことをしていないので、記事には何も残しませんでした。
悩んでいたとすれば、インジケータースピナーをモジュールとして独立させた方がいいなって後から気づいたりでゴタゴタしてたりぐらいですかね。、
ファイルを指定すると、スピナーが回ります。(ちょうど出力される瞬間に録画時間超えました)
ファイルが書き換わっています。
適用を確定させる前は、refacgoによって生成されたコードであることを明示するためにヘッダコメントをつけています。
y/n が問われるので、適用させる場合はyです。適用させたら、ヘッダコメントが取れて保存されます。
nだと元に戻ります。
ユニットテストは行った(行う必要がありそうなやつのみ)が、refacコマンドのテストはコスト的にやる根気がないのでしないことにした。
githubでリリースしてみよう
この記事に沿っていけばできるでしょうか。
できました😄
go install でダウンロードすることもできました。
このままbrewもやっちゃおうかな。
% go install github.com/kakkky/refacgo@latest
go: downloading github.com/kakkky/refacgo v0.1.0