satococoa's blog

主にサーバーサイド、Web 系エンジニアのブログです。Go, Ruby, React, GCP, ...etc.

goでjsonの一部のデータだけを後から型を指定して取り出したい

例えばAPIなんかでたまにあるこう言う形。 これを受け取った場合、共通処理としてまずはerrorがあるかどうかを見て、もしerrorがなかった場合にはdataをそのAPIに合わせた型にして取り出したいとする。

{
  "error": null,
  "data": {
    "title": "fuga"
  }
}
{
  "error": null,
  "data": {
    "nickname": "foobar"
  }
}

json.RawMessage を使うと後からjsonの一部だけを型を指定して取り出すことができる。 まさに今回欲しかったものにぴったり。 golang.org

サンプルはこちら https://play.golang.org/p/e2lW0-15o3P

package main

import (
    "encoding/json"
    "fmt"
    "log"
)

var userJSON = `{
  "error": null,
  "data": {
      "nickname": "hoge"
  }
}`
var todoJSON = `{
  "error": null,
  "data": {
      "title": "fuga"
  }
}`

type Response struct {
    Error *ResponseError  `json:"error,omitempty"`
    Data  json.RawMessage `json:"data,omitempty"` // <- User か Todo になる
}

type ResponseError struct {
    Code    int    `json:"code,omitempty"`
    Message string `json:"message,omitempty"`
}

type User struct {
    Nickname string `json:"nickname,omitempty"`
}

type Todo struct {
    Title string `json:"title,omitempty"`
}

func parseResponse(data []byte) (*Response, error) {
    res := &Response{}
    if err := json.Unmarshal(data, res); err != nil {
        return nil, err
    }
    if res.Error != nil {
        return nil, fmt.Errorf("error response: %v\n", res.Error)
    }
    return res, nil
}

func main() {
    // User APIにアクセスした
    userRes, err := parseResponse([]byte(userJSON))
    if err != nil {
        log.Fatalf("parse error: %v", err)
    }
    user := &User{}
    if err := json.Unmarshal(userRes.Data, user); err != nil {
        log.Fatalf("error: %v", err)
    }
    fmt.Printf("user: %+v\n", user)

    // TODO APIにアクセスした
    todoRes, err := parseResponse([]byte(todoJSON))
    if err != nil {
        log.Fatalf("parse error: %v", err)
    }
    todo := &Todo{}
    if err := json.Unmarshal(todoRes.Data, todo); err != nil {
        log.Fatalf("error: %v", err)
    }
    fmt.Printf("todo: %+v\n", todo)

}

Private module を含む go サーバの Dockerfile

FROM golang as builder
ENV GOFLAGS=-mod=vendor
WORKDIR /work
COPY go.* ./
RUN go mod download
COPY . ./
RUN CGO_ENABLED=0 GOOS=linux go build -mod=readonly -v -o app

FROM alpine:latest
RUN apk --no-cache add ca-certificates
COPY --from=builder /work/app /app
CMD ["/app"]

事前に go mod vendor しておき、 ENV GOFLAGS=-mod=vendor を付与することで private リポジトリを get しようとしない。

参考: github.com

mysql クライアントのみをインストールした状態で mysql2 gem をビルドする

この記事の続きです。

satococoa.hatenablog.com

よく考えると mysql サーバをローカルにインストールする必要はないので、以下のようにやってみました。

brew uninstall mysql
brew install mysql-client
# mysql との競合を避けるため /usr/local/opt/mysql-client 以下に入るので、以下のようにオプションを調整
bundle config --local build.mysql2 "--with-ldflags=-L/usr/local/opt/mysql-client/lib --with-cppflags=-I/usr/local/opt/mysql-client/include"

これで入りました。

Firebase Authentication の ID トークンを Ruby で検証する

ID トークンを確認する | Firebase にあるように、Fireabse Authentication によって発行された ID トークンを正しく検証することにより、そのユーザの user_id を確認することができます。

Firebase Admin SDK が提供されていればそれを使うことで簡単に検証できるのですが、Ruby 版は提供されていないので Rails から使いたい場合などは自分で検証処理を書くことになります。

検証すべき内容は ID トークンを確認する | Firebase に書いてあるのでそれに沿って書いていきます。

要: JWT gem

# @see https://firebase.google.com/docs/auth/admin/verify-id-tokens?hl=ja
#
# Usage:
#    validator = FirebaseAuth::TokenValidator.new(token)
#    payload = validator.validate!
#
class FirebaseAuth::TokenValidator
  class InvalidTokenError < StandardError; end

  ALG = 'RS256'
  CERTS_URI = 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com'
  CERTS_CACHE_KEY = 'firebase_auth_certificates'
  PROJECT_ID = 'YOUR_PROJECT_ID'
  ISSUER_URI_BASE = 'https://securetoken.google.com/'

  def initialize(token)
    @token = token
  end

  #
  # Validates firebase authentication token
  #
  # @raise [InvalidTokenError] validation error
  # @return [Hash] valid payload
  #
  def validate!
    options = {
      algorithm: ALG,
      iss: ISSUER_URI_BASE + PROJECT_ID,
      verify_iss: true,
      aud: PROJECT_ID,
      verify_aud: true,
      verify_iat: true,
    }
    payload, _ = JWT.decode(@token, nil, true, options) do |header|
      cert = fetch_certificates[header['kid']]
      if cert.present?
        OpenSSL::X509::Certificate.new(cert).public_key
      else
        nil
      end
    end

    # JWT.decode でチェックされない項目のチェック
    raise InvalidTokenError.new('Invalid auth_time') unless Time.zone.at(payload['auth_time']).past?
    raise InvalidTokenError.new('Invalid sub') if payload['sub'].empty?

    payload
  rescue JWT::DecodeError => e
    Rails.logger.error e.message
    Rails.logger.error e.backtrace.join("\n")

    raise InvalidTokenError.new(e.message)
  end

  private

  # 証明書は毎回取得せずにキャッシュする (要: Rails.cache)
  def fetch_certificates
    cached = Rails.cache.read(CERTS_CACHE_KEY)
    return cached if cached.present?

    res = Net::HTTP.get_response(URI(CERTS_URI))
    raise 'Fetch certificates error' unless res.is_a?(Net::HTTPSuccess)

    body = JSON.parse(res.body)
    expires_at = Time.zone.parse(res.header['expires'])
    Rails.cache.write(CERTS_CACHE_KEY, body, expires_in: expires_at - Time.current)

    body
  end
end

macOS Mojave にアップグレードしたら mysql2 gem のインストール時にエラーが発生した

手元の環境では High Sierra のときはこの辺の考慮をしなくてもインストールできていた気がするのですが、さっき新規に bundle install をしたら以下のようなエラーになってしまいました。

ld: library not found for -lssl
clang: error: linker command failed with exit code 1 (use -v to see invocation)
make: *** [mysql2.bundle] Error 1

brew でインストールした openssl が見つからないだけのようで、以下のような対応を行いました。(確か Sierra 以前の頃はこれやってた気がするんですが、なぜ High Sierra のときにはやらずにインストールできていたのだろう。。)

bundle config --local build.mysql2 "--with-ldflags=-L/usr/local/opt/openssl/lib --with-cppflags=-I/usr/local/opt/openssl/include"

参考: https://github.com/brianmario/mysql2/issues/1005

ちなみに今開発しているアプリのデプロイ先が App Engine Flexible Environment (ruby) なので、ruby や node.js をローカルにインストールしちゃう方法で開発を行なっています。 もちろんバージョンは揃えたい(開発者間での差異を無くしつつ、プロジェクトごとに違うバージョンを使えるようにしたい)ので、ruby や node は rbenv / nodenv を使い、MySQL 等のミドルウェアは dokcer-compose を使うようにしていて、コンテナとの付き合い方はこのぐらいの力の入れ具合がちょうどバランスいいかなと個人的には感じています。

とはいえローカルに開発環境を構築している関係上、今回のような個人の環境に依存する問題が発生してしまうことがありますし、実行環境が k8s だったり App Engine の custom runtime だったら全部コンテナ上に乗せて開発する方法を検討すると思いますのでその辺はケースバイケースですね。。

Angular で作った SPA を Docker の nginx を使って動かす

このサイトにある手順をまるっと使わせていただきました。

medium.com

追加したのは以下の3ファイル

.dockerignore

node_modules

nginx.conf

server {
  listen 80;
  location / {
    root /usr/share/nginx/html;
    index index.html index.htm;
    try_files $uri $uri/ /index.html =404;
  }

  error_page   500 502 503 504  /50x.html;
  location = /50x.html {
    root   /usr/share/nginx/html;
  }
}

Dockerfile

FROM node as build-stage
WORKDIR /app
COPY package*.json /app/
RUN npm install
COPY ./ /app/
ARG configuration=production
RUN npm run build -- --output-path=./dist/out --configuration $configuration

FROM nginx
COPY ./nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build-stage /app/dist/out/ /usr/share/nginx/html

以下の手順でビルド & 公開

$ docker build -t <IMAGE_TAG> .
$ docker run -p 8080:80 <IMAGE_TAG>

これで localhost:8080 でアクセスできる。

一般公開だったら Firebase Hosting とかで良いのですが、今回は社内イントラ内での公開だったのでこのようにしました。

Kubernetes の CronJob で bundle update して PR 投げてくれるようにした

bundle update を定期的に実行し差分がある場合は pull request を投げる、というよくある仕組みを k8s の cronjob で実装しました。

clone ( or pull ) -> 何らかの処理を行う (ここでは bundle update) -> [差分があったら] -> commit & push -> Pull Request を出す、という処理は今回の bundle update に限らず汎用的に使えるものだと思うので、コマンドラインツールとして Go で実装しました。

github.com

あとは任意のコンテナの中で上記 prbot のバイナリを取得して実行すれば OK。

以下のような manifest を書きます。 Clone や PullRequest の送信に GitHub の Personal Access Token が必要になるので取得して Secrets として登録しておいてください。

apiVersion: batch/v1beta1
kind: CronJob
metadata:
  name: bundle-update
spec:
  schedule: "0 2 * * WED" # cron式
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: Never
          containers:
          - image: library/ruby
            name: ruby
            command: [ "/bin/sh" ]
            args:
            - "-c"
            - |2
              set -e
              wget https://github.com/satococoa/prbot/releases/download/v${PRBOT_VERSION}/prbot_${PRBOT_VERSION}_linux_amd64.tar.gz
              tar -xvzf prbot_${PRBOT_VERSION}_linux_amd64.tar.gz
              mv prbot /bin/prbot
              chmod +x /bin/prbot
              gem install bundler
              prbot
            env:
            - name: PRBOT_VERSION
              value: "0.1.2"
            - name: GITHUB_REPOSITORY
              value: "org/repository"
            - name: GITHUB_ACCESS_TOKEN
              valueFrom:
                secretKeyRef:
                  name: bundle-update-secret
                  key: githubAccessToken
            - name: BASE_BRANCH
              value: "master"
            - name: COMMAND
              value: "bin/bundle update"
            - name: TITLE
              value: "pull request のタイトル"
            - name: AUTHOR_NAME
              value: "prbot"
            - name: AUTHOR_EMAIL
              value: "prbot@example.com"

以上です。 使うイメージについては native extension を含む gem なんかが含まれる場合は library/ruby じゃビルドできないかもしれないので、実際にアプリケーションを動かすコンテナで実行する方が確実だと思います。 library/rubybuildpack-deps を基に作られていて mysql-client なんかは入ってるため、僕の場合は library/ruby で大丈夫でした。

学び

  • GoReleaser が超便利。
    • マルチプラットフォーム向けにビルドして バイナリを GitHub の Release にアップロードして、さらに Docker イメージとか Homebrew 用のレシピとかも作れちゃう。
  • buildpack-deps という Docker image を知れた。