開拓馬の厩

いろいろやる

Puppeteer を用いたアプリケーションから Chrome を切り離す

Puppeteer を用いた web スクレイピングアプリケーションの起動中に CPU/memory 使用量が荒ぶるので何とかしたいなあと思って最近対策を考えています

Puppeteer は内部で Chrome (Chromium) を起動しているので、スクレイピングのロジック部分と Chrome を分離すればリソース割り当てがやりやすくなりそうなので挑戦してみます

書いたコードはこちら↓*1

github.com

実験台

ここに Puppeteer を使った適当なアプリケーションがあります(Puppeteer 公式サイトのサンプルをコピペしてちょっと手を加えたもの)

# index.ts
import * as puppeteer from 'puppeteer';
import { setTimeout } from 'timers/promises';

const getChromeDevPage = async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();

  await page.goto('https://developer.chrome.com/');

  // Set screen size
  await page.setViewport({width: 1080, height: 1024});

  // Type into search box
  await page.type('.search-box__input', 'automate beyond recorder');

  // Wait and click on first result
  const searchResultSelector = '.search-box__link';
  await page.waitForSelector(searchResultSelector);
  await page.click(searchResultSelector);

  // Locate the full title with a unique string
  const textSelector = await page.waitForSelector(
    'text/Customize and automate'
  );
  const fullTitle = await textSelector!.evaluate(el => el.textContent);

  // Print the full title
  console.log('The title of this blog post is "%s".', fullTitle);

  await browser.close();
}


(async () => {
  while (true) {
      try {
          await getChromeDevPage();
      } catch (e) {
          console.error('failue', { e });
          break;
      }
      await setTimeout(10 * 1000);
  }
})();

「developer.chrome.com にアクセスし、 "Customize and automate ~" というブログ記事のタイトルを抽出して標準出力に吐き出す」という動作を 10秒ごとに行います

今回はこのアプリケーションに手を加えて

をやっていきます

Puppeteer-core

puppeteer-coreChromium が同梱されていない計量版の Puppeteer です

www.npmjs.com

puppeteer-core の切り替えは細かいところを省くと

1. package.json を書き換える

   "dependencies": {
-     "puppeteer": "19.5.2"
+     "puppeteer-core": "19.5.2"
   },

2. import を書き換える

- import * as puppeteer from 'puppeteer';
+ import * as puppeteer from 'puppeteer-core';

3. puppeteer.launchpuppeteer.connect に変更

- const browser = await puppeteer.launch(someOptions);
+ const browserURL = ... # リモートブラウザの URL
+ const browser = await puppeteer.connect({ browserURL, ...someOptions });

の三か所を変えればとりあえず完了です

先のコードを書き換えるとこんな感じ

# index.ts
import * as puppeteer from 'puppeteer-core';
import { setTimeout } from 'timers/promises';

const getChromeDevPage = async (browserURL: string) => {
    const browser = await puppeteer.connect({ browserURL });
    const page = await browser.newPage();

    <<>>

    await browser.close();
};

(async () => {
    # 環境変数からリモートブラウザの URL を受け取る
    const browserURL = process.env.BROWSER_ADDR;
    if (browserURL === undefined) {
        console.error('Environment variable `BROWSER_ADDR` is not set.');
        return;
    }
    while (true) {
        try {
            await getChromeDevPage(browserURL);
        } catch (e) {
            console.error('failue', { e });
            break;
        }
        await setTimeout(10 * 1000);
    }
})();

Docker image を焼いて動かす

puppeteer-core で書き換えたコードを実際に動かしてみましょう

リモートブラウザは browserless/chrome の Docker image を使います*2

適当に Dockerfile を用意し、こんな感じに docker-compose.yaml を書きます。Dockerfile の内容は GitHub のコードを見てください*3

version: '3.7'
services:
  chrome:
    image: browserless/chrome
    ports:
      - 3000:3000

  app:
    build: .
    environment:
      - BROWSER_ADDR=http://chrome:3000

browserless/chrome の起動にすこし時間がかかるため、先に chrome を起動させてから少し待って app を動かします

$ docker compose up -d chrome
$ docker compose up app

[+] Building 70.1s (13/13) FINISHED
[+] Running 1/1

Attaching to puppeteer-separate-app-1
puppeteer-separate-app-1  | The title of this blog post is "Customize and automate user flows beyond Chrome DevTools Recorder".
puppeteer-separate-app-1  | The title of this blog post is "Customize and automate user flows beyond Chrome DevTools Recorder".
puppeteer-separate-app-1  | The title of this blog post is "Customize and automate user flows beyond Chrome DevTools Recorder".
...

10 秒ごとに「The title of this blog post is "Customize and automate user flows beyond Chrome DevTools Recorder".」が出力されます

kubernetes で動かす

実運用を想定して kubernetes で動かしてみます

まず docker image をビルドしておきます。今回は適当に scraping:1.0.0 という名前を付けて

docker build -t scraping:1.0.0 .

kubernetes の cluster は kind を使って作ります

kind.sigs.k8s.io

kind で cluster を作成します

$ kind create cluster
$ kind load docker-image scraping:1.0.0 # 手元でビルドした image を kind の cluster で使えるようにする

kubernetes の manifest を書きます

# chrome.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: chrome-ns
---
apiVersion: apps/v1
kind: Deployment
metadata:
  namespace: chrome-ns
  name: chrome
  labels:
    app: chrome
spec:
  selector:
    matchLabels:
      app: chrome
  replicas: 1
  template:
    metadata:
      labels:
        app: chrome
    spec:
      containers:
        - name: chrome
          image: browserless/chrome
          ports:
            - name: chrome
              containerPort: 3000
              protocol: TCP
      restartPolicy: Always
---
apiVersion: v1
kind: Service
metadata:
  namespace: chrome-ns
  name: chrome-svc
spec:
  ports:
    - name: chrome
      targetPort: chrome
      port: 3000
  selector:
    app: chrome
# scraping.yaml
apiVersion: v1
kind: Namespace
metadata:
  name:  scraping
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: scraping
  namespace: scraping
  labels:
    app: scraping
spec:
  selector:
    matchLabels:
      app: scraping
  replicas: 2
  template:
    metadata:
      labels:
        app: scraping
    spec:
      containers:
        - name: scraping
          image: scraping:1.0.0
          env:
            - name: BROWSER_ADDR
              value: "http://chrome-svc.chrome-ns.svc.cluster.local:3000"
      restartPolicy: Always

cluster にデプロイして scraping のログを見ると「The title of this blog post is "Customize and automate user flows beyond Chrome DevTools Recorder".」が出力されています

$ kubectl apply -f chrome.yaml
$ kubectl apply -f scraping.yaml
  # スクレイピングが何回か実行されるまで数十秒待つ
$ kubectl logs deploy/scraping -c scraping -n scraping
Found 2 pods, using pod/scraping-74b5d47b66-psqbj
The title of this blog post is "Customize and automate user flows beyond Chrome DevTools Recorder".
The title of this blog post is "Customize and automate user flows beyond Chrome DevTools Recorder".
The title of this blog post is "Customize and automate user flows beyond Chrome DevTools Recorder".
...

kubernetes 上で動かせていることが確認できました

上記の例では Chrome の pod 1台に対してスクレイピングアプリの pod を2台動かしているので、ブラウザ同梱の Puppeteer を 2台動かすのと比較して Chrome 1台分のリソースを削減できていそうですね(雑な単純計算ですが……)

まとめ

本稿では puppeteer を使ったアプリケーションから Chromium を分離してみました

Chrome を分離することで以下のような利点がありそうです

  • Chrome を分離することで kubernetes の node や CPU/memory の配置に改善できる
    • Node.js の container と Chrome の conatainer のリソースを別々に設定できる
    • スクレイピングアプリの開発者もロジック部分のみを注視して最適化の試行錯誤を行える
  • docker build が楽になる
    • puppeteer 内臓の Chromium を使う場合、ビルド時にちょっとした小細工*4が必要

まだ構想段階なので、勤務先*5のアプリケーションで実際に運用してみて使用感を確かめたいです*6

謝辞:Chrome 分離構想を提案してくれた id:hiroqn さん id:taketo957 さんに感謝

*1:本文では実行環境の説明などを省略しており、それらの説明はリポジトリの README に書くようにしています

*2:browserless/chrome はコンテナサイズがわりとデカめなのでもっと軽量なやつを探したいところ

*3:趣味で nixos/nix ベースにしたので説明がめんどくさい

*4:この記事本来は 'puppeteer' VS 'puppeteer-core' の比較をやろうとしていたが、この小細工が面倒だったので puppeteer が入った image を作らずに済む構成にした

*5:株式会社 HERP ではエンジニアを募集しています herp.careers

*6:この記事の裏の狙いは弊社でスクレイピングをやっているメンバーへの情報共有

MySQL しか使ったことないアプリケーション開発者が PostgreSQL の Docker 環境を立てるまで

最近仕事で PostgreSQL を使うことになったので、忘備録としてDocker で環境構築して一通り触ったメモを、主に MySQL との差分に注目しながら書きます。

PostgreSQL の Docker image

PostgreSQL は公式の docker image が公開されています

hub.docker.com

基本的に ${Postgresのバージョン}-${baseのLinux OS} の名前で 11.14-apline とか 12.9-bullseye とかのタグがつけられています。 数字のみのタグは -bullseye と同内容(Debian 11 ベース)のようです。 基本的には好みのバージョンの -alpine を使い、何か困ったら -bullseye に変える感じにすればいいと思います。

以下のような docker-compose.yaml を準備しておきます。

version: '3.7'

services:
  db:
    image: postgres:13.4-alpine
    ports:
      - 5432:5432 # postgres はデフォルトで 5432 を使う
    environment:
      - POSTGRES_HOST=localhost
      - POSTGRES_PASSWORD=password # スーパーユーザーのパスワード
      - POSTGRES_USER=admin # スーパーユーザーの名前。デフォルトは `postgres`
      - POSTGRES_DB=test_db # この環境変数で指定した名前のデータベースを自動で作ってくれる
      # パスワード無しでのログインを可能にする。MySQL における `MYSQL_ALLOW_EMPTY_PASSWORD=yes` とほぼ同じ
      # - POSTGRES_HOST_AUTH_METHOD=trust
    volumes:
      - postgres-tmp:/var/lib/postgresql/data

volumes:
  postgres-tmp:

POSTGRES_PASSWORD に何かしらの値を入れるあるいは POSTGRES_HOST_AUTH_METHOD=trust を付けるのどちらかを行わないと、起動時にエラーを吐きます。

対話型クライアント psql

PostgreSQLpsql という対話型 のクライアントが用意されています。MySQL における mysql コマンドとだいたい同じです。

先述の docker-compose.yaml で image を立ち上げている場合、 docker compose exec db psql -U admin -W test_db で立ち上げられます。

docker compose exec db psql -U admin -W test_db
Password: password

psql (13.4)
Type "help" for help.

test_db=#

ここに出てきたオプションの説明です。

  • -U (または --username): ユーザー名
  • -W: パスワード要求プロンプトを表示させる
  • test_db: 使用するデータベース名*1

MySQL との違いはユーザー名のあたりぐらいでしょうか(MySQL では -u または --user)

私の環境ではなぜか POSTGRES_HOST_AUTH_METHOD=trust を指定しなくてもパスワード入力を省略してログインできたり、パスワードプロンプトに間違った文字列を入力してもログインできたりする現象が発生しました(ローカル環境で動作確認をする分には問題ないのでとりあえずそのままで)。

psql を起動できたのでとりあえず色々触ってみましょう。psql にはメタコマンドと呼ばれるものが用意されており、以下のようなものがあります。

メタコマンド 説明 MySQLで似たような挙動をするやつ
\?(またはhelp) ヘルプを表示 ? help
\l データベース一覧を表示 show databases
\du ユーザー一覧を表示 select * from mysql.user; など
\dn スキーマ一覧を表示 スキーマMySQL には存在しない
\dt テーブル一覧を表示 show tables
\d {テーブル名} 指定したテーブルの概要を表示 show columns from {テーブル名}
\c {データベース名} 指定したデータベースに接続する use {データベース名}
\q psql を終了((PostgrSQL 11 以降は exit も可)) q quit exit

とりえあず、適当にテーブルを作って、適当にデータを追加して、適当に取得してみます。

データベース一覧を見る

test_db=# \l
                             List of databases
   Name    | Owner | Encoding |  Collate   |   Ctype    | Access privileges
-----------+-------+----------+------------+------------+-------------------
 postgres  | admin | UTF8     | en_US.utf8 | en_US.utf8 |
 template0 | admin | UTF8     | en_US.utf8 | en_US.utf8 | =c/admin         +
           |       |          |            |            | admin=CTc/admin
 template1 | admin | UTF8     | en_US.utf8 | en_US.utf8 | =c/admin         +
           |       |          |            |            | admin=CTc/admin
 test_db   | admin | UTF8     | en_US.utf8 | en_US.utf8 |

POSTGRES_DB で指定した test_db というデータベースが作成されています。

テーブル作成

適当に CREATE TABLE します。

test_db=# CREATE TABLE "test_table" (
  "id" VARCHAR(32) NOT NULL,
  "name" VARCHAR(255) NOT NULL,
  "tags" VARCHAR(255)[] NOT NULL,
  "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,

  PRIMARY KEY ("id")
);

CREATE TABLE

\dt でテーブル一覧を表示すると、作成した test_table が確認できます。

test_db=# \dt
          List of relations
 Schema |    Name    | Type  | Owner
--------+------------+-------+-------
 public | test_table | table | admin

\d test_tabletest_table の中身を見ます。

test_db=# \d test_table;
                               Table "public.test_table"
   Column   |              Type              | Collation | Nullable |      Default
------------+--------------------------------+-----------+----------+-------------------
 id         | character varying(32)          |           | not null |
 name       | character varying(255)         |           | not null |
 tags       | character varying(255)[]       |           | not null |
 created_at | timestamp(6) without time zone |           | not null | CURRENT_TIMESTAMP
Indexes:
    "test_table_pkey" PRIMARY KEY, btree (id)

PRIMARY KEY の指定方法が MySQL とちょっと違いますね。あと、 PostgreSQL は配列型を使えるようで便利そうですね。*2

データを保存・取得

test_table にデータを適当に insert してみます。

test_db=# INSERT INTO test_table
  ("id", "name", "tags") VALUES
  ( 'id1234', 'MyName', Array['tag1','tag2']);

INSERT 0 1

配列は Array[] で囲むか {} で囲むかすると表現できるようです。あとは連結演算子 || があって Array[1,2] || 3 とか書くと Array[1,2,3] になったりとか便利そうな機能が用意されています。

データ取得も普通に SELECT 文を書くだけです。

test_db=# SELECT * FROM "test_table" WHERE "id"='id1234';

   id   |  name  |    tags     |         created_at
--------+--------+-------------+----------------------------
 id1234 | MyName | {tag1,tag2} | 2022-02-03 09:55:55.306328

アプリケーション開発の場面では、実際のデータベースへのアクセスはほとんどの場合ライブラリ任せで生のクエリを書くことはあまりないので(特に書き込み系)動作確認はこれぐらいで。

スキーマ

PostgreSQL にはデータベースとテーブルの間にスキーマという層が存在し、三層構造になっています。MySQL にはスキーマが存在せず二層であり、このあたりが両者の大きな相違点なのかなと思います。

f:id:pf_siedler:20220207183647p:plain
データベース・スキーマ・テーブルの関係
PostgreSQLにおけるデータベース、スキーマ、テーブルの関係 -- DBOnline から引用

デフォルトでは public という名前のスキーマが用意されており、クエリが省略されているときは自動で補完されます。 public スキーマのみを使用すれば MySQL とだいたい同じ使用感になると思われますが、 MySQL では test_db.test_table と書けたところが PostgreSQL では test_db.public.table になる点は注意したいですね。

おわりに(日記)

本稿では、 PostgreSQL の Docker image を構築し、一通り触ってみました。

余談ですが、ポスグレを使うことになった経緯について少し書きます。

そこそこ大きな Node.js 製アプリケーションの一部をマイクロサービス化しつつちょっと新機能を追加する試みを進めており、その一環で私は現在 TypeScript で小さめのアプリケーションを作っています。 既存アプリケーションからのデータの移行等はなく、データベースは新規に作ったものを使うことになりました。 ある程度プロトタイプの開発を進めて方向性が決まった段階で、データベースの選定について SRE に相談したところ、 PostgreSQL を使うことになりました。 弊社ではこれまで RDBMySQL を使っていましたが、 PostgreSQL の知見を貯める意味で試験的にやってみよう❗という感じです。

当初は前例に習って MySQL を使うつもりで進めていましたが途中で postgreSQL に切り替えるという状況になったのですが、データベースの操作は ORM ライブラリの Prisma*3 を使用しており、設定ファイルをちょっと書き換えるだけで、アプリケーションの実装はほぼそのままで移行が住みました。

以上、 ORM って便利ですねという日記でした。

この記事は業務時間中に書きました。 仕事の一貫なので弊社の宣伝を

↓弊社開発チームメンバーのブログ記事集です↓

tech-hub.herp.co.jp

↓我々と一緒に働きたい人を募集中です↓

careers.herp.co.jp

参考資料

*1:psql のオプション処理が悪いのか docker との相性なのか不明だが、 docker compose exec db psql -U admin とやると admin の箇所がユーザー名かつデータベース名だと判定される現象が発生する

*2:ただ、配列を濫用すると後々管理が大変になるため、別テーブルを作って relation を貼る方が好ましい場合が多い

*3:Prisma の記事もいつか書きたいです。いつかね……

Protobuf を SQS のスキーマ管理に使ってみる

Amazon SQS のスキーマ管理を Protobuf でやってみました。

サンプルコードはこちら。

github.com

.proto ファイル

以下のような .proto ファイルを用意します。

// proto/example.proto

syntax = "proto3";

message Item {
  string product_id = 1;
  float price = 2;
}

message Purchase {
  string user_id = 1;
  repeated Item items = 2;
  // Unix time
  uint32 timestamp = 3;
}

今回は Purchase message を binary serialize した後、 base64 encode したものを SQS に積む方針で進めます。

AWS で SQS のリソースを作る

まずはじめに Amazon SQS のリソースを準備します。 今回は Amazon SQS のキューをひとつ作って、手元の環境からアクセスする方針で進めます(EC2 からアクセスしたりとかバックエンドを Lambda にしたりとかは 面倒 本稿のテーマとは無関係なのでやらない)。 キューをひとつ作るだけなのでマネジメントコンソールをポチポチするだけでもいいのですが、なんとなく Terraform を使ってみます。

予め SQSFullAccess ポリシーを持ったユーザーを作っておき、その Access Key と Secret を使って aws-cli でプロファイルを作成します(AWSのIAMユーザーを作る部分は説明略)。

# Access Key を準備する部分は省略
# aws configure で sqs という profile を作る
$ aws configure --profile sqs
  AWS Access Key ID [None]: XXXXXXXXXXXXXXXXX
  AWS Secret Access Key [None]: XXXXXXXXXXXXXXX
  Default region name [None]: ap-northeast-1
  Default output format [None]: None

# AWS_PROFILE を指定
$ export AWS_PROFILE=sqs

.tf ファイルを準備します。

# terraform/terraform.tf

provider "aws" {
  region = "ap-northeast-1"
}

resource "aws_sqs_queue" "queue" {
  name = "example-queue"
}

# Queue の URL を出力する
output "queue_id" {
  value = aws_sqs_queue.queue.id
}

あとは Terraform CLI を叩くだけで、 example-queue というキューが作られて、キューの id が出力されます。 id は URL 形式になっており、キューを積んだり取り出したりする際に必要になります。 後々用いるので環境変数に追加しておきます。

# 初回だけ必要
$ terraform init

# dry run。 実行後に AWS 上にどのような変更が行われるか確認できる
$ terraform plan

# 実際に AWS 上のリソースが作る
$ terraform apply
# =>  queue_id = "https://sqs.ap-northeast-1.amazonaws.com/xxxxxxxxxx/example-queue"

# id を環境変数に登録しておく。ついでに AWS のリージョンも
export QUEUE_URL=https://sqs.ap-northeast-1.amazonaws.com/xxxxxxxxxx/example-queue
export AWS_REGION=ap-northeast-1

Terraformで作成したリソースは terraform destroy で削除できます。使い終わったら忘れずに消しておきましょう*1

Protobuf.js

.proto ファイルと SQS の準備ができたので、やっと実装が始まります。 今回は TypeScriptProtobuf.js を用いて adapter を実装します。

github.com

Protobuf.js はその名の通り Protobuf を取り扱うライブラリです。あくまでも Protobuf を扱うライブラリなので、 gRPC 関連の機能ほぼサポートされていませんが、これはむしろ今回の用途に適っているので好都合。 gRPC の文脈では @grpc/proto-loader と組み合わせて dynamic codegen ((実行時に .proto ファイルを読み込む方式))を行う際によく使われているライブラリです。 しかし、今回は Protobuf.js が提供する CLI ツール pbjspbts を用いて static codegen ((予め .proto ファイルを読み込んでコードを生成し、それを用いる方式)) を行います。

コード生成のコマンドはそこそこ長いので shell script にまとめておきます。

# bin/protogen.sh

#!/usr/bin/env bash
set -eu

NODE_BIN=$(dirname $0)/../node_modules/.bin
PROTO_DIR=$(dirname $0)/../proto/*.proto
OUTPUT_DIR=$(dirname $0)/../generated

rm -f ${OUTPUT_DIR}/*.js ${OUTPUT_DIR}/*.ts
${NODE_BIN}/pbjs --target static-module --out ${OUTPUT_DIR}/index.js ${PROTO_DIR}
${NODE_BIN}/pbts --out ${OUTPUT_DIR}/index.d.ts ${OUTPUT_DIR}/index.js

ちなみに pbts は jsdoc を元に型定義を吐くライブラリ tsd-jsdoc を使っているようです。なので pbjs--no-comment オプションを付けてコメントを省略してしまうと .d.ts ファイルが正しく生成されません(ちょっとしたハマりポイント)。 成功すると以下のような .d.ts ファイルが生成されます。

// generated/index.d.ts から抜粋

import * as $protobuf from "protobufjs";

export interface IItem {
  productId?: string | null;
  price?: number | null;
}
export interface IPurchase {
  userId?: string | null;
  items?: IItem[] | null;
  timestamp?: number | null;
}

export class Purchase implements IPurchase {
  // Message class を作るやつ
  public static create(properties?: IPurchase): Purchase;

  // Message を binary serialize するやつ
  public static encode(
    message: IPurchase,
    writer?: $protobuf.Writer,
  ): $protobuf.Writer;

  // binary を deserialize して Message Class を得るやつ
  public static decode(
    reader: $protobuf.Reader | Uint8Array,
    length?: number,
  ): Purchase;
}

pbts{ hoge?: (T|null); } のような undefined を許容した型を吐きます。これでは少し不便なので undefined を許容しない型へ変換するヘルパーを用意します。

// clinet/types.ts

export type DropUndefined<T> = T extends undefined
  ? never
  : {
      [P in keyof T]-?: T[P] extends undefined ? never : DropUndefined<T[P]>;
    };

// DropUndefined<{ hoge?: string|null }> = { hoge: string|null }

DropUndefined<T> を用いることで、値無しの項目について明示的に null を付ける必要が出るため、意図しない代入漏れを防ぐことができます*2

メッセージを積むやつ(client)を書く

こんな感じ

// client/index.ts

import { IPurchase, Purchase } from "../generated";
import { SQS } from "aws-sdk";
import { DropUndefined } from "./types";

async function main(): Promise<void> {
  const QueueUrl = process.env.QUEUE_URL;
  if (QueueUrl === undefined) {
    console.error("Please set env `QUEUE_URL`");
    process.exit(1);
  }
  const region = process.env.AWS_REGION ?? "ap-northeast-1";

  // TS の object
  const rawMessage: DropUndefined<IPurchase> = {
    userId: "hoge",
    items: [{ productId: "fuga", price: 123.4 }],
    timestamp: new Date(0).valueOf(),
  };

  // Protobuf.js が生成した Class
  const message: Purchase = Purchase.create(rawMessage);

  // serialize されたバイト列
  const serialized: Uint8Array = Purchase.encode(message).finish();

  // バイト列を base64 でエンコードして文字列にしたもの
  const base64String: string = Buffer.from(serialized).toString("base64");

  // SQS に積む処理
  await new SQS({ region })
    .sendMessage({
      MessageBody: base64String,
      QueueUrl,
    })
    .promise();
}

main()
  .catch(e => {
    console.error(e);
    process.exit(1);
  })
  .then(() => {
    console.log("finished");
    process.exit(0);
  });

実行するとキューにメッセージが積まれます。マネジメントコンソールや aws-cli から積まれているメッセージ数を確認すると値が増えていることがわかります。

$ yarn ts-node client

# finished

$ aws sqs get-queue-attributes --queue-url $QUEUE_URL --attribute-names ApproximateNumberOfMessages

# {
#    "Attributes": {
#        "ApproximateNumberOfMessages": "1"
#    }
#}

キューを処理するやつ(consumer)を書く

キューを処理するコードはこんな感じになります

// consumer/index.ts

import { SQS } from "aws-sdk";
import { Purchase } from "../generated";

async function main() {
  const QueueUrl = process.env.QUEUE_URL;
  if (QueueUrl === undefined) {
    console.error("Please set env `QUEUE_URL`");
    process.exit(1);
  }
  const region = process.env.AWS_REGION ?? "ap-northeast-1";

  const sqsInstance = new SQS({
    region,
  });

  // SQS からメッセージを取り出す
  const sqsResult = await sqsInstance
    .receiveMessage({
      QueueUrl,
    })
    .promise();
  if (sqsResult.Messages === undefined || sqsResult.Messages.length === 0) {
    console.log("no messages found");
    process.exit(0);
  }

  // base64 string から Protobuf.js の Message Class を生成する
  const getMessage = (base64String: string): Purchase => {
    // Protobuf の仕様に従って serialize されたバイト列
    const serialized = Uint8Array.from(Buffer.from(base64String, "base64"));
    // Protobuf.js の Message Class
    return Purchase.decode(serialized);
  };
  const messages = sqsResult.Messages.map(m => m.Body)
    .filter(<T>(x: T | undefined): x is T => x !== undefined)
    .map(str => getMessage(str));

  // 各 message について、何らかの処理を行う
  // 今回は console.log に適当に文を吐くだけ
  messages.forEach(message => {
    // `message.userId` 等でプロパティにアクセスできる
    const userId = message.userId;
    // 型も付く Protobuf で `uint32 timestamp = 3;` と定義したので、`message.timestamp` は number 型
    const date = new Date(message.timestamp);

    // ただし、 Message をネストすると型が正しく付かない
    // message.items[n].price は `number | null | undefined`
    const sumPrice = message.items.reduce((sum, i) => sum + (i.price ?? 0), 0);

    console.log(
      `ユーザー(${userId})が ${date.toLocaleDateString()} に合計 ${Math.floor(
        sumPrice,
      )}円の買い物をしました`,
    );
  });

  // 取得したメッセージを消す
  await sqsInstance
    .deleteMessageBatch({
      QueueUrl,
      Entries: sqsResult.Messages.map(m => ({
        Id: m.MessageId!,
        ReceiptHandle: m.ReceiptHandle!,
      })),
    })
    .promise();
}

main()
  .catch(e => {
    console.error(e);
    process.exit(1);
  })
  .then(() => {
    console.log("finished");
    process.exit(0);
  });
$ yarn ts-node consumer

# ユーザー(hoge)が 1970/1/1 に合計 123円の買い物をしました
# finished

こんな感じで base64 string から値を復元できます。 少し気になったのは Purchase.Items の型が IItems[] になっている点でしょうか。 IItems{ productId?: (string|null); price?: (number|null); } です。 本来なら値が指定されていない項目については空文字列や 0 で初期化されているため、 { productId: string; price: number; } とみなしてよいのですが、 null や undefined を含む型として扱われてしまい不便です。 as で対処する等の工夫が要るかもしれません*3

余談:serialize について

今回は Protobuf のメッセージをバイナリに serialize しました。 ですが、 Protobuf はバイナリ以外のフォーマットにも serialize 可能です。 例えば、 JSON に serialize すれば、base64 エンコードせずとも SQS に乗せることができますし、デバッグ目的で手動ポーリングする際に可読性のあるメッセージが拾えて便利です。 送受信双方のライブラリが対応しているのなら JSON を用いる選択肢も検討の余地がありそうです*4

まとめ

いかがでしたか?(枕詞) 本記事では Protobuf を SQS のスキーマ管理に用いる例を紹介しました。 今回は client/consumer ともに TypeScript で実装しましたが、 Protobuf は language-neutral をウリにしているので、別の言語で書いたアプリケーションを使うこともできます。 node 製の BFF サーバーがメッセージを積んで、 Java や Go や Rust や Haskell で書いた job worker が処理するといった運用は実際に使う場面がありそうです。

*1:Amazon SQS は 100 万リクエスト/月 まで無料でキューの維持費もとくにないので本記事の内容を一通り試した後にキューを放置してもとくに課金されることは無い。何らかの手違いで悪意ある第三者に id が見つかってイタズラされた場合はその限りではないが

*2:oneof が使われている proto との相性はあまりよくないので万能では無いが

*3:このあたりの型定義の緩さは JavaScript を使う上で避けられない面もある。現状 TS の型定義がちゃんとしている Protobuf ライブラリが見当たらず、 client 側はまだしも consumer 側を JS/TS で書くのは面倒そうだという印象を覚えた。 Protobuf は多言語対応なので、 consumer 側については別の言語で書いてしまうほうがいいのかもしれない

*4:Protobuf.js は JSON への serialize/deseriualize 双方に対応しているようなので、少し書き換えれば JSON を積む実装に変更できる

社内勉強会はじめました

弊社で社内勉強会をした話を書く

弊社の紹介

本ブログで現在勤務している会社に言及したことがあまり無いので改めて紹介をします。 私は昨年の9月から株式会社HERPでエンジニアをしています。 HERPは採用管理システム(Applicant Tracking System; ATS)を提供している会社です。 主にTypeScriptとHaskellを使ってプロダクト開発をしています。 弊社にはエンジニアが十数名*1在籍しています。 中にはブラウザのレンダリングエンジンにContributeしている人がいたり、計算機科学の研究をしている現役大学院生がいたりします。メンバーの知識に差があるのでそれを埋めるためにも勉強会をやりたいなあという声が社内であがっていました。

また、弊社では「スクラム採用」という採用活動を推進しています。 スクラム採用とは採用施策を人事担当者ではなく現場社員が立案・遂行するという採用活動です。 エンジニアの採用はエンジニアが、デザイナーの採用はデザイナーが介入するといった感じです。 エンジニアがスクラム採用をやっていくにはTwitterやブログでテクノロジーに関する話をしたり、イベントで登壇したりして注目を集める能力があるとうれしい。 社内で勉強会を行うのは知識を発信する訓練として良さそうだというのも社内勉強会を開催した動機のひとつです。

社内勉強会の様子

8月1日に私が企画して社内勉強会を行いました。 1回目なのでひとまず30分ぐらいで終わるように発表者を2名集めて発表7分、質疑応答7分としました。

Cybaiさん:今年の6月にMozilla Whistler All Hands 2019に参加した話をしてもらいました。 hiroqnさん:nixパッケージマネージャーの話をしてもらいました。

主催者としての反省

この手のイベントの企画は初めてだったのですが、やってみた反省としては以下のとおりです。

  • 発表準備のケツ叩きは必要
  • タイムキーパーは必要
  • 発表者は案外集めづらい

あと、今後も社内勉強会を定期的に開催するようにしたいと思っています。 ゆくゆくは社外の人も参加できる形にしていきたいと考えており、他社がどのようにして勉強会を企画・開催しているのかとか、エンジニア勉強会をやる文化を根付かせるためにどんな試みをしているのかとかを調査しています(有識者の方がいればコメントくださると幸いです)。

Scrapbox使うとよさげ?

発表スライドをScrapboxのプレゼンテーションモードで作ると手軽でいいんじゃないかなと思います。実際にhiroqnさんはスライドをScrapboxで作っていました。

f:id:pf_siedler:20190815142240p:plain
Scrapboxのプレゼンテーションモード。「あなたとJAVA,今すぐダウンロード」っぽい改行が入るのはご愛嬌

パブリックプロジェクトで作ればそのまま公開もできます。今回の発表資料も↓で公開しています。

このScrapbox projectですが、スライド以外にも弊社で使っているフレームワークや技術の解説もまとめています。題してHERP-Technoteです。 今年6月頃からすこしづつ書き始めて現在50以上のページができています。 今後も記事を増やしていく予定なので読んでくださると嬉しいです。

scrapbox.io

まとめ

  • 勉強会やっていきたい
  • Technoteもちょくちょく更新されていくので良かったら見てください

*1:学生インターンと業務委託を含む

FuseBoxというイケてるmodule bundlerがあるらしい

FuseBoxというmodule bundlerがイケてるらしいので使ってみた github.com

どこらへんがイケてるのか

公式サイトGitHubのREADMEによると以下のような利点があるらしい。

  • 早い
  • デフォルトでTypeScriptやJSX記法に対応している
  • devサーバー内蔵
  • HMR(hot module replacement)がサポートされている
  • configが楽

実際に使った感想としては、configファイルが簡素な点、で専用のCLIを使わない点、後述のWebIndexPluginなどが主な魅力だと思われる。

実際に使ってみた

実際に使ったソースコードこちら

以降は、実際に書いたコードを提示しながら*1、FuseBoxの使い方を説明する。 「ただbuildするだけ→devサーバーを使う→pluginを入れる」のように後半になるにつれて高度な機能の説明になっていく。

とりあえずbuild

まず適当なtsファイルをbuildしてみる。src/ディレクトレリを作って、その中にindex.tsという名前で以下のソースコードを保存する。

// index.ts

const msg: string = "Hello World";
console.log(msg);

次にyarnを初期化して、fuse-boxtypescriptをインストールする。

$ yarn init
$ yarn add -D fuse-box typescript

READMEに習って以下のようなconfigファイルを書き、fuse.jsの名前で保存する。

// fuse.js
const { FuseBox } = require("fuse-box");

const fuse = FuseBox.init({
  homeDir: "src",
  output: "dist/$name.js",
  sourceMaps: true,
});

fuse.bundle("app").instructions("> index.ts");

fuse.run();

fuse.bundle()ソースコードを一つのjsファイルにまとめるコマンドで あり、.instructions()でビルドの起点になるファイルを指定する(webpack.config.jsでいうところのentry)。

設定ファイルを記述したら、以下のコマンドを実行する。FuseBoxはwebpack-cliの専用のCLIコマンドは用意されておらず、configファイルをそのまま実行する形式を取っている(npm i -gしたくない派の人喜びそう)。

node fuse

すると、dist/ディレクトリが作られ、中にビルド結果がapp.jsapp.js.mapの名前で出力される。

ちなみに今回のように、tsconfig.jsonを作らずに実行すると、src/以下に自動生成してくれる。 targetbaseUrlなどの設定項目はfuse.jsに書いた通りにしてくれるのでちょっと便利。

devサーバーを動かす

fuse.jsにdevサーバーを立てる設定を追記する。 ついでに出力されるjsとsource mapをブラウザ向けのものにしておく。

// fuse.js
const { FuseBox } = require("fuse-box");

const fuse = FuseBox.init({
  homeDir: "src",
  output: "dist/$name.js",
  target: "browser@es5", // ブラウザ向けのコードを吐かせる。ついでにバージョンもes5に指定
  sourceMaps: {vender: true}, // ブラウザ向けのsourceMapを出すようにしておく
});

// devサーバーの設定
fuse.dev({
    port: 8888 // port番号。デフォルトは4444。
    root: 'dist' // devサーバーのルートディレクトリ。デフォルトはFuseBox.init()の`output`で指定したディレクトリ。
});

fuse.bundle("app")
    .instructions(`> index.ts`)
    .watch() // tsを書き換えるたびに差分コンパイルを行う
    .hmr(); // hot module replacementを有効にする

fuse.run();

ES5で出力するようにしたので、index.tsもES6以降の機能を使ったやつにしてみる。

enum LogLevel {
    Info,
    Error,
}
type Message = { type: LogLevel; message: string };

const showMessage = (m: Message): string => {
    switch (m.type) {
        case LogLevel.Info:
            return `📢 ${m.message}`;
        case LogLevel.Error:
            return `⚠️ ${m.message}`;
        default:
            throw new Error("invalid message");
    }
};

const hello: Message = { type: LogLevel.Info, message: "Hello World" };
document.querySelector("body").innerHTML = showMessage(hello);

最後にdist/index.htmlを作成すれば準備完了。

<!-- index.html -->

<body>
  <div id="app"></div>
  <script src="/app.js"></script>
</body>

node fuseを実行し、localhost:8888にアクセスすると「📢 Hello World」が表示される。 ついでにapp.jsを確認すると、ES6やTypeScript特有の記法がES5に変換されていることが確認できる。

// app.js の抜粋
var LogLevel; // TypeScriptのenumで書いた箇所
(function (LogLevel) {
    LogLevel[LogLevel["Info"] = 0] = "Info";
    LogLevel[LogLevel["Error"] = 1] = "Error";
})(LogLevel || (LogLevel = {}));
var showMessage = function (m) { // ES6のarrow関数で書いた箇所
    switch (m.type) {
        case LogLevel.Info:
            return "\uD83D\uDCE2 " + m.message;

WebIndexPluginでindex.htmlを自動生成

前章ではindex.htmlを手動でdist/に置いた。しかし、dist/は自動出力専用にして、手書きのコードは他のディレクトリで管理したいという要求もあるだろうと思われる(私はそうしたい)。 そこで、WebIndexPluginというものを使ってそれを実現する。

まず、src/public/template.htmlを作る。

<!-- hemplate.html(Emmetのテンプレをちょっと書き換えただけ) -->

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>My Page</title>
</head>
$css<!-- ここにstyleタグが挿入される -->
<body>
  <div id="app"></div>
  $bundles <!-- ここにscriptタグが挿入される。設定次第で、src=""で別ファイルから読み込むかinlineか選べる -->
</body>
</html>

fuse.jsプラグインの設定を追加する。

// fuse.js

const { FuseBox, WebIndexPlugin } = require("fuse-box"); // WebIndexPluginもimportする

const fuse = FuseBox.init({
  homeDir: "src",
  output: "dist/$name.js",
  target: "browser@es5",
  sourceMaps: {vender: true},
  plugins: [WebIndexPlugin({ template: "src/public/template.html" })], // これを書き足す
});

実行するとdist/index.htmlが自動生成される。ちなみに、WebIndexPluinの引数でtemplateを指定しない場合、適当なhtmlファイルを自動生成が作られる。便利。

まだまだあるぞ

まだ色々紹介したい機能があるが、長くなったので記事を分けたい。 残っているネタは以下の通り。

  • AltCSSを使ってみた
  • JSXを使ってみた
  • fuse-box/sparkyで静的ファイルのコピーをしてみた

ついでにwebpackあたりとの比較もやってみたい(未着手)。

ただ、7月17日時点で、依存ライブラリに脆弱性が見つかっているようなので本格的に使うなら次のバージョンを待った方がよさそう*2f:id:pf_siedler:20190717180724p:plain

*1:清書の段階で修正した箇所もあるためgitのlogと不整合な箇所もある

*2:version 4の準備が進んでいるらしく、hot fixするのかv 4公開のタイミングで直すのかよくわからない。7月17日時点でdependencyのバージョンを上げるissue/PRは出てないのでcontributionチャンスかも?

弊社でCycle.jsに寄付した話

Open Collectiveを使って業務で使っているOSSライブラリに寄付をした話をする。

Cycle.js

会社でCycle.jsというフロントエンドフレームワークを使っている。

github.com

Cycle.jsはステート管理やをひとつの関数として実装して、 ブラウザ上の操作やHTTP通信などの"外界"の動作を関数外の処理に任せるというアーキテクチャを採用している。

f:id:pf_siedler:20190712162458p:plain
Webアプリはブラウザ操作やHTTPレスポンスを受け取り、ビューやHTTPリクエストを吐く関数である

弊社プロダクトのフロントエンド部分はほとんどCycle.jsで実装しており、大変お世話になっている。 そこで、Cycle.jsのOSSコミュニティに資金援助をしよういう話が社内であがった。

Open Collective

Cycle.jsはOpen Collectiveというサイトで寄付金を受け付けている。

opencollective.com

Open Collectiveは投げ銭サービスの一種で、主にGitHubOSSコミュニティ向けに作られている。

寄付する側での会員登録、寄付の方法を説明する。

会員登録

個人で会員登録をする場合、メールアドレスと名前を入力するだけで登録できる。 パスワード入力は不要で、登録メアド宛に送られてくるmagic linkを開くことでログインする。*1

法人として登録する場合、上記に追加して

  • 団体名
  • WEBサイトURL
  • GitHub Organizationの URL(任意)
  • Twitter ID(任意)

の入力を求められる。 GitHub OrganizationやTwitter IDを入力しておくと、団体のアイコンを自動で取得してくれるみたいなのでちょっと便利。

お支払い

次に寄付の方法を説明する。

まず寄付先のコミュニティのサイトを開き、「コントリビュート」のボタンをクリックする。

すると、コミュニティが指定した寄付のプランが表示される。 ここでプラン通りに寄付をしても良いが、ページ下部の「Or make a custom financial contribution」を選んで自分で寄付額を決めることもできる。

f:id:pf_siedler:20190712163138p:plain

自分で寄付額を決める場合、金額と寄付の頻度(1回、毎月、毎年の3パターン)を入力する。

f:id:pf_siedler:20190712163314p:plain
ちなみに通貨単位がユーロ建てになっているコミュニティもある。寄付金を決める際は為替を要チェック

寄付金の支払い方法は以下から選べる。

  • クレジットカード決済
  • PayPal*2
  • 銀行振込

銀行振込で海外送金するのは色々大変そうなので、実質クレカとPayPalの二択になると思われる。

実際に寄付してみた

社長にお願いしたら1000ドルぽんと出してくれた :sasuga_shacho:

そして公式サイトに載った。

f:id:pf_siedler:20190712163708p:plain

さいごに

Cycle.jsはかなりイケてるフレームワークなので、ガンガン開発してほしいですね。

herp.careers

herp.careers

*1:つまり、メールを受け取れる人が本人という形で本人確認を行っている。

*2:寄付先の団体が米ドル建てで寄付を受け付けている場合のみ?

開拓馬 バージョン 24.0.0を公開しました

24歳の誕生日なので去年からの差分をリリースノートっぽく書いた

  • 自力で生活費を稼ぐ機能を追加しました
  • TypeScriptを扱う機能を追加しました
    • Cycle.jsでフロントエンドを実装する機能を追加しました
    • stylusやpugを使ってある程度の静的サイトを記述する機能を追加しました
    • TypeORMを使ってDBにアクセスする機能を追加しました*1
    • fp-tsio-tsで素のTSより型安全なコードを書く機能を追加しました
    • circleCIのconfigを書く機能を追加しました
    • Jest, Mocha, TypeMoqを使ってテストコードを書く機能を追加しました
    • 稀にDefinitelyTypedにPRを出す機能を追加しました
  • 余剰資産を投資信託で運用するように変更しました from Folio
  • 研究意欲がないのに大学院に行ってしまう不具合を修正しました
  • レーニングジムに週1~2回通いヘルスケアに勤しむ機能を追加しました
  • コーヒーの好みを苦味が強いものから酸味の強いものに変更しました from LIGHT UP COFFEE
  • 認知行動療法を導入することで自己肯定感が急激に低下する不具合を解消しました from デビット・D・バーンズ
    • 何もしていないときに焦りを覚える現象が発生しづらくなりました
    • 根拠の無い自己肯定が有効であるという認識を導入しました
    • 「人間は何を成し遂げたとしても死んで無に帰す」という世界観を持つようになりました from 中島義道*2, ショーペンハウアー, シオラン
    • ↑を「優れた洞察力を持つ」と言い換ることでペシミズムをある種の長所と捉えるようになりました
  • 引っ越しによりロード時間*3を短縮しました
  • 食洗機を利用して時短家事を行うようになりました(めっちゃ便利)
  • 図書館を利用することで読書量を増やしました
    • web予約機能を利用するようになりました(イマドキの図書館は便利ね)
    • 蔵書リクエストを出すようになりました
  • Nintendo Switchで遊ぶようになりました from Nintendo
    • 昨年11月に購入してから400時間ぐらい遊んでる
  • スマホゲームに手を出すようになりました
  • SEKIROクリアしました*5
  • 欲しいものリストで誕生日プレゼントをねだる機能が追加されました

*1:機能を追加してません。昨日TypeORMでmysqlのdockerイメージぶっ壊したので

*2:中島氏の本は14歳の頃に必死で読んでいたが、大人になってまた違う形で解釈できるようになったような気がする

*3:通勤時間の意味

*4:DOTA2 Mod版は操作性がいまいちなのであんまりやってない

*5:一週目不死断ちエンドのみ。50時間ぐらいかかった