Puppeteer を用いたアプリケーションから Chrome を切り離す
Puppeteer を用いた web スクレイピングアプリケーションの起動中に CPU/memory 使用量が荒ぶるので何とかしたいなあと思って最近対策を考えています
Puppeteer は内部で Chrome (Chromium) を起動しているので、スクレイピングのロジック部分と Chrome を分離すればリソース割り当てがやりやすくなりそうなので挑戦してみます
書いたコードはこちら↓*1
実験台
ここに 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 でリファクタリング
- Node.js 部分と Chrome を別々の Docker image にして docker compose で動かす
- kubernetes 環境で動かす
をやっていきます
Puppeteer-core
puppeteer-core は Chromium が同梱されていない計量版の Puppeteer です
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.launch
を puppeteer.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 で 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 の配置に改善できる
- docker build が楽になる
まだ構想段階なので、勤務先*5のアプリケーションで実際に運用してみて使用感を確かめたいです*6
謝辞:Chrome 分離構想を提案してくれた id:hiroqn さん id:taketo957 さんに感謝
MySQL しか使ったことないアプリケーション開発者が PostgreSQL の Docker 環境を立てるまで
最近仕事で PostgreSQL を使うことになったので、忘備録としてDocker で環境構築して一通り触ったメモを、主に MySQL との差分に注目しながら書きます。
PostgreSQL の Docker image
PostgreSQL は公式の docker image が公開されています
基本的に ${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
PostgreSQL は psql
という対話型 のクライアントが用意されています。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_table
で test_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 にはスキーマが存在せず二層であり、このあたりが両者の大きな相違点なのかなと思います。
デフォルトでは public
という名前のスキーマが用意されており、クエリが省略されているときは自動で補完されます。
public スキーマのみを使用すれば MySQL とだいたい同じ使用感になると思われますが、 MySQL では test_db.test_table
と書けたところが PostgreSQL では test_db.public.table
になる点は注意したいですね。
おわりに(日記)
本稿では、 PostgreSQL の Docker image を構築し、一通り触ってみました。
余談ですが、ポスグレを使うことになった経緯について少し書きます。
そこそこ大きな Node.js 製アプリケーションの一部をマイクロサービス化しつつちょっと新機能を追加する試みを進めており、その一環で私は現在 TypeScript で小さめのアプリケーションを作っています。 既存アプリケーションからのデータの移行等はなく、データベースは新規に作ったものを使うことになりました。 ある程度プロトタイプの開発を進めて方向性が決まった段階で、データベースの選定について SRE に相談したところ、 PostgreSQL を使うことになりました。 弊社ではこれまで RDB は MySQL を使っていましたが、 PostgreSQL の知見を貯める意味で試験的にやってみよう❗という感じです。
当初は前例に習って MySQL を使うつもりで進めていましたが途中で postgreSQL に切り替えるという状況になったのですが、データベースの操作は ORM ライブラリの Prisma*3 を使用しており、設定ファイルをちょっと書き換えるだけで、アプリケーションの実装はほぼそのままで移行が住みました。
以上、 ORM って便利ですねという日記でした。
この記事は業務時間中に書きました。 仕事の一貫なので弊社の宣伝を
↓弊社開発チームメンバーのブログ記事集です↓
↓我々と一緒に働きたい人を募集中です↓
参考資料
Protobuf を SQS のスキーマ管理に使ってみる
Amazon SQS のスキーマ管理を Protobuf でやってみました。
サンプルコードはこちら。
.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 の準備ができたので、やっと実装が始まります。
今回は TypeScript
と Protobuf.js
を用いて adapter を実装します。
Protobuf.js
はその名の通り Protobuf を取り扱うライブラリです。あくまでも Protobuf を扱うライブラリなので、 gRPC 関連の機能ほぼサポートされていませんが、これはむしろ今回の用途に適っているので好都合。
gRPC の文脈では @grpc/proto-loader
と組み合わせて dynamic codegen ((実行時に .proto
ファイルを読み込む方式))を行う際によく使われているライブラリです。
しかし、今回は Protobuf.js
が提供する CLI ツール pbjs
と pbts
を用いて 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で作っていました。
パブリックプロジェクトで作ればそのまま公開もできます。今回の発表資料も↓で公開しています。
このScrapbox projectですが、スライド以外にも弊社で使っているフレームワークや技術の解説もまとめています。題してHERP-Technoteです。 今年6月頃からすこしづつ書き始めて現在50以上のページができています。 今後も記事を増やしていく予定なので読んでくださると嬉しいです。
まとめ
- 勉強会やっていきたい
- Technoteもちょくちょく更新されていくので良かったら見てください
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-box
とtypescript
をインストールする。
$ 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.js
、app.js.map
の名前で出力される。
ちなみに今回のように、
tsconfig.json
を作らずに実行すると、src/
以下に自動生成してくれる。target
やbaseUrl
などの設定項目は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日時点で、依存ライブラリに脆弱性が見つかっているようなので本格的に使うなら次のバージョンを待った方がよさそう*2。
*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というフロントエンドフレームワークを使っている。
Cycle.jsはステート管理やをひとつの関数として実装して、 ブラウザ上の操作やHTTP通信などの"外界"の動作を関数外の処理に任せるというアーキテクチャを採用している。
弊社プロダクトのフロントエンド部分はほとんどCycle.jsで実装しており、大変お世話になっている。 そこで、Cycle.jsのOSSコミュニティに資金援助をしよういう話が社内であがった。
Open Collective
Cycle.jsはOpen Collectiveというサイトで寄付金を受け付けている。
Open Collectiveは投げ銭サービスの一種で、主にGitHubでOSSコミュニティ向けに作られている。
寄付する側での会員登録、寄付の方法を説明する。
会員登録
個人で会員登録をする場合、メールアドレスと名前を入力するだけで登録できる。 パスワード入力は不要で、登録メアド宛に送られてくるmagic linkを開くことでログインする。*1
法人として登録する場合、上記に追加して
の入力を求められる。 GitHub OrganizationやTwitter IDを入力しておくと、団体のアイコンを自動で取得してくれるみたいなのでちょっと便利。
お支払い
次に寄付の方法を説明する。
まず寄付先のコミュニティのサイトを開き、「コントリビュート」のボタンをクリックする。
すると、コミュニティが指定した寄付のプランが表示される。 ここでプラン通りに寄付をしても良いが、ページ下部の「Or make a custom financial contribution」を選んで自分で寄付額を決めることもできる。
自分で寄付額を決める場合、金額と寄付の頻度(1回、毎月、毎年の3パターン)を入力する。
寄付金の支払い方法は以下から選べる。
銀行振込で海外送金するのは色々大変そうなので、実質クレカとPayPalの二択になると思われる。
実際に寄付してみた
社長にお願いしたら1000ドルぽんと出してくれた :sasuga_shacho:
そして公式サイトに載った。
さいごに
Cycle.jsはかなりイケてるフレームワークなので、ガンガン開発してほしいですね。
開拓馬 バージョン 24.0.0を公開しました
24歳の誕生日なので去年からの差分をリリースノートっぽく書いた
- 自力で生活費を稼ぐ機能を追加しました
- TypeScriptを扱う機能を追加しました
- 余剰資産を投資信託で運用するように変更しました from Folio
- 研究意欲がないのに大学院に行ってしまう不具合を修正しました
- トレーニングジムに週1~2回通いヘルスケアに勤しむ機能を追加しました
- コーヒーの好みを苦味が強いものから酸味の強いものに変更しました from LIGHT UP COFFEE
- 認知行動療法を導入することで自己肯定感が急激に低下する不具合を解消しました from デビット・D・バーンズ
- 引っ越しによりロード時間*3を短縮しました
- 食洗機を利用して時短家事を行うようになりました(めっちゃ便利)
- 図書館を利用することで読書量を増やしました
- web予約機能を利用するようになりました(イマドキの図書館は便利ね)
- 蔵書リクエストを出すようになりました
- Nintendo Switchで遊ぶようになりました from Nintendo
- 昨年11月に購入してから400時間ぐらい遊んでる
- スマホゲームに手を出すようになりました
- 最近のおすすめはAuto Chess*4, Brawl Stars, アイドルマスターシャイニーカラーズ
- SEKIROクリアしました*5
- 欲しいものリストで誕生日プレゼントをねだる機能が追加されました