ここまで、サーバーサイド開発の基本的な内容を説明しました。負荷分散やさまざまなストレージミドルウェアなど、サーバー側の基本ソフトウェアの導入に比較的長い時間を費やしてきました。前回の講義では、サーバーのビジネス アーキテクチャに関する一般的な問題をいくつか紹介しました。
今日から実戦に入ります。
サーバーとデスクトップの内容を比較すると、サーバー側の開発とデスクトップ側の開発にはそれぞれ独自の複雑さがあることがわかります。サーバーサイド開発は基本的なソフトウェアが多いため難しく、プログラマーやアーキテクトに高い知識と深い理解が求められます。ただし、ビジネスの複雑さという観点から見ると、サーバー側のビジネス ロジックは比較的単純です。それどころか、デスクトップ開発は、ユーザー対話ロジックが複雑で、コード量が多く、ビジネス アーキテクチャが非常に複雑であるため、困難です。
前章の実践部分が少し難しかったという意見が多くありましたが、これはコース内容の設計にある程度関係しています。前章では、アーキテクチャの観点から概要設計、つまりシステムアーキテクチャを中心に紹介しました。したがって、実装の詳細はあまり分析せず、モジュール間のインターフェイス結合に焦点を当てました。これは、すぐに局所的な詳細に立ち入るのではなく、全体的な状況に焦点を当ててほしいという願いからです。ただし、プロセス全体の分析が不足しているため、プロセス全体をつなぎ合わせることができず、理解が損なわれてしまいます。
この章では、アーキテクチャの観点から詳細な設計に焦点を当てます。これは実戦編にも反映されます。
前の章では、サーバーのモック バージョンを実装しました。コードは次のとおりです。
https://github.com/qiniu/qpaint/tree/v31/paintdom
次に、これを段階的に運用レベルのサーバー プログラムに変えていきます。
RPC フレームワーク
最初のステップでは、RPC フレームワークを紹介します。
理解を容易にするために、前章の実戦では、模擬サーバー プログラムには非標準のライブラリ コンテンツは導入されませんでした。コードは以下のように表示されます:
https://github.com/qiniu/qpaint/blob/v31/paintdom/service.go
サービス全体のコードは約 280 行です。
Qiniu Cloud のオープンソース restrpc フレームワークに基づいて実装しました。コードは次のとおりです:
https://github.com/qiniu/qpaint/blob/v41/paintdom/service.go
このようにして、サービス全体のコードは約 163 行のみになり、元のコードの 60% 未満になります。
どのコードの記述が少なくなったでしょうか?新しいグラフィックの作成を見てみましょう。もともと私たちはこう書きました:
func (p *Service) PostShapes(w http.ResponseWriter, req *http.Request, args []string) {
id := args[0]
drawing, err := p.doc.Get(id)
if err != nil {
ReplyError(w, err)
return
}
var aShape serviceShape
err = json.NewDecoder(req.Body).Decode(&aShape)
if err != nil {
ReplyError(w, err)
return
}
err = drawing.Add(aShape.Get())
if err != nil {
ReplyError(w, err)
return
}
ReplyCode(w, 200)
}
ここで次のように書きます。
func (p *Service) PostShapes(aShape *serviceShape, env *restrpc.Env) (err error) {
id := env.Args[0]
drawing, err := p.doc.Get(id)
if err != nil {
return
}
return drawing.Add(aShape.Get())
}
この例の戻りパッケージは比較的単純で、HTTP パッケージの本体はありません。
グラフィックの内容を取り上げて、より複雑な返品パッケージの別の例を見てみましょう。もともと私たちはこう書きました:
func (p *Service) GetShape(w http.ResponseWriter, req *http.Request, args []string) {
id := args[0]
drawing, err := p.doc.Get(id)
if err != nil {
ReplyError(w, err)
return
}
shapeID := args[1]
shape, err := drawing.Get(shapeID)
if err != nil {
ReplyError(w, err)
return
}
Reply(w, 200, shape)
}
ここで次のように書きます。
func (p *Service) GetShape(env *restrpc.Env) (shape Shape, err error) {
id := env.Args[0]
drawing, err := p.doc.Get(id)
if err != nil {
return
}
shapeID := env.Args[1]
return drawing.Get(shapeID)
}
これら 2 つの例を比較すると、次のことがわかります。
もともと、これら 2 つのリクエスト POST /drawings/
元々、PostShapes は独自に Shape インスタンスを定義し、HTTP リクエスト パッケージ req.Body の内容を解析する必要がありました。これで、パラメータに Shape タイプを指定するだけで済み、restrpc フレームワークが自動的にパラメータの解析を完了します。
当初、GetShape はエラー自体に応答するか、通常の HTTP プロトコル パケットを返す必要がありました。これで、応答するデータを戻り値リストに返すだけで、restrpc フレームワークが戻り値のシリアル化を自動的に完了し、HTTP リクエストに応答します。
2 つのバージョンのコードの違いを比較することで、restrpc の HTTP 処理関数の背後で何が行われているかを大まかに推測できます。そのコアコードは次のとおりです。
https://github.com/qiniu/http/blob/v2.0.2/rpcutil/rpc_util.go#L96
注目に値するのは、Env のサポートです。RPC フレームワークは、Env クラスの外観を制限しませんが、次のインターフェイスを満たす必要があることのみを規定しています。
type itfEnv interface {
OpenEnv(rcvr interface{}, w *http.ResponseWriter, req *http.Request) error
CloseEnv()
}
OpenEnv メソッドでは、通常、Env を初期化します。 CloseEnv メソッドはその逆を行います。 ResponseWriter インターフェイスが OpenEnv メソッドのポインターとして渡されるのはなぜですか? ResponseWriterの実装を書き直したいという顧客もいるかもしれないからです。
たとえば、API 監査ログ機能を RPC フレームワークに拡張するとします。次に、ユーザーから返されたHTTPパケットを引き継いで記録する必要がありますが、この際、引き継ぎと記録の目的を達成するためにResponseWriterを書き換える必要があります。
また、HTTP リクエスト処理関数の restrpc バージョンが HTTP 処理関数のようにはならず、通常の関数のように見えることにも注目してください。
これは、Service クラスを 2 つの方法でテストできることを意味します。通常のメソッドを使用して HTTP サービスをテストすることに加えて、サービス クラスを通常のクラスとしてテストすることもできるため、単体テストのコストが大幅に削減されます。サービスのクライアント SDK をラップして、クライアント SDK に基づいて単体テストを行う必要がなくなったためです。
もちろん、このような低コストのテスト方法はありますが、HTTP プロトコルを使用していないため、このテスト方法ではコーディング上の小さな事故をカバーできないのではないかという懸念が残ります。
restrpcのHTTP処理機能を理解したら、残りはrestrpcのルーティング機能です。これは、restrpc.Router クラスの Register 関数によって行われます。コードは以下のように表示されます:
https://github.com/qiniu/http/blob/v2.0.1/restrpc/restroute.go#L39
2 つのルーティング方法がサポートされており、1 つはメソッド名に基づく自動ルーティングです。たとえば、POST /drawings/
ルールは比較的単純で、パス内の「/」は大文字で区切られ、DrawingIDやShapeIDなどのURLパラメータは「_」に置き換えられます。
もちろん、このメソッドの名前を見て「ダサい」と思う人もいるでしょう。次に、手動ルーティングを選択し、routeTable に渡すことができます。次のようになります。
var routeTable = [][2]string{
{"POST /drawings", "PostDrawings"},
{"GET /drawings/*", "GetDrawing"},
{"DELETE /drawings/*", "DeleteDrawing"},
{"POST /drawings/*/sync", "PostDrawingSync"},
{"POST /drawings/*/shapes", "PostShapes"},
{"GET /drawings/*/shapes/*", "GetShape"},
{"POST /drawings/*/shapes/*", "PostShape"},
{"DELETE /drawings/*/shapes/*", "DeleteShape"},
}
手動ルーティングですが、メソッド名には Get、Put、Post、または Delete で始まる必要があるという制限があります。
ビジネスロジックの階層化
Restrpc フレームワークを理解した後、QPaint サーバーのビジネス自体を見てみましょう。サーバー側のビジネス ロジックは 2 つの層に分かれていることがわかります。1 つはビジネス ロジックの実装層で、通常は意識的に DOM ツリーに編成されます。コードは以下のように表示されます:
https://github.com/qiniu/qpaint/blob/v41/paintdom/drawing.go
https://github.com/qiniu/qpaint/blob/v41/paintdom/shape.go
もう 1 つの層は RESTful API 層で、ユーザーのネットワーク リクエストを受信し、それを基礎となる DOM ツリーのメソッド呼び出しに変換する役割を果たします。上で紹介した restrpc フレームワークでは、この層の各メソッドは比較的単純であることが多く、単純な関数呼び出しにすぎないものもあります。例えば:
func (p *Service) DeleteDrawing(env *restrpc.Env) (err error) {
id := env.Args[0]
return p.doc.Delete(id)
}
完全な RESTful API レイヤー コードは次のとおりです。
https://github.com/qiniu/qpaint/blob/v41/paintdom/service.go
この階層化の理由は、コア ビジネス ロジックを実装するときに、RESTful API を通じて公開する必要があることを想定していないためです。以下の可能性を検討します。
まず、ネットワーク呼び出しがまったく必要ない可能性があります。
たとえて言えば、mysql は TCP プロトコルを通じてサービス インターフェイスを提供するのに対し、sqlite は組み込みデータベースであり、ローカル関数呼び出しを通じてサービス インターフェイスを提供することは誰もが知っています。ここでの階層化は、mysql を実装したときと同様で、最初に sqlite に似た組み込みデータベースを最下層に実装し、次に TCP プロトコルに基づいたネットワーク インターフェイスを提供しました。
第 2 に、多くのネットワーク プロトコルをサポートする必要がある可能性があります。
RESTful API は現在人気があるため、私たちのインターフェースは RESTful スタイルです。いつか Github のような GraphQL に切り替えたい場合、少なくとも基礎となるビジネス ロジック実装レイヤーを変更する必要はなく、比較的薄い GraphQL レイヤーを実装するだけで済みます。
さらに、この場合、多くの場合、RESTful API と GraphQL を同時にサポートする必要があります。結局のところ、トレンドに従うだけで古いユーザーを放棄することはできません。
複数のネットワーク インターフェイスのセットを同時にサポートする必要がある場合、この階層化の値が反映されます。異なるネットワーク インターフェイスのモジュールは、同じ DOM ツリーのインスタンスを共有します。システム全体が複数のプロトコルの共存を実現するだけでなく、完全に分離され、相互に完全に独立していることも実現します。
単体テスト
ビジネスについて話した後、単体テストについて見てみましょう。
以前は、単体テストは基本的にはあまり役に立ちませんでした。
https://github.com/qiniu/qpaint/blob/v31/paintdom/service_test.go#L62
コードは以下のように表示されます:
type idRet struct {
ID string `json:"id"`
}
func TestNewDrawing(t *testing.T) {
...
var ret idRet
err := Post(&ret, ts.URL + "/drawings", "")
if err != nil {
t.Fatal("Post /drawings failed:", err)
}
if ret.ID != "10001" {
t.Log("new drawing id:", ret.ID)
}
}
ここのテスト コードからわかるように、図面を作成し、返される描画 ID が「10001」であることをリクエストしました。
単体テストの観点からすると、当然のことながら、このようなテスト強度は非常に不十分です。同じテスト ケースは、前の講義で紹介した httptest テスト フレームワークを使用して実装されます。
func TestNewDrawing(t *testing.T) {
...
ctx := httptest.New(t)
ctx.Exec(
`
post http://qpaint.com/drawings
ret 200
json '{"id": "10001"}'
`)
}
もちろん、実際には、次のようなさらに多くの状況をテストする必要があります。
func TestService(t *testing.T) {
...
ctx := httptest.New(t)
ctx.Exec(
`
post http://qpaint.com/drawings
ret 200
json '{
"id": $(id1)
}'
match $(line1) '{
"id": "1",
"line": {
"pt1": {"x": 2.0, "y": 3.0},
"pt2": {"x": 15.0, "y": 30.0},
"style": {
"lineWidth": 3,
"lineColor": "red"
}
}
}'
post http://qpaint.com/drawings/$(id1)/shapes
json $(line1)
ret 200
get http://qpaint.com/drawings/$(id1)/shapes/1
ret 200
json $(line1)
`)
if !ctx.GetVar("id1").Equal("10001") {
t.Fatal(`$(id1) != "10001"`)
}
}
この場合、何を実証したいのでしょうか?これは比較的複雑なケースです。まず図面を作成し、図面 ID を変数 $(id1) に代入します。次に、線 $(line1) を図面に追加します。追加が成功したことを確認するために、グラフィック オブジェクトを取り出し、取得したグラフィックが追加された $(line1) と一致するかどうかを確認します。
さらに、最も古い DSL スクリプトと Go 言語コードの相互運用性も示します。 Go コードを使用して変数 $(id1) を取得し、それが「10001」に等しいかどうかを判断します。
qinutest の詳細については、次の情報を確認してください。
https://github.com/qiniu/httptest
https://github.com/qiniu/qinutest
スピーチの記録: http://open.qiniudn.com/qinutest.pdf
私たちのテスト コードでは、Qiniu Cloud のオープン ソースの模擬 http コンポーネントも使用しました。これも非常に興味深いものです。
https://github.com/qiniu/x/blob/v8.0.1/mockhttp/mockhttp.go
この模擬 http は実際にはポートを監視しません。興味のある学生はそれを勉強してください。
結論
今日の内容をまとめてみましょう。今日から、以前に作成した模擬サーバーを実際のサーバー プログラムに段階的に変換していきます。
変革への最初のステップは、RPC フレームワークと単体テストです。このようにして、次のように、初めてサードパーティのコード ライブラリに依存するようになりました。
http://github.com/qiniu/http (restrpc を使用)
http://github.com/qiniu/qinutest
http://github.com/qiniu/x (mockhttp を使用)
外部依存関係が存在する場合は、依存ライブラリのバージョン管理を考慮する必要があります。幸いなことに、最新の言語のほとんどは適切なバージョン管理仕様を備えており、Go 言語の場合はバージョン管理に go mod を使用します。