技術メモ

技術メモ

ラフなメモ

Goにおけるフラットアプリケーション構造

こちらは Flat Application Structure in Go の日本語訳です。


Flat Application Structure in Go

コードをパッケージに分割する方法を見つけ出すのに時間を費やすよりも、フラットな構造を持つアプリケーションでは、すべての .go ファイルを単一のパッケージに配置します。

myapp/
  main.go
  server.go
  user.go
  lesson.go
  course.go

フラットなアプリケーション構造は、ほとんどの人が Go を始めるときに最初に使うものです。A Tour of Go のすべてのプログラム、Gophercises のほとんどの練習問題、その他多くの初期の Go プログラムは、パッケージに分割されていません。その代わりに、いくつかの .go ファイルを作成して、すべてのコードを同じ(多くの場合はメインの)パッケージに入れています。

一見はこれはよくなさそうです。コードはすぐに扱いにくくなるのではないでしょうか?ビジネスロジックと UI レンダリングコードをどのように分離すればいいのでしょうか?どのようにして適切なソースファイルを見つけるのでしょうか?結局のところ、パッケージを使用する理由の大部分は、懸念事項を分離しつつ、正しいソースファイルを素早く探せることにあります。

効果的なフラットな構造の使用

フラット構造を使用する場合にも、コーディングのベストプラクティスに従うようにしてください。アプリケーションの異なる部分は、異なる .go ファイルを使用して分離したいと思うでしょう。

myapp/
  main.go # 設定の読み込み、アプリケーションを起動
  server.go # 全体的な HTTP ハンドラの処理
  user_handler.go # ユーザの HTTP ハンドラのロジック
  user_store.go # ユーザの DB のロジック
  # その他...

グローバルな変数は問題になる可能性があるので、コードに含まれないように型にメソッドをつけることを考えるべきです。

type Server struct {
  apiClient *someapi.Client
  router *some.Router
}

func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
  s.router.ServeHTTP(w, r)
}

そして、あなたの main() 関数は、アプリケーションの設定以外のほとんどのロジックを削除すべきです。

// Warning: これは単なる例でコンパイルすらできないかもしれません。

type Config struct {
  SomeAPIKey     string
  Port           string
  EnableTheThing bool
}

func main() {
  var config Config
  config.SomeAPIKey = os.Getenv("SOMEAPI_KEY")
  config.Port = os.Getenv("MYAPP_PORT")
  if config.Port == "" {
    config.Port = "3000"
  }
  config.EnableTheThing = true

  if err := run(config); err != nil {
    log.Fatal(err)
  }
}

func run(config Config) error {
  server := myapp.Server{
    APIClient: someapi.NewClient(config.SomeAPIKey),
  }
  return http.ListenAndServe(":" + config.Port, server)
}

実際には、基本的にはフラットな構造で、すべてのコードを一つのパッケージにまとめ、コマンドを定義する別のメインパッケージを使うことができます。これにより、一般的な cmd サブディレクトリのパターンを用いることができます。

myapp/
  cmd/
    web/
      # package main
      main.go
    cli/
      # package main
      main.go
  # package myapp
  server.go
  user_handler.go
  user_store.go
  ...

この例では、あなたのアプリケーションは基本的にはまだフラットですが、同じコアアプリケーションを使って2つのコマンドをサポートする必要があるなど、必要性があったためにメインパッケージを分けました。

フラット構造を推奨する理由

フラットな構造の主な利点は、すべてのコードを単一のディレクトリに保管しているとか、そのような愚かなことではありません。この構造の本質的な利点は、物事をどのように整理するかを心配するのをやめて、代わりにアプリケーションで解決しようとしている問題を解決することに専念できるということです。

このアプリケーションの構造は PHP を書いていたときのことを思い出します。私が最初にコーディングを学んだとき、私は、様々な種類の HTML と混ざり合ったロジックを持つランダムな PHP ファイルから始めましたが、それは混乱していました。大規模なアプリケーションを構築すべきだと提案しているわけではありません - それは最悪です - しかし、私はすべてがどのように整理するべきかあまり心配していませんでした。コードの書き方や自分の特定の問題を解決する方法を学ぶことに関心がありました。フラットな構造を使うことで、アプリケーションのニーズやドメイン、一般的なコードの書き方など、学習や構築に集中しやすくなります。

これは、「このロジックはどこに行くべきか」というようなことを気にする必要がなくなるからです。関数であれば、パッケージ内の新しいソースファイルに移動させることができます。間違った型のメソッドであれば、2 つの新しい型を作成して元の型からロジックを分割することができます。また、パッケージが 1 つしかないため、循環的な依存関係の問題が発生することを心配する必要はありません。

フラットな構造を検討するもう一つの大きな理由は、アプリケーションの複雑さに応じて構造を進化させることが容易になるということです。コードを別のパッケージに分割することで利益を得られることが明らかになった場合、多くの場合、いくつかのソースファイルをサブディレクトリに移動して、そのパッケージを変更し、新しいパッケージの接頭辞を使用するように参照を更新するだけです。例えば、SqlUser を持っていて、データベース関連のロジックを処理するために別の sql パッケージを持っていた方が得だと判断した場合、型を新しいパッケージに移動した後、sql.User を使用するように参照を更新します。 MVC のような構造は、他のプログラミング言語のように不可能または困難ではないものの、リファクタリングが少し難しいことがわかりました。

フラットな構造体は、パッケージを作るのに時間がかかることが多い初心者にとっては特に有用です。なぜこのような現象が起こるのかはよくわかりませんが、Go を始めたばかりの人は大量のパッケージを作るのが大好きで、ほとんどの場合、スタッタリング (user.User) や周期的な依存関係、あるいは他の問題につながります。

次回の MVC の記事では、あまりにも多くのパッケージを作成すると、Go で MVC が不可能に見えてしまうかを探っていきます。

アプリケーションが少し成長して理解が深まるまで新しいパッケージを作成する決断を先延ばしにすることで、新進気鋭の Gophers がこのような間違いを犯す可能性ははるかに低くなります。

これが、多くの人が開発者に自分のコードを早々にマイクロサービスに分割することを避けるように勧める理由でもあります。何をマイクロサービスに分割すべきか、分割すべきではないかを、早い段階で実際に知るには十分な知識がないことが多く、先取り的なマイクロサービス化(これが格言になることをちょっと期待しています)は、将来の仕事を増やすことにつながるだけです。

フラット構造は良いことばかりではありません

フラットな構造を使うことのデメリットがないように装うのは不誠実であり、デメリットも検討する必要があります。

まず第一に、フラット構造では、ここまでしかできません。しばらくの間は機能しますが、ある時点でアプリが複雑になり、分割する必要が出てきます。フラット構造を使用する利点は、これを先延ばしにすることができ、分割したときにコードをよりよく理解できるようになることです。デメリットは、どこかの時点でリファクタリングに時間を費やす必要があることで、(もしかしたらですが、ちょっと無理があるかもしれませんが)いずれにせよ、最初からやりたかった構造にリファクタリングしていることに気づくかもしれません。

フラットな構造では、名前付けが衝突することも厄介な場合があります。例えば、アプリケーションに Course 型が欲しいとしますが、データベースのエンティティとして Course の構造体は、JSON で Course をレンダリングする方法ための構造体とは同じではありません。これに対する簡単な解決策は 2 つの型を作成することですが、両方とも同じパッケージに含まれているので、それぞれに異なる名前が必要となり、次のように SqlCourse と JsonCourse のようなものになってしまうかもしれません。これはそれほど大きな問題ではありませんが、単に Course という名前の型がなってしまったのはちょっと残念です。

また、コードを新しいパッケージにリファクタリングするのは常にとても簡単ではありません。通常はとても簡単ですが、すべてのコードが一つのパッケージに含まれているため、性質上、コードの依存が周期する可能性があります。例えば、コースの ID が常に crs_ で始まる JSON レスポンスを持っていて、様々な通貨で価格を返したい場合を想像してみてください。これを処理するために JsonCourse を作成するかもしれません。

type JsonCourse struct {
  ID       string `json:"id"`
  Price struct {
    USD string `json:"usd"`
  } `json:"price"`
}

一方、SqlCourse は整数の ID と、様々な通貨でフォーマットできるセント単位の価格を格納する必要があります。

type SqlCourse struct {
  ID    int
  Price int
}

SqlCourse から JsonCourse に変換する方法が必要なので、これを SqlCourse 型のメソッドにするかもしれません。

func (sc SqlCourse) ToJson() (JsonCourse, error) {
  jsonCourse := JsonCourse{
    ID: fmt.Sprintf("crs_%v", sc.ID),
  }
  jsonCourse.Price.USD = Price: fmt.Sprintf("%d.%2d", sc.Price/100, sc.Price%100)
  return jsonCourse, nil
}

そして後で、受信した JSON をパースして SQL に変換する方法が必要になるかもしれないので、それを別のメソッドとして JsonCourse 型に追加します。

func (jc JsonCourse) ToSql() (SqlCourse, error) {
  var sqlCourse SqlCourse
  // JSON ID is "crs_123" and we convert to "123"
  // for SQL IDs
  id, err := strconv.Atoi(strings.TrimPrefix(jc.ID, "crs_"))
  if err != nil {
    // Note: %w is a Go 1.13 thing that I haven't really
    // tested out, so let me know if I'm using it wrong 😂
    return SqlCourse{}, fmt.Errorf("converting json course to sql: %w", err)
  }
  sqlCourse.ID = id
  // JSON price is 1.00 and we may convert to 100 cents
  sqlCourse.Price = ...
  return sqlCourse, nil
}

ここで行ったすべてのステップは理にかなっていて、論理的だと感じましたが、今は 2 つのタイプが同じパッケージに含まれていなければならず、そうでなければ循環的な依存関係を示すことになります。

このような問題は、MVCドメイン駆動設計、および他のアプリ構造を前もって使用している場合には、発生する可能性ははるかに低いと思いますが、正直に言うと、これを修正するのはそれほど難しいことではありません。本当に必要なのは、変換ロジックを抽出して、両方のタイプを使用する場所に配置することだけです。

func JsonCourseToSql(jsonCourse json.Course) (sql.Course, error) {
  // move the `ToSql()` functionality here
}

func SqlCourseToJson(sqlCourse sql.Course) (json.Course, error) {
  // Move the `ToJson()` functionality here
}

甘い口ひげを生やして、コーヒーショップの仲間に自分がいかにすごいかをアピールしたいのであれば、フラットな構造はボーナスポイントを獲得できないかもしれません。一方で、コードを動作させたいだけなら、これが適しているかもしれません。🤷

私にはフラットな構造が合っているのでしょうか?

まず、一般的なアドバイスをさせてください。 途中でコードのリファクタリングが必要になるのを避けるために、最後までスキップしようとしないでください。 それは決してうまくいかないし、おそらくその方がより多くの作業をすることになるでしょう。ソフトウェアの将来の要件を予測することはほぼ不可能であり、これは私たちが開発者としてそれを行おうとしているもう一つの方法にすぎません。

これでは時間の節約にならないだけでなく、自分自身が損をすることにもなりかねません。大企業の組織では、より複雑なコード構造を使用する必要があるため、より複雑なコード構造を使用します。それが、様々な構成でテストする必要があるからであろうと、強固なユニットテストが必要であるからであろうと、他の何であろうと、彼らが複雑な構造を使用している理由は常に存在します。もしあなたがコードを学ぶソロの開発者であっても、小規模なチームで迅速に作業を進めようとしている場合でも、あなたのニーズは同じではありません。彼らがなぜその構造を選んだのかを理解せずに大規模な組織のようなふりをしようとすると、実際にあなたを助けるというよりも、あなたのペースを落とす可能性が高くなります。

ここでの注意点は、あなたが何をしているのかを知っている場合、これは必ずしも真実ではないということです。

このすべての意味は、あなたがあなたの状況に合わせて最適な構造を選択する必要があるということです。アプリケーションがどれくらい複雑になるかわからない場合や、学習中の場合は、フラットな構造が素晴らしい出発点になるでしょう。アプリが何を必要としているかをよりよく理解したら、リファクタリングやパッケージの抽出を行うことができます。これは多くの開発者が無視するのが好きな点です - アプリケーションを構築しないと、どのように分割すべきかを理解するのが難しいことがよくあります。この問題は、人々がマイクロサービスに飛びつくのが早すぎる場合にも発生する可能性があります。

一方で、アプリケーションが大規模なものになることがすでにわかっている場合 - おそらく、あるスタックから別のスタックに大規模なアプリケーションを移植している場合 - は、すでに多くのコンテキストから作業を行うことができるので、これは悪い出発点かもしれません。

その他の考慮事項

あなたがフラットな構造を試してみることを選ぶ場合は、心に留めておくべきいくつかのことがあります。

  • グローバルな変数は一般的によくありませんし、設定は main() で行うべきです (そのパターンを使用している場合は run() で行うべきです)。
  • フラットな構造から始めても、一つのパッケージに固定されることはありません。それが有益であることが明らかになったら、すぐにコードを別のパッケージに分割してください。
  • コードを別々のソースファイルに分割したり、カスタム型を使用したりすることで、さらに恩恵を受けることができます。

免責事項 - 学習の際に init() やグローバル変数を試してみるのは大歓迎です。実際、ジュニア開発者としては、コードを動作させて理解することは、完璧な構造よりも重要だと思います。初期の動作するバージョンを書くのは、通常、Go のベストプラクティスを使ってリファクタリングするよりもはるかに難しく、「悪い」方法で書いた後に、ベテランの開発者がなぜそのような推奨をするのかを理解できるかもしれません。似たような例として、React で Redux を使用することがあります。