Goでマイクロサービスやってみた
序文
マイクロサービスという設計手法が世に広まって数年、 今では様々なプロジェクトでマイクロサービスが採用されているのを目にします。
興味はあるけどまだよくわかってない、でも実際に手を動かして理解を深めたい、といったモヤモヤを抱えている人もいるのではないでしょうか?
私もその一人でしたが、この度マイクロサービスでサンプルAPIを実装してみたのでその実装方法、実際に手を動かしてみることで得た所感を共有させていただきたいと思います。
簡単なマイクロサービスの実装
今回私はマイクロサービスアーキテクチャを用いたAPIのサンプルをGoで実装しました。
https://github.com/masakazutakewaka/grpc-proto
下図が今回実装したサンプルAPIの概観になります。
APIはユーザーとアイテム、コーディネートという3つのサービスで構成されていて、
それぞれのサービスは別々のコンテナ上に配置されています。
そしてそれらのサービスにhttpでアクセスするためのリバースプロキシを用意するところまでやりました。
ディレクトリ構造はこのようになっていています。
grpc-proto/ ├── Gopkg.lock ├── Gopkg.toml ├── README.md ├── coordinate ├── docker-compose.yaml ├── gateway ├── item ├── user └── vendor
docker-composeはこのようになっています。
version: "3.6" services: item: build: context: "." dockerfile: "./item/app.dockerfile" depends_on: - "item_db" environment: DATABASE_URL: "postgres://takewaka:takewaka@item_db/grpcproto?sslmode=disable" item_db: build: context: "./item" dockerfile: "./db.dockerfile" environment: POSTGRES_DB: "grpcproto" POSTGRES_USER: "takewaka" POSTGRES_PASSWORD: "takewaka" restart: "unless-stopped" user: build: context: "." dockerfile: "./user/app.dockerfile" depends_on: - "user_db" environment: DATABASE_URL: "postgres://takewaka:takewaka@user_db/grpcproto?sslmode=disable" user_db: build: context: "./user" dockerfile: "./db.dockerfile" environment: POSTGRES_DB: "grpcproto" POSTGRES_USER: "takewaka" POSTGRES_PASSWORD: "takewaka" restart: "unless-stopped" coordinate: build: context: "." dockerfile: "./coordinate/app.dockerfile" depends_on: - "coordinate_db" - "item" - "user" environment: DATABASE_URL: "postgres://takewaka:takewaka@coordinate_db/grpcproto?sslmode=disable" ITEM_URL: "item:8080" USER_URL: "user:8080" coordinate_db: build: context: "./coordinate" dockerfile: "./db.dockerfile" environment: POSTGRES_DB: "grpcproto" POSTGRES_USER: "takewaka" POSTGRES_PASSWORD: "takewaka" restart: "unless-stopped" gateway: build: context: "." dockerfile: "./gateway/gateway.dockerfile" ports: - "8000:8080" depends_on: - "item" - "user" - "coordinate" environment: ITEM_URL: "item:8080" USER_URL: "user:8080" COORDINATE_URL: "coordinate:8080"
一つのサービスに対して一つのDBを用意しています。
他のサービスのデータを要求する手段を、サービスのインターフェース経由に限定することで各サービスは疎結合になっています。
それでは早速各サービスの中身を見ていきましょう。
Item
ディレクトリ構成
item/ ├── app.dockerfile ├── client.go ├── create_table.sql ├── db.dockerfile ├── main ├── pb ├── repository.go └── server.go
DBとの連携を担うrepository層、gprcサーバーを定義するserver層、grpcサーバーと通信するクライアント側の機能を担うclient層から成り立ちます。
今回は簡単なサンプル実装ということでドメインロジックは用意していません。(かなり不自然ですが、そういう理由でドメインロジックを格納する層が存在しないです。)
まずはItemサービスのプロトコル定義から見ていきましょう。
プロトコル定義
syntax = "proto3"; package pb; import "google/api/annotations.proto"; service ItemService { rpc getItem (getItemRequest) returns (getItemResponse) { option (google.api.http) = { get: "/item/{id}" }; } rpc getItems (getItemsRequest) returns (getItemsResponse) {} rpc postItem (postItemRequest) returns (postItemResponse) { option (google.api.http) = { post: "/item" body: "*" }; } } message Item { int32 id = 1; string name = 2; int32 price = 3; } . . .
ここではサービスのインターフェースとオブジェクトの型を定義しています。
また、google/api/annotations.proto
を使って、httpで通信するリバースプロキシのエンドポイントの定義もここでしています。
protocol bufferのコンパイラであるprotoc
を使ってこのファイルをコンパイルすると
item.pb.go
・・・Itemオブジェクトの型、gprcのAPIが定義されている。item.pb.gw.go
・・・リバースプロキシのAPIが定義されている。
が生成されます。
各層を実装していく上で、これらのファイルを適宜インポートしていきます。
※ protocコマンドをそのまま使うとオプションが大変なことになるので protoeasy を使いましょう。
client層
client層には、grpcサーバーと通信するクライアント側の機能を実装します。
. . . type Client struct { conn *grpc.ClientConn service pb.ItemServiceClient } func NewClient(url string) (*Client, error) { conn, err := grpc.Dial(url, grpc.WithInsecure()) if err != nil { return nil, err } client := pb.NewItemServiceClient(conn) return &Client{conn, client}, nil } func (client *Client) Close() { client.conn.Close() } func (client *Client) GetItem(ctx context.Context, id int32) (*pb.Item, error) { res, err := client.service.GetItem(ctx, &pb.GetItemRequest{Id: id}) if err != nil { return nil, err } return &pb.Item{ Id: res.Item.Id, Name: res.Item.Name, Price: res.Item.Price, }, nil } . . .
Client型の2つのフィールドにはそれぞれ
- conn : gprcサーバーへのコネクション
- service : クライアント側が持つメソッドを集約したもの
が格納されています。
ItemServiceClientの定義
type ItemServiceClient interface { GetItem(ctx context.Context, in *GetItemRequest, opts ...grpc.CallOption) (*GetItemResponse, error) GetItems(ctx context.Context, in *GetItemsRequest, opts ...grpc.CallOption) (*GetItemsResponse, error) PostItem(ctx context.Context, in *PostItemRequest, opts ...grpc.CallOption) (*PostItemResponse, error) }
Itemサービスと通信したい場合はNewClient
関数を呼び出すことで、gprcサーバーとのコネクションを貼ることができます。
server層
server層にはgprcサーバー周りの機能を実装していきます。
. . . type itemServer struct { r Repository } func ListenGRPC(r Repository, port int) error { listen, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) if err != nil { return err } server := grpc.NewServer() pb.RegisterItemServiceServer(server, &itemServer{r}) reflection.Register(server) return server.Serve(listen) } func (s *itemServer) GetItem(ctx context.Context, r *pb.GetItemRequest) (*pb.GetItemResponse, error) { item, err := s.r.GetItemByID(ctx, r.Id) if err != nil { return nil, err } return &pb.GetItemResponse{Item: item}, nil } . . .
肝となるのは
RegisterItemServiceServer(server, &itemServer{r})
の部分で、中で grpc#Server.RegisterService を使ってサービスに関わる実装をgprcサーバーに登録しています。
ここでいうサービスに関わる実装というのはitemServer
型のフィールドであるRepository
のことです。
Repository
の実装は次のrepository層の説明で詳しく見ていきましょう。
repository層
repository層にはDBと直接やり取りする部分を実装していきます。
. . . type Repository interface { Close() GetItemByID(ctx context.Context, id int32) (*pb.Item, error) GetItemsByIds(ctx context.Context, ids []int32) ([]*pb.Item, error) InsertItem(ctx context.Context, name string, price int32) error } type postgresRepository struct { db *sql.DB } func NewPostgresRepository(url string) (Repository, error) { db, err := sql.Open("postgres", url) if err != nil { return nil, err } err = db.Ping() if err != nil { return nil, err } return &postgresRepository{db}, nil } . . . func (r *postgresRepository) GetItemByID(ctx context.Context, id int32) (*pb.Item, error) { row := r.db.QueryRowContext(ctx, "SELECT id, name, price FROM items WHERE id = $1", id) item := &pb.Item{} if err := row.Scan(&item.Id, &item.Name, &item.Price); err != nil { return nil, err } return item, nil } . . .
Repository
interfaceにはserver層で定義されているインターフェースの中で使われる関数が集約されています。
それぞれの関数にはDBから取得したデータをGoの型にマッピングする機能を持たせています。
main.go
ポート8080でgrpcサーバーを立ち上げます。
. . . func main() { dbURL := os.Getenv("DATABASE_URL") repo, err := item.NewPostgresRepository(dbURL) if err != nil { log.Fatal(err) } log.Println("listen to port 8080 ...") log.Fatal(item.ListenGRPC(repo, 8080)) }
文字通りプログラムのエントリーポイントであり、コンテナ内でこのファイルをコンパイルし実行しています。
FROM golang:1.10.3-alpine3.8 AS builder WORKDIR /go/src/github.com/masakazutakewaka/grpc-proto/item COPY vendor ../vendor COPY item ./ RUN go build -o /go/bin/app main/main.go FROM alpine:3.8 WORKDIR /usr/bin COPY --from=builder /go/bin . EXPOSE 8080 CMD ["app"]
User
ディレクトリ構成
user/ ├── app.dockerfile ├── client.go ├── create_table.sql ├── db.dockerfile ├── main ├── pb ├── repository.go └── server.go
プロトコル定義
. . . service UserService { rpc getUser (getUserRequest) returns (getUserResponse) { option (google.api.http) = { get: "/user/{id}" }; } rpc getUsers (getUsersRequest) returns (getUsersResponse) {} rpc postUser (postUserRequest) returns (postUserResponse) { option (google.api.http) = { post: "/user" body: "*" }; } } message User { int32 id = 1; string name = 2; } message getUserRequest { int32 id = 1; } message getUserResponse { User user = 1; } . . .
client層
Itemサービスのclient層とほとんど同じです。
. . . type Client struct { conn *grpc.ClientConn service pb.UserServiceClient } func NewClient(url string) (*Client, error) { conn, err := grpc.Dial(url, grpc.WithInsecure()) if err != nil { return nil, err } client := pb.NewUserServiceClient(conn) return &Client{conn, client}, nil } func (client *Client) Close() { client.conn.Close() } func (client *Client) GetUser(ctx context.Context, id int32) (*pb.User, error) { res, err := client.service.GetUser(ctx, &pb.GetUserRequest{Id: id}) if err != nil { return nil, err } return &pb.User{ Id: res.User.Id, Name: res.User.Name, }, nil } . . .
server層
Itemサービスのserver層とほとんど同じです。
. . . type userServer struct { r Repository } func ListenGRPC(r Repository, port int) error { listen, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) if err != nil { return err } server := grpc.NewServer() pb.RegisterUserServiceServer(server, &userServer{r}) reflection.Register(server) return server.Serve(listen) } func (s *userServer) GetUser(ctx context.Context, r *pb.GetUserRequest) (*pb.GetUserResponse, error) { user, err := s.r.GetUserByID(ctx, r.Id) if err != nil { return nil, err } return &pb.GetUserResponse{User: user}, nil } . . .
repository層
Itemサービスのrepository層とほとんど同じです。
. . . type Repository interface { Close() GetUserByID(ctx context.Context, id int32) (*pb.User, error) ListUsers(ctx context.Context, skip int32, take int32) ([]*pb.User, error) InsertUser(ctx context.Context, name string) error } type postgresRepository struct { db *sql.DB } func NewPostgresRepository(url string) (Repository, error) { db, err := sql.Open("postgres", url) if err != nil { return nil, err } err = db.Ping() if err != nil { return nil, err } return &postgresRepository{db}, nil } func (r *postgresRepository) Close() { r.db.Close() } func (r *postgresRepository) Ping() error { return r.db.Ping() } func (r *postgresRepository) GetUserByID(ctx context.Context, id int32) (*pb.User, error) { row := r.db.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id = $1", id) user := &pb.User{} if err := row.Scan(&user.Id, &user.Name); err != nil { return nil, err } return user, nil } . . .
main.go
Itemサービスのmain.go
とほとんど同じです。
. . . func main() { dbURL := os.Getenv("DATABASE_URL") repo, err := user.NewPostgresRepository(dbURL) if err != nil { log.Fatal(err) } log.Println("listen to port 8080 ...") log.Fatal(user.ListenGRPC(repo, 8080)) } . . .
Coordinate
ディレクトリ構成
coordinate/ ├── app.dockerfile ├── client.go ├── create_table.sql ├── db.dockerfile ├── main ├── pb ├── repository.go └── server.go
プロトコル定義
coordinate/pb/coordinate.proto
. . . service CoordinateService { rpc getCoordinatesByUser (getCoordinatesByUserRequest) returns (getCoordinatesByUserResponse) { option (google.api.http) = { get: "/user/{userId}/coordinates" }; } rpc postCoordinate (postCoordinateRequest) returns (postCoordinateResponse) { option (google.api.http) = { post: "/coordinate" body: "*" }; } } message Coordinate { int32 id = 1; int32 userId = 2; repeated int32 itemIds = 3; } message getCoordinatesByUserRequest { int32 userId = 1; } message getCoordinatesByUserResponse { repeated Coordinate coordinates = 1; } . . .
Coordinate型にはフィールドにユーザーのIDとアイテムのIDを持たせています。
client層
. . . type Client struct { conn *grpc.ClientConn service pb.CoordinateServiceClient } func NewClient(url string) (*Client, error) { conn, err := grpc.Dial(url, grpc.WithInsecure()) if err != nil { return nil, err } client := pb.NewCoordinateServiceClient(conn) return &Client{conn, client}, nil } func (client *Client) Close() { client.conn.Close() } func (client *Client) GetCoordinatesByUser(ctx context.Context, userId int32) ([]*pb.Coordinate, error) { res, err := client.service.GetCoordinatesByUser(ctx, &pb.GetCoordinatesByUserRequest{UserId: userId}) if err != nil { return nil, err } return res.Coordinates, nil }
GetCoordinatesByUser
はユーザーのIDからコーディネートを取ってきます。
server層
. . . type coordinateServer struct { r Repository itemClient *item.Client userClient *user.Client } func ListenGRPC(r Repository, itemURL string, userURL string, port int) error { itemClient, err := item.NewClient(itemURL) if err != nil { return err } userClient, err := user.NewClient(userURL) if err != nil { return err } listen, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) if err != nil { itemClient.Close() userClient.Close() return err } server := grpc.NewServer() pb.RegisterCoordinateServiceServer(server, &coordinateServer{r, itemClient, userClient}) reflection.Register(server) return server.Serve(listen) } func (s *coordinateServer) GetCoordinatesByUser(ctx context.Context, r *pb.GetCoordinatesByUserRequest) (*pb.GetCoordinatesByUserResponse, error) { _, err := s.userClient.GetUser(ctx, r.UserId) if err != nil { return nil, err } coordinates, err := s.r.GetCoordinatesByUserId(ctx, r.UserId) if err != nil { return nil, err } return &pb.GetCoordinatesByUserResponse{Coordinates: coordinates}, nil } . . .
CoordinateサービスではItemサービス、Userサービスとの連携があるので、coordinateServer
のフィールドにitem.Client
、user.Client
を持たせて、Itemサービス、Userサービスのgrpcサーバーと通信できるようにしています。
GetCoordinatesByUser
ではコーディネートのデータを取得する前に、特定のIDのユーザーが存在するか確かめています。
repository層
. . . type Repository interface { Close() GetCoordinatesByUserId(ctx context.Context, userId int32) ([]*pb.Coordinate, error) InsertCoordinate(ctx context.Context, userId int32, itemIds []int32) error } type postgresRepository struct { db *sql.DB } func NewPostgresRepository(url string) (Repository, error) { db, err := sql.Open("postgres", url) if err != nil { return nil, err } err = db.Ping() if err != nil { return nil, err } return &postgresRepository{db}, nil } func (r *postgresRepository) Close() { r.db.Close() } func (r *postgresRepository) Ping() error { return r.db.Ping() } func (r *postgresRepository) GetCoordinatesByUserId(ctx context.Context, userId int32) ([]*pb.Coordinate, error) { rows, err := r.db.QueryContext(ctx, "SELECT id, item_ids FROM coordinates WHERE user_id = $1", userId) coordinates := []*pb.Coordinate{} if err != nil { return nil, err } var itemIds string for rows.Next() { coordinate := &pb.Coordinate{} if err := rows.Scan(&coordinate.Id, &itemIds); err != nil { return nil, err } coordinate.UserId = userId coordinate.ItemIds, err = SliceItemIds(itemIds) if err != nil { return nil, err } coordinates = append(coordinates, coordinate) } if err := rows.Err(); err != nil { return nil, err } return coordinates, nil } . . .
main.go
. . . func main() { dbURL := os.Getenv("DATABASE_URL") itemURL := os.Getenv("ITEM_URL") userURL := os.Getenv("USER_URL") repo, err := coordinate.NewPostgresRepository(dbURL) if err != nil { log.Fatal(err) } log.Println("listen to port 8080 ...") log.Fatal(coordinate.ListenGRPC(repo, itemURL, userURL, 8080)) } . . .
リバースプロキシ
ディレクトリ構成
gateway/ ├── gateway.dockerfile └── main
main.go
import ( . . . "github.com/golang/glog" "github.com/grpc-ecosystem/grpc-gateway/runtime" "google.golang.org/grpc" coordinatePb "github.com/masakazutakewaka/grpc-proto/coordinate/pb" itemPb "github.com/masakazutakewaka/grpc-proto/item/pb" userPb "github.com/masakazutakewaka/grpc-proto/user/pb" ) func run(itemURL string, userURL string, coordinateURL string) error { ctx := context.Background() ctx, cancel := context.WithCancel(ctx) defer cancel() mux := runtime.NewServeMux() opts := []grpc.DialOption{grpc.WithInsecure()} err := itemPb.RegisterItemServiceHandlerFromEndpoint(ctx, mux, itemURL, opts) if err != nil { return err } err = userPb.RegisterUserServiceHandlerFromEndpoint(ctx, mux, userURL, opts) if err != nil { return err } err = coordinatePb.RegisterCoordinateServiceHandlerFromEndpoint(ctx, mux, coordinateURL, opts) if err != nil { return err } return http.ListenAndServe(":8080", mux) } func main() { itemURL := os.Getenv("ITEM_URL") userURL := os.Getenv("USER_URL") coordinateURL := os.Getenv("COORDINATE_URL") defer glog.Flush() if err := run(itemURL, userURL, coordinateURL); err != nil { glog.Fatal(err) } }
作ってみて感じたこと
時間がかかる
今回作ったサンプルAPIはRailsでモノリシックに作ればほんの一瞬できるものですが、マイクロサービスにしたことで結構時間がかかった感じがします。
よく言われることですが、マイクロサービスにすることで発生する初期のオーバーヘッドはかなり大きいと感じました。
中規模以上のアプリケーションをマイクロサービスで作ってこの初期のオーバーヘッドを回収していくんだなぁと実感できました。
各機能が疎結合になる
疎結合になることで、シンプルに考えられたり、実装できたりするのは純粋に利点だなぁと実感しました。
ただ疎結合に関する難しさは、既存のサービスの機能を切り分けることそのものにあるのだろうとも思いました。
デバッグが増える
サービス間の通信の部分でのエラーなど、モノリスだと考えなくてよかったものが出てきて、運用する際に悩みのタネになったりしそうだなぁと思いました。
まとめ
今回、マイクロサービスで簡単なサンプルAPIを実装してみたので
- 実装方法
- 実装してみた所感
を書きました。
やはりアプリケーションにあった設計方法というものがあって、その判断を適切にするということが重要なんだと実感することができました。
"銀の弾丸"などないんだなーと。
この記事がどなたかの参考に少しでもなれば幸いです。