GAミント至上主義

Web Monomaniacal Developer.

自作パッケージをnpmに公開&GitHub Actionsで自動化する

趣味で開発しているPuppeteerを使ったPDFサーバーhc-pdf-serverで一部分をnpmにパッケージとして公開しています。

これ↓
www.npmjs.com
FastifyからPuppeteerを通して、Headless ChromeのPageを使用できるプラグインです。
名前の通り速いFastifyの長所を殺すこと無く、起動しておくPageを増やすことで大量リクエストもさばけそうな気がします(実戦経験はない)。
PDFだけでなく、スクリーンショットとか、JavaScriptでできたHTMLを取得したりいろいろできるよ!


Puppeteerとの連携部分はもともとFastifyのプラグインとして作っていたこともあり、
また超絶頭のいいと思われる見知らぬプログラマからすばらしいプルリクを頂いて強いプラグインとなったという理由もあり、
次もPuppeteer + Fastifyで何か作ろうと思っているのもあり。


で、npmにパッケージ公開してそれを使ってみたら、いろいろ世界の見え方変わった気がしたのでかなりおすすめです。基本無料です。

細かい説明は他にあるので、ざっくり流れを忘れないようにメモ。

レポジトリを分ける

既存のアプリケーションからの分離なのでまずはこれでした。
必要最低限なファイルや設定で新しいレポジトリをつくり、package.jsonに必要な情報を書きます。

この記事を参考にしました。
qiita.com


nameには最初でグローバルを汚すのも申し訳ないのでscopedと呼ばれる@uyamazak/をつけました

package.json

"name": "@uyamazak/fastify-hc-pages",

qiita.com

TypeScriptのコードで、jsはレポジトリに含まれていないため、prepublishOnlyでビルドするようにしました。

package.json

  "scripts": {
    "build": "rm -rf ./dist && tsc",
    "prepublishOnly": "npm run build",

TypeScriptでnpmライブラリ開発ことはじめ - Qiita


あとmainのパスをちゃんとやったりぐらいかな?

ここらへんで普段意識していなかったpackage.jsonの項目を理解し、意識できるようになった気がします。

GitHubからインストールして動作確認

それっぽいものができたら、npmjsで公開する前に自分のGitHubレポジトリにプッシュしてそこからインストールして試します。

yarnでGitHubのレポジトリからインストールを初めて使いました。

$ yarn add https://github.com/uyamazak/fastify-hc-pages

package.jsonにこんな感じで入ります。
バージョンの数字の代わりにURLが入るんだなぁ。ブランチとかの指定もできそう

  "dependencies": {
    "@uyamazak/fastify-hc-pages": "https://github.com/uyamazak/fastify-hc-pages",
    "fastify": "^3.13.0",

型定義が吐き出されずに困ってましたが、原因は拡張子を.d.tsにしてたため、というしょうもないミスでハマってました。

ここらへんでnpmパッケージがどうインストールされるかとか、かなり理解できた気がします。

npmjsに登録したり、ローカルでログインしたり、READMEとか準備

まあ普通にやるので割愛。

GitHubに吸収されて1年経つので、アイコンはGitHubの使えるのかと思ったら・・・
Gravatarェ・・・・。

太古に作ったWordPressアカウントがあった気がするけど面倒なのでそのまま。

www.publickey1.jp

publish !

準備ができたらpublishします。公開パッケージなのでpublicが必要です。

npm publish --access public

ここでやらかしましたが、パッケージ名が他のFastifyプラグインと違うため、変更したくなりました(最後のpluginいらなかった・・・)

でも一度公開してしまうと基本的に名前の変更、削除はできないため、新しく作って、古いのはdeprecateして放置する必要があります。
以前消えて問題になったことあるししょうがない。

↓殘骸
@uyamazak/fastify-hc-pages-plugin - npm


改めてパッケージ名を変更し、このページができました。
www.npmjs.com

自動化

それからしばらくバージョンアップのたびに手動でやってましたが、ローカルで操作するの面倒ですね。
自動でできるようにします。

npmとは別のGithub Packagesの説明が入っていて邪魔ですが、こちらを参考にしました。
docs.github.com

npm側でトークンを作り、GitHubのSecretsに入れるNPM_TOKENの設定が必要ですが、yamlは最終的にはこんな感じ。

.github/workflows/npm.yml

name: Node.js Package
on:
  release:
    types: [created]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2
    # npmに公開する.npmrcファイルを設定する
    - uses: actions/setup-node@v1
      with:
        node-version: '14.x'
        registry-url: 'https://registry.npmjs.org'
    - run: npm install
    # npmに公開する
    - run: npm publish --access public
      env:
        NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

タイミングは、いつものマージ、プルリクなどではなく、GitHubのReleaseという機能を使います。

ここから、タグにバージョンを入れてリリースを作成すると、自動で公開されるようになりました。
f:id:uyamazak:20210318171326p:plain

リリースの作成もcommitメッセージを集めてきたり、ある程度自動化できそうですが、しばらくはこのままでいいかな。

ここらへんで、他のレポジトリがどうnpmのパッケージをリリースしているのかを理解できた気がします。

開発を続ける

で、しばらく開発をすすめると、プラグインを更新するたびにそれを利用している元本体側も変更が必要など手間が増え、作業時間は増えてしまいます。

が、そのおかげでユーザーとしての気持ちも持つことができるので、変更する際には少し慎重になり、いい設計、コードにしたいという気持ちが強くなった気がします。

でも趣味なので作業時間はプライスレス。

おまけ: npmのWeekly Downloadsについて

ここのダウンロード数、自分しか使ってないので結構行ってますが、このレポジトリと元のレポジトリのGitHub Actionsで回しているテスト時のダウンロードもカウントされてるようです。
そのため自動でdepandabotがつくるプルリク×3環境×2Node.jsバージョン分だけこの数が増えていくことに。

f:id:uyamazak:20210318212045p:plain

1人しかユーザーいなくてもCIなどのDLが含まれて200近く行ってしまうということで、他のパッケージのダウンロード数もそこまで参考にならないかなぁという気がします。


Google Apps ScriptとNuxtJSで簡易来客受付システムをつくったら実質サーバーコスト無料だった

シニアジョブのオフィスの引越にあたり、セキュリティの面からも来客の記録システムが必要になりました。

最初すでにいろんな会社が作ってると思ったので、それ使えばいいじゃんと思いましたが、仕様を聞くとたしかに超簡易的なものなのでまあいいかということで作りました。
とりあえず一通り動くまで2,3日、あとCIとかの環境まわり、デザイナーさんの工数とか入れたら1週間ぐらい?

できたもの

iPadでこんな感じに表示されていて
f:id:uyamazak:20210309171400p:plain

名前とかいれて呼び出しすると「ピンポーン」という音がして
\ピンポーン/
f:id:uyamazak:20210309171503p:plain

スプレッドシートに記録されて
f:id:uyamazak:20210309171555p:plain

Chatworkにも送信されます
f:id:uyamazak:20210309171633p:plain

システム的には

  1. Google App Engineで公開されたNuxtJSアプリ(静的ファイル配信のみ)
  2. GASウェブアプリ(REST API的なもの)で来客リクエスト受け付け + iPadでピンポーン鳴らす
  3. GASでスプレッドシートにログ書き込み & Chatworkにメッセージ送信
  4. 備え付けたiPadでこれを開く

だけのシンプルな構成です。

iPadの選定

まずこれから頼まれた。
生まれてからMac製品買ったこと無い人間なので、いろいろ調べてこんな条件で中古で選んで買ってもらいました。
3万ちょっとだった気がする。

  • 新しめ(2019)
  • 大きめ(10.2インチ)
  • WiFiで厚さとかストレージ気にしない

Raspbery Pi + タッチディスプレイも考えたけどめんどいよね。

フロント側選定

他でも使ってるしNuxtJSでいいよね。UIも大してないので定番のVuetify
いざとなったらサーバー側の処理もできるし(結局GASで済んだのでいらなかった)

インフラ選定

NetlifyとかVercelとか便利だけど、スプレッドシートへの書き込みなので、Google内が良さそう。
常時起動の必要はなく、必要なときだけ起動するGoogle App Engine(スタンダード環境)を選びました。
NuxtJSで普通に作ってローカルで動かしたあとapp.yaml作るだけでデプロイ出来ました。

休日開けとかの起動時間がちょっとネックだけど10秒はいかない感じなので今の所常時起動とかはまあいいかなと。

おそらく無料枠に収まるので実質無料では?

サーバー側選定

この記事で詳しく書いたけど結局サーバーサイド的な処理はGASのウェブアプリだけで完結していましました。
もともとGoogle Workplaceはつかってるので実質無料では?
uyamazak.hatenablog.com

CI、デプロイ

これもGitHubのmainブランチへのプッシュをフックにGCPのCloud Buildを設定しました。
権限周りでちょっとハマったけど、無料枠以上デプロイする気がしないので実質無料では?
uyamazak.hatenablog.com

小技集

ピンポーン鳴らす

もしネットが切れてるときにお客さんが操作するとただエラー画面が表示されるだけで、Chatworkにもスプレッドシートにも飛ばないので人をイラだたせる板になってしまいます。
そこでエラー時にも音出せば原始的なチャイムとして機能する?と思ってつけました。
音量最大にしてオフィスの中聞こえるかはまだ新オフィス行ったこと無いのでわかりません。

音はmp3などでネットで無料でライセンス的にも問題なさそうなのを拾ってきます。
ブラウザで音鳴らすの随分久しぶりな気がしましたが、この関数だけで動きました。
リモートなので実機テストはデザイナーの方に頼みましたが問題なかったそう。

ping-pong.ts

let audio: HTMLAudioElement

export const pingPong = () => {
  if (!audio) {
    audio = new Audio('pingpong.mp3')
  }
  audio.pause()
  audio.currentTime = 0
  audio.play()
  console.log('pinpong')
}

エラーページ出たらしばらくしたらトップにもどる

ネットワーク等なにかの問題でエラー画面がでてそのままだったらうざいので、自動でトップに戻るようにしました。

せっかくなので新しいcomposition-apiプラグイン的に作りました
手で戻った場合に備えてonUnmountedでキャンセルするのも忘れない。

plugins/setup-auto-back-top

import { onMounted, onUnmounted, useRouter } from '@nuxtjs/composition-api'

export const setupAutoBackToTop = (timeout: number) => {
  return () => {
    const router = useRouter()
    let timeoutId: number
    onMounted(() => {
      timeoutId = window.setTimeout(() => {
        router.push('/')
      }, timeout)
    })
    onUnmounted(() => {
      window.clearTimeout(timeoutId)
    })
  }
}

これをエラーページのテンプレートでsetupに時間(ミリ秒)を付けて入れるだけ。
15秒でトップに自動で戻ります。

error.vue

<script lang="ts">
import { defineComponent } from '@nuxtjs/composition-api'
import { setupAutoBackToTop } from '@/plugins/setup-auto-back-top'

export default defineComponent({
  setup: setupAutoBackToTop(15000),
})
</script>

PWAでiPadにインストール&アドレスバー無しを実現する

そのままだとアドレスバーとか出ててダサいのでアプリっぽく動くようにします。
といってもNuxtJSのモジュール入れて、アイコンとか設定するだけ。

pwa.nuxtjs.org

これでiPadにインストールすれば、アプリっぽくなります。
iPad手元にないので画像はない。

IP制限でオフィスからだけ見れるようにする

App Engineということでお気づきの方はいるかもしれませんが、グローバルに公開されちゃいます。
知らない国の知らない人に来客通知送られても困るので制限します。

メニューのファイアウォールで、デフォルト拒否にして、許可するIP追加するだけなので簡単。
ファイアウォールによるアクセスの制御  |  Python 2 の App Engine スタンダード環境  |  Google Cloud

ベーシック認証とかも考えたけど、認証切れたりした画面をお客さんに見せたくない&新オフィスは固定IPがあるのでIP制限にしました。

そういえばApp Engineの選定理由にこれもありました。

あともし同じIPを使われてURLを知っていて悪意がある人がいても、できるのはスプレッドシートへのログ書き込みとChatwork通知だけで、個人情報漏洩とかの心配はなかったものあり。
なにかあってもすぐGASのウェブアプリを止めればOK。


たぶん実運用が始まってからいろいろと問題が出ると思いますが、ご来社の際は触ってみてください(画面消毒用のアイテム用意しないと)

Laravelの操作ログをGCPのDataflowテンプレートを使ってBigQueryに記録する

経緯

シニアジョブの人材管理システムで、もしもの時のセキュリティのために、すべての操作ログを記録することにしました。
Dataflowのテンプレートを使うことでちょっとのコードでできてしまったので、コードにならない部分の記録をするため雑にメモ。
記事中のコードは適宜はしょってるので動きません。

ElasticsearchではなくBigQueryにした理由

最初すでにElasticsearchを使っていることもあり、そちらに保存しようと思ったけど、ESの設定や維持管理、保存のためにも結構コードを書く必要もあり、
また見る際もいろいろコードを書くか、GUIのツールを使わないといけないので大変だなぁと思っているところで、以前使ったDataflowのテンプレートを思い出しました。

Google 提供のストリーミング テンプレート  |  Cloud Dataflow  |  Google Cloud

これを使うとPub/SubにBigQueryテーブルに合わせたJSONを送るだけでいろいろいい感じにインサートしたり、失敗した時はエラー用のテーブルに記録してくれたりします。
Pub/Subをかましているので、急激な負荷の変化をある程度吸収したり、BigQuery側が落ちててもリトライとかもいい感じにやってくれるはず。

これを使うことでいわゆるサーバーレス、マネージレス的に設定だけでGCP側は完了します。

LaravelのMiddlewareを作る

まずはログに必要なリクエストごとにJSONを送る仕組みをLaravel側につくります。
Middlewareを使うのが良さそうですね

使ってるLaravelのバージョンはまだ7です(もう8出てるんだ)。

Middleware - Laravel - The PHP Framework For Web Artisans


処理のタイミングがいくつかあるんですが、レスポンス終了後に行ったほうが、ユーザーへのページ表示が遅くならないので良さそうです。
ということでterminate()を使います。

大きすぎるPOSTは省略したり、内容をいい感じに整理するのに試行錯誤したけどこんな感じ。
normalize()は状況によって違うので省略するけど、ここでいい感じにしてます。

    /**
     * レスポンス速度の影響を防ぐため終了後にログを送信する
     * @param  \Illuminate\Http\Request  $request
     * @param  \Illuminate\Http\Response  $response
     * @return void
     */
    public function terminate($request, $response)
    {
        // query()とpost()の内容が重複することが多いのでマージしたものをpayloadとして保存する
        $query = $this->normalize($request->query());
        $post = $this->normalize($request->post());
        $payload = array_merge($query, $post);
        $logContent = [
            'timestamp' => Carbon::now('UTC')->format('Y-m-d H:i:s.u'),
            'method' => $request->method(),
            'path' => $request->path(),
            'ip' => $request->ip(),
            'user_agent' => $request->userAgent(),
            'status' => $response->status(),
        ];
        $userId = $request->user()->id ?? null;
        if ($userId) {
            $logContent['user_id'] = $userId;
        }
        // 空配列JSONや文字列"null"をBigQueryに保存したくない
        if ($payload) {
            $logContent['payload'] = json_encode($payload, JSON_UNESCAPED_UNICODE);
        }
    }

BigQueryのタイムスタンプ型に入れるときは、特殊?なこの形式にするのは気をつけないといけません。あとは普通に文字列か数字。

Carbon::now('UTC')->format('Y-m-d H:i:s.u')

今回はCSVじゃないけどここに書いてるのと同じっぽい
Cloud Storage からの CSV データの読み込み  |  BigQuery  |  Google Cloud


しばらく$logContentの内容をログに出しながら動作確認して良さそうだったら、これに合わせてBigQueryのテーブルを作ります。
nullになる可能性があるpayloadとかuser_idとかはBigQuery側でnullableにしておきます。

Cloud Consoleでポチポチして作っておきます。IPも無いことはなさそうだからREQUIREDでいいかなと思ったりもしたけどそのまま。
f:id:uyamazak:20210304111839p:plain

ついでにPub/Subのトピックと、それを読むsubscriptionをポチポチして作っておきます。特に設定はない。

あと専用のサービスアカウントを作り、Pub/SubにPublishできる権限だけを作って、認証用JSONをダウンロードしておきます。

ここまでできたら、Pub/Subに送信します。
Pub/Subの処理は大したことしないけど、一応Middlewareと分けるためServicceとしました。
こんなの。

<?php

namespace App\Services;

use Google\Cloud\PubSub\PubSubClient;

class GcpPubSubService
{
    private $pubSubClient;
    private $topic;

    /**
     * @param string $jsonPath
     * @param string $topic
     * @return void
     */
    public function __construct(string $jsonPath, string $topic)
    {
        $this->pubSubClient = new PubSubClient([
            'keyFilePath' =>  $jsonPath
        ]);
        $this->topic = $this->pubSubClient->topic($topic);
    }

    /**
     * @param string $data
     * @return void
     */
    public function publish(string $data)
    {
        $this->topic->publish([
            'data' => $data,
        ]);
    }
}

こいつを使って上記terminateの最後でpublishします

$gcpPubSubService = new GcpPubSubService('保存した認証用JSONのパス', '作ったトピック名');
// 日本語見やすくするためJSON_UNESCAPED_UNICODE付けてる
$gcpPubSubService->publish(json_encode($logContent, JSON_UNESCAPED_UNICODE));

Dataflowのテンプレートでジョブを起動する

Pub/Subにエラーなく送れてそうな気がしたら、いよいよ本題のDataflowです。

「テンプレートからジョブを作成」を選んで`Pub/Sub Subscription to BigQuery`を選択します。
f:id:uyamazak:20210304112555p:plain

先程作ったPub/SubのサブスクリプションやBigQueryのテーブル名などを注釈どおりに入れます。

あと、ここで一時ファイル置き場として、Storageのフォルダが必要になりますね。
一時ファイルなので、可用性とかしらんので、安いusでregionalなバケットをつくり、tmpみたいなフォルダ作って指定しました。動作中みると空だけどいつ使うんだろう。
f:id:uyamazak:20210304113220p:plain

Dataflowのリソースをなるべくケチる

必須だけいれてポチっでも動いてしまいます。
しかし、多くても数十人が使う社内システムのログなのにCPU4とかHDD1.2TBとかメモリ15GBとか信じられないリソースが目に入ります。
ケチりたい。

ということで、Dataflowのこの隠されてる「オプションパラメータの表示」をクリックします
f:id:uyamazak:20210304113625p:plain

ここの設定は状況によって異なりますが、今回は平凡なマシン1台で十分だろってことで、
マシンタイプにn1-standard-1(ふつー)、
ワーカーの数も1、
MAXワーカーも1
で行きました。あとは空のまま。

MAXワーカー数が曲者で、デフォルトが3らしく、空のままいくと420GB×3のHDDを用意してしまいます。今回は絶対いらんやろと確信。

3度目の正直で、まあこんなもんかというリソースにできました。
f:id:uyamazak:20210304113952p:plain

デフォルトだったら1日うん十万かそれ以上のリクエストでもさばけそうですね

トラブルシューティング

Pub/Sub送れてるのにBigQueryに入ってこないなぁってなったら、BigQueryで作ったテーブルと同じデータセット

{yourtablename}_error_records

というテーブルが出来ているのでこちらを確認しましょう。
型違いとかカラム名違いとかで入れられなかった場合は、その情報をここに保存してくれます
この機能も超便利。

そもそもGCPが落ちてて送れない時は諦めてSlackにでも送りつけるしかないかな。

結果

あとは普通にBigQueryで簡単なSQLを実行するだけでログを確認することができるようになりました。
ビューアーはブラウザ(Cloud Console)でいいし、Googleアカウントで権限管理も簡単だし、
サーバー管理も不要だし、ほぼ1分以内に入ってくるのでリアルタイム性あるのがこんな簡単に作れるの嬉しいなぁ。


f:id:uyamazak:20210304114146p:plain