masakazu-takewakaのブログ

たまに書きます。

Goでマイクロサービスやってみた

序文

マイクロサービスという設計手法が世に広まって数年、 今では様々なプロジェクトでマイクロサービスが採用されているのを目にします。

興味はあるけどまだよくわかってない、でも実際に手を動かして理解を深めたい、といったモヤモヤを抱えている人もいるのではないでしょうか?

私もその一人でしたが、この度マイクロサービスでサンプルAPIを実装してみたのでその実装方法、実際に手を動かしてみることで得た所感を共有させていただきたいと思います。

簡単なマイクロサービスの実装

今回私はマイクロサービスアーキテクチャを用いたAPIのサンプルをGoで実装しました。

https://github.com/masakazutakewaka/grpc-proto

下図が今回実装したサンプルAPIの概観になります。

f:id:masakazu-takewaka:20181008234957p:plain

APIはユーザーとアイテム、コーディネートという3つのサービスで構成されていて、
それぞれのサービスは別々のコンテナ上に配置されています。
そしてそれらのサービスにhttpでアクセスするためのリバースプロキシを用意するところまでやりました。

ディレクトリ構造はこのようになっていています。

grpc-proto/
├── Gopkg.lock
├── Gopkg.toml
├── README.md
├── coordinate
├── docker-compose.yaml
├── gateway
├── item
├── user
└── vendor

docker-composeはこのようになっています。

docker-compose.yml

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サービスのプロトコル定義から見ていきましょう。

プロトコル定義

item/pb/item.proto

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サーバーと通信するクライアント側の機能を実装します。

item/client.go

.
.
.
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サーバー周りの機能を実装していきます。

item/server.go

.
.
.

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と直接やり取りする部分を実装していきます。

item/repository.go

.
.
.

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
}

.
.
.

Repositoryinterfaceにはserver層で定義されているインターフェースの中で使われる関数が集約されています。
それぞれの関数にはDBから取得したデータをGoの型にマッピングする機能を持たせています。

main.go

ポート8080でgrpcサーバーを立ち上げます。

item/main/main.go

.
.
.

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))
}

文字通りプログラムのエントリーポイントであり、コンテナ内でこのファイルをコンパイルし実行しています。

item/app.dockerfile

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

プロトコル定義

user/user.proto

.
.
.

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層とほとんど同じです。

user/client.go

.
.
.

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層とほとんど同じです。

user/server.go

.
.
.

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層とほとんど同じです。

user/repository.go

.
.
.

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とほとんど同じです。

user/main/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層

coordinate/client.go

.
.
.

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層

coordinate/server.go

.
.
.

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.Clientuser.Clientを持たせて、Itemサービス、Userサービスのgrpcサーバーと通信できるようにしています。
GetCoordinatesByUserではコーディネートのデータを取得する前に、特定のIDのユーザーが存在するか確かめています。

repository層

coordinate/repository.go

.
.
.

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

coordinate/main/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

gateway/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)
    }
}

作ってみて感じたこと

時間がかかる

今回作ったサンプルAPIRailsでモノリシックに作ればほんの一瞬できるものですが、マイクロサービスにしたことで結構時間がかかった感じがします。
よく言われることですが、マイクロサービスにすることで発生する初期のオーバーヘッドはかなり大きいと感じました。
中規模以上のアプリケーションをマイクロサービスで作ってこの初期のオーバーヘッドを回収していくんだなぁと実感できました。

各機能が疎結合になる

疎結合になることで、シンプルに考えられたり、実装できたりするのは純粋に利点だなぁと実感しました。
ただ疎結合に関する難しさは、既存のサービスの機能を切り分けることそのものにあるのだろうとも思いました。

デバッグが増える

サービス間の通信の部分でのエラーなど、モノリスだと考えなくてよかったものが出てきて、運用する際に悩みのタネになったりしそうだなぁと思いました。

まとめ

今回、マイクロサービスで簡単なサンプルAPIを実装してみたので

  • 実装方法
  • 実装してみた所感

を書きました。

やはりアプリケーションにあった設計方法というものがあって、その判断を適切にするということが重要なんだと実感することができました。
"銀の弾丸"などないんだなーと。

この記事がどなたかの参考に少しでもなれば幸いです。

参考記事

Using GraphQL with Microservices in Go - Outcrawl