朝日ネット 開発者ブログ

朝日ネットのエンジニアによるリレーブログ。今、自分が一番気になるテーマで書きます。

microservicesはじめました (2)

はじめに

前回はmicroservicesの道具としてgRPCを触ってみたので今回は小規模サービスにしてみます。

サービス構成

Geolocation APIを使ってJavaScriptでクライアントの位置情報を取得して、その位置に関連する情報を表示する、というものを考えます。

位置情報からの情報取得はYahoo! Open Local Platform (YOLP)を利用することにします。

サービス構成図

各microserviceの概要については:

  • nginx: リバースプロキシとして動くほか、Geolocation APIの利用にはHTTPSが必要なのでその処理もしてもらう。
  • front: ページの枠を返す。
  • API: ページの部品を返す。
  • address: 位置情報からその住所を返す。
  • map: 位置情報から周辺地図を返す。

実装

上記各サービスを個別に実装していきます。 今回は1人で作っていますが実際にはチームごとに並行して進められそうですね。

ディレクトリ構成は次のようにしました。

grpc02/
 +-nginx/
 | +-Dockerfile
 | +-nginx.conf
 | +-server.crt
 | +-server.key
 +-front/
 | +-Dockerfile
 | +-main.go
 | +-template/
 |   +-index.tmpl
 +-api/
 | +-Dockerfile
 | +-main.go
 +-address/
 | +-Dockerfile
 | +-main.go
 +-maps/
 | +-Dockerfile
 | +-main.go
 +proto/
 | +-address.proto
 | +-maps.proto
 +rpc/
   +-address/
   | +-address.pb.go
   +-maps/
     +-maps.pb.go

address: 位置情報からその住所を返す

引数で緯度経度を受け取って、Yahoo!リバースジオコーダAPIに投げて結果を返します。 結果の返却にgRPCを使います。1 proto/address.protoに定義を書きます。

syntax = "proto3";

package address;

message Location {
    double latitude = 1;
    double longitude = 2;
}

message Address {
    string status = 1;
    string address = 2;
    string country = 3;
    string pref = 4;
    string city = 5;
}

service Geocode {
    rpc Decode(Location) returns (Address);
}

これをコンパイルして、rpc/以下に.pb.goができるようにします。

protoc -I proto/ --go_out=plugins=grpc:rpc/address/ proto/address.proto

できたファイルはサーバ・クライアントの双方から参照します。

import (
...
    "grpc02/rpc/address"
...
)

maps: 位置情報から周辺地図を返す

addressとほとんど同じです。 ここではYahoo!スタティックマップAPIを使います。 APIの結果はPNG画像なので、bytesでバイナリを返します。

syntax = "proto3";

package maps;

message Location {
    double latitude = 1;
    double longitude = 2;
}

message MapProperty {
    Location location = 1;
    int32 scale = 2;
}

message MapImage {
    string status = 1;
    bytes map = 2;
    string format = 3;
}

service GetMapImage {
    rpc Get(MapProperty) returns (MapImage);
}

API: ページの部品をつくる情報を返す

nginx経由でやってくる/api/以下のリクエストを受けます。 さきほどの2つと通信するgRPCクライアントになるようにします。

api/main.go

func main() {
    h := http.NewServeMux()
    h.HandleFunc("/address", addressHandler)
    h.HandleFunc("/maps", mapsHandler)
    s := &http.Server{
        Addr:    fmt.Sprintf(":%d", httpPort),
        Handler: h,
    }
    err := s.ListenAndServe()
    if err != nil {
        logger.Print(err)
        return
    }
    logger.Print("started")
}
func addressHandler(w http.ResponseWriter, r *http.Request) {
    conn, err := grpc.Dial("address:10080", grpc.WithInsecure())
    if err != nil {
        errorHandler(w, r, err)
        return
    }
    defer conn.Close()
    client := address.NewGeocodeClient(conn)
    latitude, err1 := strconv.ParseFloat(r.FormValue("lat"), 64)
    longitude, err2 := strconv.ParseFloat(r.FormValue("lon"), 64)
    if err1 != nil || err2 != nil {
        errorHandler(w, r, errors.New("invalid parameters"))
        return
    }
    req := &address.Location{Latitude: latitude, Longitude: longitude}
    res, err := client.Decode(context.Background(), req)
    if err != nil {
        errorHandler(w, r, err)
        return
    }

    w.WriteHeader(http.StatusOK)
    hd := w.Header()
    hd.Set("Content-Type", "application/json")
    result := addressResult{Status: res.Status, Address: res.Address}
    j, _ := json.Marshal(result)
    fmt.Fprint(w, string(j))
}

front: ページの枠を返す

htmlの本体を返すhttpサーバです。 まずこのページが返り、そこから非同期で/api/以下から情報を取得して表示することにします。

package main

import (
    "flag"
    "fmt"
    "html/template"
    "log"
    "net/http"
    "os"
)

type frontServer struct {
    httpPort int
    tmpl     *template.Template
}

var mainServer = frontServer{httpPort: 80}
var logger = log.New(os.Stderr, "", log.LstdFlags)

func main() {
    tmpldir := flag.String("tmpldir", "template", "the root directory of template files")
    flag.Parse()
    mainServer.tmpl = template.Must(template.ParseFiles(*tmpldir + "/index.tmpl"))
    h := http.NewServeMux()
    h.HandleFunc("/", indexHandler)
    s := &http.Server{Addr: fmt.Sprintf(":%d", mainServer.httpPort), Handler: h}
    err := s.ListenAndServe()
    if err != nil {
        logger.Print(err)
        return
    }
    logger.Print("started")
}

type PageParams struct {
    Title string
}

func indexHandler(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
    hd := w.Header()
    hd.Set("Content-Type", "text/html")
    params := PageParams{
        Title: "microservicesはじめました",
    }
    mainServer.tmpl.Execute(w, params)
}

nginx: リバースプロキシ + https処理

/ にきたときにfrontに、/api/にきたときにAPIにリクエストを飛ばします。

location / {
        proxy_pass http://front:9030/;
}
location /api/ {
        proxy_pass http://api:9031/;
}

httpsが使えるように設定しておきます。

server {
    listen  443 ssl;
    ssl_certificate server.crt;
    ssl_certificate_key server.key;
    ssl_protocols   TLSv1.1 TLSv1.2;
    ssl_ciphers EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH;

動かす

単一マシン上で各サービスのコンテナを動かします。 これぐらいの規模ならMakefileでやってもいいのですがdocker swarmを使うことにします。

Get Started, Part 3: Services | Docker Documentation

docker-compose.ymlにservice概要を書きます。

version: "3"
services:
  nginx:
    image: grpc02-nginx:latest
    ports:
      - "80:80"
      - "443:443"
    depends_on:
      - front
      - api
  front:
    image: grpc02-front:latest
    ports:
      - "9030:80"
  api:
    image: grpc02-api:latest
    ports:
      - "9031:80"
  address:
    image: grpc02-address:latest
    ports:
      - "10080:5300"
  maps:
    image: grpc02-maps:latest
    ports:
      - "10081:5300"

あとは動かします。

docker swarm init
docker stack deploy -c docker-compose.yml grpc02

まとめ

作ってみた感想としては、各々プログラムのサイズが小さく作りやすいのではないかと感じました。 その恩恵を受けるためにも正確な分割ができるといいのですが。やはり設計がポイントのように思います。 DBを使えばもう少しmicroservicesらしくなったかもしれません。

今回は必要ないのでしていませんが、サービスごとにインスタンスを増やしてスケールさせられるのは強いと思います。

採用情報

朝日ネットでは新卒採用・キャリア採用を行っております。


  1. 直接JSONでクライアントに返せばいいのでgRPCを使う必要性は小さいですが今回は使いたいので使います