golangで「無駄に」アーキテクチャを設計したCLI 開発
はじめに
最近はgolangを勉強しています。楽しいなと思いながらハンズオンをいくつかこなしていましたが、多少なりとも「身についたなぁ」「ちょっとは書ける」と言えるようになるには、オリジナルの開発を行ってこそかなぁと思ったりしたので、学習のアウトプットという形でCLI開発、そしてリリースまでしてみました。
そしてさらに、最近はクリーンアーキテクチャを読んだりそれに準じたハンズオンをやったりして、設計に気を配ってやってみたいなということで、小規模なCLIながらも拡張性を考慮し、モジュールを疎結合に保つ設計を心がけて開発しました。
この規模感で今後いじる気もないのなら、設計なんおてそんなものいらないレベルだと思います。だからこそ、タイトルに「無駄に」とつけておきました。
が、こんな感じでしたらいいのかなーを自分なりに考えてみました。
制作期間は構想から含め1週間です。
なお、開発の記録として綴ったスクラップをもとに記事をまとめていくので、興味があればご覧ください。
目次
- 何を作ったか
- アーキテクチャ
- リリース
- 感想
1. 何を作ったか
以下、githubのリポジトリです。手っ取り早くは、READMEをご覧ください。
概要
生成AIを用いて、コードの評価、リファクタリングをコンソール上のコマンド一つで行えるようにしたものです。
評価とリファクタリングの機能2つに、それぞれコマンドが用意されており、引数に対象としたいファイルパスを指定します。また、フラグオプションとして、日本語対応にするものとファイルについての説明をつけたせるものがあります。
作った理由
特にありません。こじつけていえば、「いちいちリファクタリングや評価を生成AIにお願いするのに、うウィンドウを切り替えてコピペしたファイルの中身を送信するという少し手間な流れを、そのままコンソール上で解決するようにした」といった感じです。述べたとおり学習のアウトプットです。
しかし、**なんでCLIを作ったの?**という理由はあります。
これはズバリ、リリースの簡単さにあります。
私は、オリジナルのものを作ったからにはなんらかの形で配布というか公開したいと思う派です。
自分にとって、webアプリケーションのリリース(デプロイ)は結構面倒です。また、基本お金がかかります。無料枠はあったりしますが、無料で抑えようと思うと一定期間でそのサービスが終了になります(悲しい)。
そこで、CLIはモノによるかもしれないけどwebサーバーが不必要です。
3.リリース のところで後述しようと思いますが、github Actions、goreleaserといった便利なツールのおかげで簡単にリリースすることが可能です。
また、開発の中で、ローカルでも簡単に試すことができます。go install
(引数はいりません)でシングルバイナリをダウンロードするだけで自作コマンドを叩けます。もしくは、go build
(これも特に引数はいりません)でビルドしたファイルから実行することもできます。
urfave/cliパッケージを使用した理由
golangでcliを作成するなら、おそらく次の3つです。
- cobra
- urfave/cli
- flag(Go標準)
その中でurfave/cliを採用したのは、シンプルな開発ができると考えたからです。cobraもシンプルな記法ではあると思うのですが、機能の豊富さゆえに、若干の複雑性があると捉えました。
また、cobra-cliによるボイラーテンプレートの作成にも若干抵抗がありました。今回は、自分で一から、そこまでサンプルもない状態でいい感じのアーキテクチャで作るぞ!!と意気込んでいたので、自分で作っていきたかったという感じです。雛形といっても、ごく僅かだと思うんですけどね。
ドキュメントがシンプルでみやすかったからというのもあります。
詳細
この形式で実行します。
refacgo <command> [options] <filepath>
コマンドは2つ用意しており、
eval
: 引数にとったファイルを評価し、コンソール上にコメントを出力します。
refac
: 引数にとったファイルをリファクタリングして自動で書き換え、さらにアドバイスをコンソール上に出力します。
コマンドを実行しながら機能をもう少し深く紹介します。
eval
以下の形式となります。
$ refacgo eval [option] <filepath>
試しに以下のコマンドで叩いてみます。
refacgo eval ./main.go
すると、しばらくのローディング後、逐次的に評価コメントが出力されます。
以下に出力の全文を載せておきます。
evalコマンドの出力
##
Code Review: ./main.go
Here's a review of your Go
code, rated on a 10-point scale for each category, followed
by a total score and suggestions for improvement.
**Readability:** 8/10 ✅✅✅✅✅✅✅✅
* Good use
of comments explaining the purpose of functions.
* Variable names are mostly clear.
* Logic is straightforward and easy to follow.
**Maintainability:**
7/10 ✅✅✅✅✅✅✅
* The code is reasonably modular. The separation of `run` and `main` is good.
* Error handling is present but could be improved (see below).
* Consider breaking down `NewMux` into smaller, more focused functions for better maintainability.
**Performance:** 8/10 ✅✅✅✅✅✅✅✅
* No obvious performance bottlenecks.
* Efficient use
of standard library functions.
**Error Handling:** 6/10 ✅✅✅✅✅
* Uses `log.Fatalf` which is acceptable but prevents graceful shutdowns in certain situations. Consider using more sophisticated error handling, perhaps with a dedicated error logger and more context in the error messages. `os
.Exit(1)` is generally discouraged in favor of returning errors and letting the caller decide how to handle it.
**Testability:** 6/10 ✅✅✅✅✅
* Functions are generally at a testable granularity.
* Missing explicit unit tests. The code would benefit greatly
from tests to ensure correctness and aid in future development and refactoring.
**Security:** 5/10 ✅✅✅✅
* Input validation is lacking. The port number is read from config; it needs to be validated to prevent vulnerabilities.
* Security best practices (like input sanitization
, and consideration for potential attacks) should be incorporated more thoroughly.
**Documentation:** 7/10 ✅✅✅✅✅✅
* Function comments are helpful.
* Consider adding more comprehensive documentation, including a project README and possibly godoc comments.
**Reusability:** 7/10
✅✅✅✅✅✅
* The code is structured in a way that components could be reused, however, the dependencies on the `config` package would need to be considered when reusing functions.
**Consistency:** 8/10 ✅✅✅✅✅✅✅✅
* Coding style is consistent
.
**Selection of appropriate algorithms:** 9/10 ✅✅✅✅✅✅✅✅✅
* The use of `net.Listen` and the concurrency model seem appropriate for a simple server.
**Total Score:** 71/100
**Recommendations for Improvement:**
1.
**Robust Error Handling:** Replace `log.Fatalf` with more sophisticated error handling that allows for graceful shutdowns. Consider using context cancellation more consistently. Provide better error messages including context of what caused the error.
2. **Input Validation:** Validate the port number read from the configuration to prevent invalid or malicious inputs
. Other inputs (if present in `NewMux` etc) should also be validated.
3. **Security:** Implement security best practices. Consider things like rate limiting, input sanitization, etc. This is vital for any production server.
4. **Testing:** Write comprehensive unit and integration tests to
ensure code correctness, prevent regressions and enhance maintainability.
5. **Dependency Management:** Consider using a dependency management tool (like Go Modules) to ensure consistent and reproducible builds.
6. **Logging:** Improve logging to provide more detailed information for debugging and monitoring. Use a structured logging format for easier analysis.
By addressing these points, you can significantly improve the code's quality, robustness, and maintainability. Remember that security is paramount for any network application.
また、日本語で出力して欲しい場合は、-j
フラグをつけます。また、コードに説明を付加したい場合は-desc
の後に説明をつけることができます。
以下のコマンドで打ってみましょう
refacgo eval -j -desc このファイルはtodoアプリのAPIのエントリーポイントです ./main.go
出力全文
✅
**コードレビュー** 🎉
このGoコードは、ToDoアプリのAPIのエ
ントリーポイントとして機能するシンプルなサーバーです。いくつかの改善点がありますが、全体的には
良い出発点です。
**評価:**
* **可読性 (Readability):** 8/10 - 変数名と関数
名は概ね明確です。コメントも最低限必要で良いですが、`NewMux`や`NewServer` の内部動作についてのコメントがあるとさらに理解
しやすくなります。
* **保守性 (Maintainability):** 7/10 - `run`関数は比較的短く、理解しやすいですが、`NewMux`と`NewServer`の実装が見えないため
、保守性の評価は控えめです。これらの関数が適切にモジュール化され、疎結合であることを確認する必要があります。
* **パフォーマンス (Performance):** 9/10 - 現状ではパフォーマンス上のボトルネック
は見当たりません。効率的なコードです。
* **エラーハンドリング (Error Handling):** 8/10 - エラーは適切に処理され、`log.Fatalf` を使用して致命的なエラーをログ出力しています。しかし、ユーザーフレンドリーなエラーメッセージを表示する方が良いでしょう
。例えば、HTTPステータスコードを使用して、エラーをクライアントに伝えるべきです。
* **テスト可能性 (Testability):** 6/10 - `NewMux` と `NewServer` の実装が不明なため、テストのしやすさを正確に評価できません。これらの関数を
インターフェースを使って依存関係を注入するように設計すれば、モックを使ってテストしやすくなります。
* **セキュリティ (Security):** 5/10 - ポート番号の設定は環境変数から行われていますが、入力検証は行われていません。セキュリティの観点から、ポート番号が適切
な範囲内にあるかを確認する必要があります。また、リクエストへの入力検証も必要です。
* **ドキュメント (Documentation):** 6/10 - コード自体にはコメントがありますが、より詳細なドキュメント(例えば、godocコメント)が必要です。特に`NewMux`と`NewServer`
関数の機能や引数、戻り値について明確な説明が必要です。
* **再利用性 (Reusability):** 7/10 - `run`関数は、設定ファイルを読み込むことで、他のプロジェクトでも再利用できる可能性があります。しかし、依存関係(`config`
パッケージなど)に依存しているため、再利用性は限定的です。
* **一貫性 (Consistency):** 9/10 - コードスタイルは一貫しています。
* **適切なアルゴリズムの選定 (Selection of appropriate algorithms):** 10/10 -
このコードでは、特に複雑なアルゴリズムは使用されていないため、この点については問題ありません。
**総合評価: 74/100**
**改善のためのアドバイス:**
* **エラー処理の改善:** より詳細でユーザーフレンドリーなエラーメッセージを提供し、HTTPステ
ータスコードを適切に使用してください。
* **セキュリティの強化:** ポート番号の入力検証とリクエストデータのバリデーションを追加してください。
* **テスト可能性の向上:** インターフェースと依存性の注入を使用して、`NewMux`と`NewServer`をテストしやすいように設計してください。 ユニット
テストを書きましょう。
* **ドキュメントの追加:** godocコメントを使用して、関数やパッケージに関する詳細なドキュメントを追加してください。
* **設定の柔軟性:** 設定ファイルのフォーマットをより柔軟なものにする(YAMLなど)。
* **ログ出力の改善:** 構造化されたログ
出力を使用すると、ログの解析が容易になります。
この改善によって、コードの品質、保守性、そしてセキュリティが大幅に向上します。 継続的な改善を心がけましょう! 😄
refac
同様、以下の形式です。
$ refacgo refac [option] <filepath>
refac
も同じく、-j
と-desc
フラグを備えています。今回は、-j
フラグを使用して、後ろににチラ見えしているmain.goのリファクタリングをお願いしてみましょう。
refacgo refac -j ./main.go
GIF内におさまりませんでした😅
以下のように出力されました。
後ろのファイルのヘッダコメントに注目してください。このコードがrefacgoによってリファクタリングされたものであることが書いてあります。
また、コンソール上にはアドバイス・コード差分が出力されています。これで、何が追加されて何が削られたかもわかります。なお、refacコマンドに関してはevalコマンドとは違い、逐次的に出力されるわけではありません。
コンソールへの出力
**リファクタリングのポイント:**
1. **エラーハンドリングの改善:** `fmt.Errorf` を使用して、より詳細なエラーメッセージとエラーのラップを行うことで、デバッグを容易にしました。
2. **`NewServer` 関数の導入:** HTTP サーバーの設定を`NewServer`関数にまとめることで、コードの可読性と保守性を向上させました。
3. **リソースのクリーンアップ:** `listener.Close()` を `defer` で実行するように変更し、リソースリークを防ぎました。
4. **シグナルハンドリングの追加:** `syscall.SIGINT` と `syscall.SIGTERM` をキャッチして、サーバーをgracefullyに停止できるようにしました。 これにより、Ctrl+Cなどで中断された際も、適切に終了処理が行われます。
5. **ログメッセージの改善:** より明確で読みやすいログメッセージに変更しました。
6. **Context の活用:** `context.WithCancel` を使用して、シグナル受信時にサーバーを停止できるようにし、`server.ListenAndServe(ctx)` を使用して、コンテキストを意識したサーバーの起動・停止を実現しました。
7. **タイムアウト設定の追加:** `NewServer` 関数にタイムアウト設定を追加することで、サーバーの安定性を向上させました。
**このような意識で書けばよいというアドバイス:**
* **エラー処理を徹底する:** エラーが発生する可能性のある箇所はすべて、適切なエラーハンドリングを行う。`errors.Wrap` を活用して、エラーのコンテキストを保持する。
* **可読性を重視する:** 変数名、関数名、コメントを分かりやすくする。コードのフォーマットを統一する。
* **関数やメソッドを小さく、役割を明確にする:** 一つの関数は一つのことを行うようにする。
* **リソースリークを防ぐ:** ファイル、ネットワーク接続、データベース接続などは、必ずクローズする。`defer` を有効活用する。
* **テストしやすいコードを書く:** 関数やメソッドは独立性が高く、テストしやすいように設計する。
* **標準ライブラリを積極的に活用する:** 標準ライブラリで提供されている機能を有効活用することで、コードの品質と保守性を高めることができます。
* **Context を意識する:** 非同期処理や長時間の処理において、Context を使用することで、処理の中断やタイムアウト処理を容易に実装できます。
これらの点を意識することで、より保守性が高く、理解しやすい、そしてバグの少ないコードを書くことができます。 今回のリファクタリングはこれらの原則に基づいて行われています。 `http` パッケージのインポートを忘れないでください。
the difference is (-orign +refactored): (
"""
... // 6 identical lines
"net"
"os"
+ "os/signal"
+ "syscall"
"github.com/YutaKakiki/go-todo-api/config"
)
+ // NewServer はHTTPサーバーを生成します。引数にリスナーとハンドラーを受け取ります。
+ func NewServer(listener net.Listener, handler http.Handler) *http.Server {
+ return &http.Server{
+ Handler: handler,
+ // graceful shutdownのためのタイムアウト設定
+ ReadTimeout: 5 * time.Second,
+ WriteTimeout: 10 * time.Second,
+ IdleTimeout: 15 * time.Second,
+ MaxHeaderBytes: 1 << 20, // 1MB
+ BaseContext: func(listener net.Listener) context.Context {
+ return context.WithValue(context.Background(), "listener", listener)
+ },
+ }
+ }
+
func main() {
- // run関数内でサーバーを起動
- if err := run(context.Background()); err != nil {
- fmt.Printf("failed to terminate server:%v", err)
- os.Exit(1)
- }
- }
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
- // 外部からのキャンセル操作を受け取るとサーバを修了する
+ // シグナルハンドリングを追加
+ sigCh := make(chan os.Signal, 1)
+ signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
+ go func() {
+ <-sigCh
+ cancel()
+ }()
+
+
+ if err := run(ctx); err != nil {
+ fmt.Printf("failed to terminate server: %v\n", err)
+ os.Exit(1)
+ }
+ }
+
... // 12 identical, 9 removed, and 16 inserted lines
"""
)
そして、この出力の後、コンソール上には次のような状態で、入力待ちをしています。
Do you want to apply this refactored code?
(y/n):
yを押せば、ヘッダコメントが取れてリファクタリングされたコードが保存されます。
nを押せば、元のコードに戻ります。
それ以外だと無限ループで正しい入力を迫るようになっています。
今回はnoとしましょう。
本当に元に戻ってるの??とわかりにくければ、どうぞinstallして試してみてください!
2.アーキテクチャ
このCLIアプリケーションのアーキテクチャは、以下のようになっています。
設計で意識したことは、次のような点です。
- モジュール間の疎結合
- 下位レベルに対しては、インターフェースに依存させる
- 他のモジュールの実装の詳細を知らないようにする
- ビジネスロジックは、コマンドラインツールの仕様に依存しない
- webアプリケーションであっても同様に使用可能なドメインロジックを実装する
- なるべく責務別にモジュールを分ける。
- アプリケーション層 :
cli.Command.Action
フィールドに配置する、コマンドの持つ振る舞い。ドメイン層とプレゼンター層のモジュールを利用して振る舞いを実装。 - ドメイン層:ビジネスロジックを担当
- プレゼンター層:出力を担当
- ゲートウェイ層 : geminiAPIなど外部とのやり取りを担当
- アプリケーション層 :
cmdAction(アプリケーション層相当)には、各レイヤーのモジュールがDI(依存性の注入)されています。
インターフェースによって境界線が引かれているので、cmdActionは実装の詳細を知らずに利用できます。
また、各レイヤーにおけるモジュールは独立しているので、cliアプリケーションに依存しているわけではありません。
このアーキテクチャには次の利点があります。
-
疎結合
- 各レイヤー(Presenter、Domain)は抽象型を介して接続されており、依存関係が限定的。プラグインの追加・変更が容易。
-
拡張性
- 新しいモジュール(例: 日本語対応の評価ロジック EvaluationWithGenAiInJap)を追加しても、既存コードの修正はほとんど必要ない。
- 生成AIのモデルを変更する場合(geminiからたとえばOpenAIへ)も、インターフェースによる境界線のおかげで、DIするモジュールを変更するだけで可能になります。既存のコードの変更は要りません。追加だけでOKです。
-
テスト可能性
- 各モジュールをスタブやモックに置き換えられるため、単体テストが容易。
レイヤーごとの責務わけ
今回の構成には、レイヤーとして分けると次のようになります。
- プレゼンテーション層
- アプリケーション層
- ドメイン(ビジネスロジック)層
- ゲートウェイ層
この各レイヤーには次のようなモジュールを配置してあります。
-
プレゼンテーション層
- *cli.App.Command
- これはwebAPIでいうハンドラ・コントローラのようなものと捉えています
- Presenter
- EvalPrinter
- RefacPrinter
- RefacOverWriter
- *cli.App.Command
-
アプリケーション層
- evalCmdAction(cli.Command.Action)
- refacCmdAction(cli.Command.Action)
-
ドメイン(ビジネスロジック)層
- Evaluation
- Refactoring
- Differ
-
ゲートウェイ層
- GenAI
レイヤードアーキテクチャを想起しますが、少し異なるかなと思います。レイヤードアーキテクチャだと、依存関係は「上の層から下の層へ」のみ許可されます。
ただ、今回の場合はこんなふうに表せるかなと思います。
Applicationはインターフェースに依存しており、各モジュールそれぞれが疎結合になっています。
GatewayにあるGenAIモジュールを利用してドメインロジックを実装してますが、これもインターフェースに依存させています。
依存関係の逆転を実現できているでしょうか。若干不安で言い切るのが怖いですが、多分できていることにします。
また、*cli.App.Commandがハンドラのようだと書いた理由は、WebAPIの場合にある「ルーティング→特定のユースケースやアクションを呼び出す」流れと同じように、サブコマンドとして登録されたコマンド(今回だとrefac
とeval
)によって特定の機能を呼び出す、この感じがなんか似てるなぁと思い、外部からコマンドを受け取るという点でプレゼンテーション層に含めておきました。
しかしながら、綺麗に責務分けできているかは怪しいところです、実際。
以下にコマンドアクション(アプリケーション層相当)を載せますが、ここに若干責務が集中し過ぎている間があります。
以下、懸念点です。
- アプリケーション層からプレゼンター層の機能を呼び出すのはおかしい?
- でも疎結合ではある
- プレゼンターを「出力」という括りで見ているが(インジケータースピナーもプレゼンター)、コンソール上で適用させるかどうかの入力待ちをして真偽を返す関数はアプリケーション層のutils関数として実装している
- これはプレゼンターに配置すべきなのか??出力ではある...。
- 今回は、あくまでこれを下位レベルの問題を解決するものとしてutils関数とした。
- 考えようによってはpresenterであると思う。(書きながらそっちに考えが寄ってきた)
- これはプレゼンターに配置すべきなのか??出力ではある...。
- 下位レベルの問題を解決するutils関数はモジュールとして切り出す必要?ないよね?
- アプリケーション層は、CLIである構造にかなり依存している
- フラグを受け取ったり、引数を読み取ったり。
- CLI"アプリケーション"というくらいだから、依存してて当然??
- もしwebAPI版で行う場合(機能的に難しいけど)、このアプリケーション層をそれように差し替えたらいいのか
- もちろん、それようにPresenterも実装する必要があるが。
- もしwebAPI版で行う場合(機能的に難しいけど)、このアプリケーション層をそれように差し替えたらいいのか
refacCmdActionの実装(アプリケーション)
func (rca *refacCmdAction) run(cCtx *cli.Context, ctx context.Context) error {
// インジケータースピナを回す
rca.indicater.Start()
if cCtx.NArg() != 1 {
return errors.New("only one argument, the filename, is required")
}
// ファイル名(パス)を引数から取得し読み込む
filename := cCtx.Args().Get(0)
originSrc, err := loadfile.LoadFile(filename)
if err != nil {
return err
}
// descフラグから文字列を取得し、ソースコードに追加
desc := cCtx.String("description")
originSrcWithDesc := shared.AddDescToSrc(originSrc, desc)
// リファクタリングする
ch := make(chan string)
var wg sync.WaitGroup
wg.Add(1)
go func() error {
defer wg.Done()
if err := rca.refactoring.Refactor(ctx, originSrcWithDesc, filename, ch); err != nil {
return err
}
return nil
}()
var result string
for s := range ch {
result += s
}
// リファクタリング結果をテキスト/コードに分ける
code, text, err := utils.DevideCodeAndText(result)
if err != nil {
return err
}
// 差分を検出
diff := rca.differ.Diff(string(originSrc), code)
// インジケータースピナーを止める
rca.indicater.Stop()
// テキスト・差分を表示
rca.refacPrinter.Print(text, diff)
// ファイルへの上書き
// ヘッダーコメントを付加して上書きする
rca.refacOverWriter.OverWriteWithHeaderComment(filename, code)
// 上書きを確定するかどうか
if utils.DecideToApply(os.Stdin) {
rca.refacOverWriter.OverWrite(filename, code)
} else {
rca.refacOverWriter.OverWrite(filename, string(originSrc))
}
wg.Wait()
return nil
}
3.リリース
以下のZenn記事を参考にしました。というか全て流れに沿ってやった感じになります。
また、以下の記事はgoreleaserにおける、--rm-dist
が非推奨となったためにエラーが出たので助かりました。
--clean
にすればいいとのことです。
感想は、、、
めっちゃ便利で楽だ...
です。
webアプリをリリースするときとは比べ物にならないこの楽さたるや。
とても簡単に自作ツールを配布できて嬉しいですね。ほぼ自己満です。
また、今回のCLIツールではgeminiAPIを使う前提ですので、お使いいただく際には.env
ファイルにAPIキーの記述が必要です。
geminiはぶっちゃけ全然お金かからへんやんやっす!って所感です。
4.感想
今回は、golangのアウトプット、また、アーキテクチャ設計の学習用としてCLIツールを開発する経緯となりました。
本来ならこんな小規模コマンドラインツールに、凝った設計はいらない気もしますし、下手にモジュールを分離したことで、複雑性が増し、逆に不便だった節もありました。しかしながら、アーキテクチャ周りを考えている時間は楽しかったですし、もっと色々な知見を吸収したいです。実装よりもそっちを考えていることの方が時間かかってました。
ただ個人開発はやはりいいですね。
今回のはちょうど、構想から実装・テスト、リリースまで1週間でした。golangの書き方に慣れましたし、色々なことも知れました。少し身についた感覚がありますし、やり切った感が多少なりとも生まれます。
また、Zennの機能の一つであるスクラップを活用した開発は個人的にこれからも続けていこうかなと考えています。後から見返して振り返りもできますし、githubのイシューよりも手軽に綴れるのでいいですね。
今度は機能面もしっかり考えて作ろうかなと思います。
Discussion