tag:crieit.net,2005:https://crieit.net/users/tubone24/feed tubone24の投稿 - Crieit Crieitでユーザーtubone24による最近の投稿 2022-04-26T23:06:22+09:00 https://crieit.net/users/tubone24/feed tag:crieit.net,2005:PublicArticle/18176 2022-04-26T23:06:22+09:00 2022-04-26T23:06:22+09:00 https://crieit.net/posts/K6-TypeScript-Docker 負荷テストツールK6をTypeScript+Dockerで動かすためのテンプレートを作る <h2 id="k6とは?"><a href="#k6%E3%81%A8%E3%81%AF%EF%BC%9F">k6とは?</a></h2> <p><a target="_blank" rel="nofollow noopener" href="https://k6.io/">k6</a> は<a target="_blank" rel="nofollow noopener" href="https://go.dev/">Go製</a>のOSS負荷テストツールで普通のHTTPの他、HTTP/2, gRPC, WebSocketなどにも対応した高機能なテストシナリオ作成がJavaScriptで実施できる手軽さと<a target="_blank" rel="nofollow noopener" href="https://grafana.com/">Grafana</a>を使ったパフォーマンスレポートが魅力の製品です。</p> <p>以前はloadimpactと呼ばれていました。こちらのほうが馴染みある人が多いのではないでしょうか?また、K6のクラウド版を使ったことありますー!という方もいらっしゃるのではないでしょうか?</p> <p>私個人としては、この手の負荷試験ツールは長らく<a target="_blank" rel="nofollow noopener" href="https://locust.io/">Locust</a>を愛用してきてそれなりに複雑なテストシナリオを作ってきた経験もあるのですが、時代の流れ的にこっちのほうがいいのではないかと思い浮気することにしました。</p> <h2 id="使い心地"><a href="#%E4%BD%BF%E3%81%84%E5%BF%83%E5%9C%B0">使い心地</a></h2> <p>K6をMacやEC2などで作ったLinux上に構築する方法はググればたくさん出てくるのでここでは割愛しますが、まず<strong>非常に簡単に環境が構築できる。</strong> この点はしっかり強調しておきたいです。</p> <p>MacならHomebrew入っていれば、</p> <pre><code>brew install k6 </code></pre> <p>で完了ですからね。環境構築は超簡単です。</p> <p>シナリオファイルの作成もJavaScript ES6対応&<a target="_blank" rel="nofollow noopener" href="https://k6.io/docs/">公式ドキュメント</a>比較的充実しているので、ある程度JavaScript触ったことのある人であればノーストレスで実装できそうです。</p> <p>また、細かいニーズのシナリオ作成については<a target="_blank" rel="nofollow noopener" href="https://github.com/grafana/k6/tree/master/samples">GitHubレポジトリのサンプルコード</a>がかなり参考になります。</p> <p>なので、正直普通に使う分にはこの記事で紹介することなんてあんまりないのです。ここまで読んでくれた皆さん、ごめんなさい。</p> <h2 id="ロマン"><a href="#%E3%83%AD%E3%83%9E%E3%83%B3">ロマン</a></h2> <h3 id="完全Dockerコンテナ上で実行"><a href="#%E5%AE%8C%E5%85%A8Docker%E3%82%B3%E3%83%B3%E3%83%86%E3%83%8A%E4%B8%8A%E3%81%A7%E5%AE%9F%E8%A1%8C">完全Dockerコンテナ上で実行</a></h3> <p>とはいえ、これで終わりならわざわざブログを書くこともないので早速ちょっとした改良をしていきます。</p> <p>まず、完全Dockerコンテナ上での実行です。</p> <p>開発チームの皆さんがMacを使っている会社さんなら不要な気もしますが中にはWindowsやLinuxを使っている人が混在している開発現場もありそうで、そういった現場で「はい。K6入れておいてね」はちょっと不親切です。かといって、全OSのマニュアルを用意するのもつらいです。</p> <p>また、負荷テストはネットワークの安定したサーバー上で実行することもしばしばでMacで動くのに負荷テスト用サーバーで動きません!みたいになるのも悲しいです。そもそも専用のサーバーを立てるのもめんどくさいのでDocker Image化してDockerコンテナ管理サービスでサクッと実行させるのが賢そうです。なんならCIに組み込んで、パイプライン上で実行させるとかも面白そうです。</p> <p>もちろんK6公式もぬかりなく<a target="_blank" rel="nofollow noopener" href="https://github.com/grafana/k6#docker">Docker Image</a>を用意してます。ただ、あくまでもこちらのImageはk6単体のImageなのでパフォーマンス可視化をするには別途<a target="_blank" rel="nofollow noopener" href="https://www.influxdata.com/">InfluxDB</a>と<a target="_blank" rel="nofollow noopener" href="https://grafana.com/">Grafana</a>を立ち上げる必要があります。</p> <p>なので、まとめて環境をDocker composeしてしまいましょう。</p> <p><del>もちろん、本来は結果をきちんと残しておくためにInfluxDBとGrafanaを永続化する必要がありますが、今回はテスト実行なのでInfluxDB永続化は行いません。</del> 永続化することにしました。代わりにInfluxDBのcleanコマンドを実装する形にしました。本格的に負荷テスト環境を作るならAWS ECSであればデータボリュームを<a target="_blank" rel="nofollow noopener" href="https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/efs-volumes.html">Amazon EFS ボリューム</a>とかで管理すると幸せになれる気がします。</p> <p>次のような<strong>docker-compose.yml</strong>を作るだけで完成です。楽でいいですねdocker compose。</p> <pre><code class="yml">version: '3.8' services: influxdb: image: influxdb:1.8 ports: - "8086:8086" environment: - INFLUXDB_DB=k6 networks: - k6net - grafanaNet volumes: - ./influxdb:/var/lib/influxdb k6: image: grafana/k6:latest networks: - k6net ports: - "6565:6565" environment: - K6_OUT=influxdb=http://influxdb:8086/k6 volumes: - ./tests/scripts:/scripts grafana: image: grafana/grafana:latest ports: - "3000:3000" environment: - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin - GF_AUTH_ANONYMOUS_ENABLED=true - GF_AUTH_BASIC_ENABLED=false volumes: - ./grafana:/etc/grafana/provisioning/ networks: - grafanaNet networks: k6net: driver: bridge grafanaNet: driver: bridge </code></pre> <p>やっていることは超基本的なこととしてnetworkを切ってサービス名でInfluxDBにアクセスできるようにする点くらいなのですが、k6のenvironmentで、</p> <pre><code> environment: - K6_OUT=influxdb=http://influxdb:8086/k6 </code></pre> <p>とやってあげることで実行結果をInfluxDBに出力します。</p> <p><a target="_blank" rel="nofollow noopener" href="https://k6.io/docs/results-visualization/influxdb-+-grafana/#run-the-test-and-upload-the-results-to-influxdb">公式ドキュメント</a>のように環境変数でなく実行時の引数で設定できます。</p> <p>これで、<a target="_blank" rel="nofollow noopener" href="https://localhost:3000">https://localhost:3000</a>にアクセスすることで<strong>実行結果を可視化</strong>できるようになりました。</p> <p><img src="https://i.imgur.com/sX5HtCG.png" alt="grafana" /></p> <h3 id="TypeScriptでテストシナリオを書けるようにする"><a href="#TypeScript%E3%81%A7%E3%83%86%E3%82%B9%E3%83%88%E3%82%B7%E3%83%8A%E3%83%AA%E3%82%AA%E3%82%92%E6%9B%B8%E3%81%91%E3%82%8B%E3%82%88%E3%81%86%E3%81%AB%E3%81%99%E3%82%8B">TypeScriptでテストシナリオを書けるようにする</a></h3> <p>加えて、シナリオファイルの<strong>TypeScript化</strong>を実施します。</p> <p><a target="_blank" rel="nofollow noopener" href="https://k6.io/docs/using-k6/javascript-compatibility-mode/">公式ドキュメント</a>によるとGoのJavaScript VMが<strong>ES5にしか対応してない</strong>のでES6で書いたテストシナリオはどうやらK6のなかでES5に変換してから実行しているらしいです。</p> <p>K6の<a target="_blank" rel="nofollow noopener" href="https://github.com/grafana/k6/blob/master/go.mod">go.mod</a>を見てみると使っているJavaScript VMは<a target="_blank" rel="nofollow noopener" href="https://github.com/dop251/goja">goja</a>みたいですね。</p> <p>逆に、<strong>compatibility-mode=base</strong>というオプションを使ってES5のテストファイルを渡してあげた場合、ES6への変換の手間がなくなるので実行時間とメモリの使用量が改善され、<strong>パフォーマンスが改善</strong>されるとのことです。</p> <p>じゃあせっかくなら<strong>TypeScript=>ES5のトランスパイル</strong>を実施して実行時はcompatibility-mode=baseオプションを指定すれば、テストシナリオをTypeScriptで実装しつつパフォーマンス面でも有利に働きそうです。</p> <p>とりあえず<a target="_blank" rel="nofollow noopener" href="https://github.com/grafana/k6-hardware-benchmark">公式に載っている例</a>を参考にwebpack.config.jsをゴリっと書くことにしました。</p> <p>babelってトランスパイルしてしまったのでこれ型チェックとかしないかもな...と思いつつ公式がそうやっているからそのまま真似していくつかplugin追加しただけです。</p> <p>あと、target webで出力してますがsourcemapを出してしまうとK6で読み込めないよエラーが出るので出力させないです。(ちょっとハマった)</p> <p>さらに、今回はおそらく使わないと思いましたが<a target="_blank" rel="nofollow noopener" href="https://webpack.js.org/plugins/copy-webpack-plugin/">copy-webpack-plugin</a>を使ってテスト用のassetをdistディレクトリにコピーする形にしてます。こうしておくことで、ファイルのアップロードみたいなテストケースも書けるようになりそうです。多分。</p> <p>と思って色々試行錯誤していたら公式に実装例があり、ほぼやりたいことがそれでできていたので結局それをパクることにしました..。</p> <p><a target="_blank" rel="nofollow noopener" href="https://github.com/grafana/k6-template-typescript">Template to use TypeScript with k6</a></p> <pre><code class="javascript">const path = require('path'); const { CleanWebpackPlugin } = require('clean-webpack-plugin'); const CopyPlugin = require('copy-webpack-plugin'); const GlobEntries = require('webpack-glob-entries'); module.exports = { mode: 'production', entry: GlobEntries('./tests/scripts/*.ts'), output: { path: path.join(__dirname, 'dist'), libraryTarget: 'commonjs', filename: '[name].js', }, resolve: { extensions: ['.ts', '.js'], }, module: { rules: [ { test: /\.ts$/, use: 'babel-loader', exclude: /node_modules/, }, ], }, target: 'web', externals: /^(k6|https?\:\/\/)(\/.*)?/, stats: { colors: true, }, plugins: [ new CleanWebpackPlugin(), new CopyPlugin({ patterns: [{ from: path.resolve(__dirname, 'tests/assets'), noErrorOnMissing: true }], }), ], optimization: { minimize: false, }, }; </code></pre> <p>tsconfig.jsonも特に特徴はないですが、targetはes5にしてます。</p> <pre><code>{ "compilerOptions": { "target": "es5", "moduleResolution": "node", "module": "commonjs", ..... } } </code></pre> <p>これで<strong>webpackコマンド</strong>を実行することできちんとES5に変換されたテストシナリオのJavaScriptファイルがdist配下に出力されるようになりました。</p> <h3 id="実行してみる"><a href="#%E5%AE%9F%E8%A1%8C%E3%81%97%E3%81%A6%E3%81%BF%E3%82%8B">実行してみる</a></h3> <p>Docker composeにしてますので、必要なコンテナをupで立ち上げたのち、k6だけ個別にrunします。runするときにdist配下のテストファイルを標準入力として指定すればOKです。</p> <pre><code>ddocker compose up -f docker compose run k6 run --compatibility-mode=base - < ./dist/httpGet.js </code></pre> <p>無事実行できました!</p> <pre><code>asset cookieAuthServerSession.js 5.3 KiB [emitted] (name: cookieAuthServerSession) asset httpGet.js 2.63 KiB [emitted] (name: httpGet) runtime modules 1.83 KiB 8 modules orphan modules 451 bytes [orphan] 4 modules built modules 2.52 KiB [built] ./tests/scripts/cookieAuthServerSession.ts + 4 modules 2.38 KiB [not cacheable] [built] [code generated] ./tests/scripts/httpGet.ts + 1 modules 141 bytes [not cacheable] [built] [code generated] webpack 5.35.1 compiled successfully in 619 ms failed to get console mode for stdin: The handle is invalid. failed to get console mode for stdin: The handle is invalid. failed to get console mode for stdin: The handle is invalid. /\ |‾‾| /‾‾/ /‾‾/ /\ / \ | |/ / / / / \/ \ | ( / ‾‾\ / \ | |\ \ | (‾) | / __________ \ |__| \__\ \_____/ .io execution: local script: - output: InfluxDBv1 (http://influxdb:8086) scenarios: (100.00%) 1 scenario, 1 max VUs, 10m30s max duration (incl. graceful stop): * default: 1 iterations for each of 1 VUs (maxDuration: 10m0s, gracefulStop: 30s) running (00m00.9s), 0/1 VUs, 1 complete and 0 interrupted iterations default ✓ [ 100% ] 1 VUs 00m00.9s/10m0s 1/1 iters, 1 per VU data_received..................: 21 kB 24 kB/s data_sent......................: 519 B 574 B/s http_req_blocked...............: avg=734.31ms min=734.31ms med=734.31ms max=734.31ms p(90)=734.31ms p(95)=734.31ms http_req_connecting............: avg=161.49ms min=161.49ms med=161.49ms max=161.49ms p(90)=161.49ms p(95)=161.49ms http_req_duration..............: avg=169.36ms min=169.36ms med=169.36ms max=169.36ms p(90)=169.36ms p(95)=169.36ms { expected_response:true }...: avg=169.36ms min=169.36ms med=169.36ms max=169.36ms p(90)=169.36ms p(95)=169.36ms http_req_failed................: 0.00% ✓ 0 ✗ 1 http_req_receiving.............: avg=2.71ms min=2.71ms med=2.71ms max=2.71ms p(90)=2.71ms p(95)=2.71ms http_req_sending...............: avg=49µs min=49µs med=49µs max=49µs p(90)=49µs p(95)=49µs http_req_tls_handshaking.......: avg=381.41ms min=381.41ms med=381.41ms max=381.41ms p(90)=381.41ms p(95)=381.41ms http_req_waiting...............: avg=166.6ms min=166.6ms med=166.6ms max=166.6ms p(90)=166.6ms p(95)=166.6ms http_reqs......................: 1 1.105208/s iteration_duration.............: avg=903.84ms min=903.84ms med=903.84ms max=903.84ms p(90)=903.84ms p(95)=903.84ms iterations.....................: 1 1.105208/s Done in 6.27s. </code></pre> <p>Locustに勝るとも劣らないかなりしっかりとしたレポートが出ますね!</p> <h2 id="yarn scriptに何とかして取り込む"><a href="#yarn+script%E3%81%AB%E4%BD%95%E3%81%A8%E3%81%8B%E3%81%97%E3%81%A6%E5%8F%96%E3%82%8A%E8%BE%BC%E3%82%80">yarn scriptに何とかして取り込む</a></h2> <p>まぁこれで完成なのですが、ちょっとまだ不満点があります。 docker compose runコマンドを打つ前にwebpackコマンドを実行しないといけない点です。</p> <p>どうせならテスト実行一発でトランスパイル=>実行と移ってほしいのでyarn scriptを書いていきます。</p> <p>今回実装するまで知らなかったのですが、yarn scriptに引数を渡すときコマンドの途中に引数の文字列を入れたいとき、ちょっと難しかったです。サクッとできるかなと思ったのですが。</p> <p>結局、次のようにshellから0番目の引数を取って実行するという無茶苦茶実装にしました。こうすれば、 <strong>yarn start testFile</strong> で実行できます。</p> <pre><code>start": "sh -c \"webpack && docker compose run k6 run --compatibility-mode=base - < ./dist/${0}.js\"", </code></pre> <h2 id="GitHub Actionsに組み込んでみる"><a href="#GitHub+Actions%E3%81%AB%E7%B5%84%E3%81%BF%E8%BE%BC%E3%82%93%E3%81%A7%E3%81%BF%E3%82%8B">GitHub Actionsに組み込んでみる</a></h2> <p>最後にGitHub Actionsに組み込んでみることにします。</p> <p>とはいってもGitHub Actionsは普通にdocker composeできるのであまり気にするところはなさそうです。</p> <p>強いて言えば、docker compose runする際に-Tオプションをつけないと、コンソールが戻ってこないので無限に動いてしまいます。ちょっとだけ詰まりました。</p> <pre><code class="yaml">name: Test Scenario on: push: branches: - main pull_request: branches: - main jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Use Node.js 14.x uses: actions/setup-node@v1 with: node-version: 14.x - name: setup config run: mv tests/config/configExample.ts tests/config/config.ts - name: Setup test Env run: | yarn install docker-compose up -d yarn build - name: run # In GitHub Actions, if pseudo-tty is assigned in case of docker compose run, # container execution will continue and step will not be completed, so disable it with -T option. run: docker compose run -T k6 run --compatibility-mode=base - < ./dist/httpGet.js </code></pre> <h2 id="完成"><a href="#%E5%AE%8C%E6%88%90">完成</a></h2> <p>というわけでこちらが完成したテンプレートです。</p> <p><a target="_blank" rel="nofollow noopener" href="https://github.com/tubone24/k6_template_with_typescript_on_docker">K6 with TypeScript on Docker</a></p> <p>これからK6をプロジェクトで導入したいなと思っている人は上記をテンプレートとしてご活用いただければ幸いです。</p> <h2 id="参考"><a href="#%E5%8F%82%E8%80%83">参考</a></h2> <ul> <li><a target="_blank" rel="nofollow noopener" href="https://k6.io/docs/">k6 documentation</a></li> <li><a target="_blank" rel="nofollow noopener" href="https://github.com/grafana/k6-hardware-benchmark">k6-hardware-benchmark</a></li> <li><a target="_blank" rel="nofollow noopener" href="https://github.com/grafana/k6-template-typescript">Template to use TypeScript with k6</a></li> </ul> tubone24 tag:crieit.net,2005:PublicArticle/17450 2021-06-27T09:05:46+09:00 2021-06-27T09:05:46+09:00 https://crieit.net/posts/RSS-Slack スーパー完全無料でRSSをSlackに投稿できるやつを作った <h2 id="皆さん、どうやって技術ネタ、キャッチアップしてますか?"><a href="#%E7%9A%86%E3%81%95%E3%82%93%E3%80%81%E3%81%A9%E3%81%86%E3%82%84%E3%81%A3%E3%81%A6%E6%8A%80%E8%A1%93%E3%83%8D%E3%82%BF%E3%80%81%E3%82%AD%E3%83%A3%E3%83%83%E3%83%81%E3%82%A2%E3%83%83%E3%83%97%E3%81%97%E3%81%A6%E3%81%BE%E3%81%99%E3%81%8B%EF%BC%9F">皆さん、どうやって技術ネタ、キャッチアップしてますか?</a></h2> <p>皆さんはどうやって日々日進月歩な技術ネタをキャッチアップしてますか?</p> <p>私はよく企業や個人が書いている技術ブログから情報を得ることが多いです。本当に技術ブログって手軽なのにすごい勉強になりますよね。</p> <h2 id="皆さん、どうやってブログ記事を通知してますか?"><a href="#%E7%9A%86%E3%81%95%E3%82%93%E3%80%81%E3%81%A9%E3%81%86%E3%82%84%E3%81%A3%E3%81%A6%E3%83%96%E3%83%AD%E3%82%B0%E8%A8%98%E4%BA%8B%E3%82%92%E9%80%9A%E7%9F%A5%E3%81%97%E3%81%A6%E3%81%BE%E3%81%99%E3%81%8B%EF%BC%9F">皆さん、どうやってブログ記事を通知してますか?</a></h2> <p>ブログ記事確認はもちろん定期的にブログに訪問するのが一番ですが、なかなか時間の取れない中でそれは酷なので何かしら皆さん工夫していると思います。</p> <p>ブログの更新にあわせてTwitterを更新してくれる企業様であれば、Twitterのフォローをすればいいかもしれませんが、必ずしもそうでもないかもしれませんし、Twitterのフォローには技術以外の話題も飛び交うので、集中して記事を確認することも難しいかもしれません。</p> <p>そういったときに役立つのがRSSです。RSSとは<strong>R</strong>ich <strong>S</strong>ite <strong>S</strong>ummaryの略で、ニュースやブログなど各種のウェブサイトの更新情報を配信するための仕組みやXMLフォーマットのことです。</p> <p>RSSの更新を定期的に取得し、記事更新を教えてくれるRSSリーダーは皆さんお世話になっている人も多いのではないでしょうか?</p> <p>私もGoogle Chromeに拡張としてRSSリーダーを入れていた時期もありました。</p> <h2 id="問題点"><a href="#%E5%95%8F%E9%A1%8C%E7%82%B9">問題点</a></h2> <p>RSSリーダーを使って技術ブログの更新を検知する方法はおそらくデファクトスタンダードだと思いますが、個人的にちょっと問題点がありました。</p> <p>それは、<strong>通勤時間の時間をうまく使ってキャッチアップするのが面倒ということです。</strong></p> <ul> <li>携帯にPCと同じRSSを登録するのがめんどくさい</li> <li>RSSリーダーを開かない <ul> <li>電車に乗っているとTwitterやSlackを開いている時間がほぼ全て</li> <li>Kindleで読書するのも細かく乗り換えがあって中断が多く発生するためストレス</li> </ul></li> <li>(RSSリーダーによって違うのかもしれませんが)タイトルを見て中身を判断するのが難しい</li> </ul> <p>このような悩みがあるため、私は<strong>Slack</strong>の<strong>/feed</strong>機能を使ってRSSを購読してました。</p> <p>が、しかしこれもまたもや問題点。Slackの無料ワークスペースには、Appsが10個までしか登録できないのです。(/feedもAppsを消費します)</p> <p>Slackには他にもAppsをいくつか作って入れているため、実際登録できるRSSは5個くらいになってしまいちょっと心もとない感じになってしまいました。</p> <h3 id="IFTTTはどうなの?"><a href="#IFTTT%E3%81%AF%E3%81%A9%E3%81%86%E3%81%AA%E3%81%AE%EF%BC%9F">IFTTTはどうなの?</a></h3> <p>ちょっと詳しい人だと「じゃあIFTTT」はどうなんです?という意見が聞こえてきそうですが結果的にこちらも不採用。</p> <p>理由は上記とほぼ同じで、無料版だと設定できる数に制限があるためこちらもあえなく不採用。</p> <p>というより、お金出せよって声が聞こえてきますね。</p> <h2 id="じゃあ作ろっか"><a href="#%E3%81%98%E3%82%83%E3%81%82%E4%BD%9C%E3%82%8D%E3%81%A3%E3%81%8B">じゃあ作ろっか</a></h2> <p>ということで、作ります。</p> <p>要求は次の通りのことを満たす必要があります。</p> <ul> <li>無制限にRSSを登録できること</li> <li>更新がある場合のみSlackに投稿すること</li> <li>SlackもAppsを消費しないこと(Custom Integration)</li> <li>できれば内容を要約したものや、OGP画像も一緒に投稿して記事の選別に役立てられる付加機能を作ること</li> </ul> <h2 id="feedparser"><a href="#feedparser">feedparser</a></h2> <p>今回は時間もない中だったのでサクッとPythonで作っていきます。</p> <p>RSSの購読には<a target="_blank" rel="nofollow noopener" href="https://pythonhosted.org/feedparser/">feedparser</a>を使うと便利です。</p> <p>RSS2.0だけでなく、Atomや古いRSSの形式でも難なく読み込んでくれます。</p> <pre><code class="python">import feedparser entries = feedparser.parse('http://feedparser.org/docs/examples/rss20.xml') for e in entries: print(e.title) print(e.link) print(e.summary) </code></pre> <p>Entry Itemへのアクセスはイテレーターになっているので取り出しもかんたんです。</p> <p>RSSのEntry Itemの取り出しはこれで進めます。本当にかんたんでありがたい。</p> <p>さらに便利なのは<strong>published_parsed</strong>という項目がEntry Itemから取れます。</p> <p>こちら、RSSのpublished_dateをdatetimeオブジェクトにパースしてくれます。</p> <p>おかげで、フォーマット差分をあまり意識することなく、更新差分チェック実装ができました。</p> <h2 id="ステート管理"><a href="#%E3%82%B9%E3%83%86%E3%83%BC%E3%83%88%E7%AE%A1%E7%90%86">ステート管理</a></h2> <p>RSSには記事の作成日付(Publish Date)があり、RSSの取得のたびに差分チェックとして活用することができます。</p> <p>なので、以前取得した記事のPublish Dateを記憶して、更新があった場合のみ記事を取得するようにしたいのですが、それには何かしらのDB、もしくはデータ保存する仕組みが必要となります。</p> <p>今回は無料という縛りがあるため、当初はGitHubのレポジトリ上にステートファイルをコミットするようにしようとも思ったのですが、コミットが伸び過ぎてしまうのは色々問題なのでやはりDBを使いたいです。</p> <h3 id="HarperDB"><a href="#HarperDB">HarperDB</a></h3> <p>HarperDBは、データ管理を容易にすることに重点を置いた分散型データベースで、ジョインを含むNoSQLとSQLをサポートしています。</p> <p>NoSQLでSQLがかけるのは便利ですね!!</p> <p>日本ではあまり聞きませんが、<a target="_blank" rel="nofollow noopener" href="https://dev.to/">dev.to</a>とかだとちょこちょこ話題に上がっております。</p> <p>こちらのHarperDB、HarperDB Cloud Instanceというマネージドサービスも提供されており、インスタンスタイプを選ぶだけで、手軽にHarperDBを使うことができるようになっております。</p> <p><img src="https://i.imgur.com/CA1sLCU.png" alt="harperdb" /></p> <p><img src="https://i.imgur.com/48qXVQw.png" alt="img" /></p> <p>え?でもお高いんじゃない?そんな声が聞こえてきますね。</p> <p>なんと、今だけかもしれませんがHarperDB Cloud Instanceの一番最小のInstance構成だと無料で使うことができます!これは嬉しいですね。</p> <div class="table-responsive"><table> <thead> <tr> <th>Name</th> <th>Value</th> </tr> </thead> <tbody> <tr> <td>RAM</td> <td>0.5GB</td> </tr> <tr> <td>DISK</td> <td>1GB</td> </tr> <tr> <td>VERSION</td> <td>3.0.0</td> </tr> <tr> <td>IOPS</td> <td>3000</td> </tr> </tbody> </table></div> <p>正直今回の使い方ではこのレベルで十分です。</p> <p>Python上でのHarperDB操作も<a target="_blank" rel="nofollow noopener" href="https://pypi.org/project/harperdb/">専用のライブラリ</a>が用意されているためかんたんに実装できます。</p> <pre><code class="python">HARPERDB_URL = os.getenv("HARPERDB_URL") HARPERDB_USERNAME = os.getenv("HARPERDB_USERNAME") HARPERDB_PASSWORD = os.getenv("HARPERDB_PASSWORD") HARPERDB_SCHEMA = os.getenv("HARPERDB_SCHEMA", "prd") FILEPATH = "entry.csv" db = harperdb.HarperDB( url=HARPERDB_URL, username=HARPERDB_USERNAME, password=HARPERDB_PASSWORD,) test = db.search_by_hash(HARPERDB_SCHEMA, "last_published", [name], get_attributes=["time"]) for t in test: print(t["time"]) </code></pre> <p>このようにNoSQLライクにHash Attributeを使って検索する感じで実装できます。もちろんValue引きも可能です。(遅くなるのかは不明だがNoSQLなら全走査になりそうなので多分遅い)</p> <p>UpdateやInsertも同様な感じで実施できます。</p> <pre><code class="python">ef insert_last_published(name: str): db.insert(HARPERDB_SCHEMA, "last_published", [{"name": name, "time": 123456789}]) return 123456789 def update_last_published(name: str, time: int): result = db.update(HARPERDB_SCHEMA, "last_published", [{"name": name, "time": time}]) return result </code></pre> <p>また、便利だなと思ったのはやはりSQLでの走査です。</p> <pre><code class="python">def get_entry_urls(): return [{"name": x["name"], "url": x["url"], "icon": x["icon"]} for x in db.sql(f"select * from {HARPERDB_SCHEMA}.entry_urls")] </code></pre> <p>といった具合にテーブルの*Selectやジョインなんかも書くことができます。テーブル全体をなめたいとき、これは楽でいいですね。</p> <p>また、CSV load機能もあり、CSVをHarperDBに食わせることもできちゃったりします。</p> <p>今回はこちらの機能はRSSのEntryURL登録機能として便利に使用させていただきました。</p> <pre><code class="python">import os import harperdb HARPERDB_URL = os.getenv("HARPERDB_URL") HARPERDB_USERNAME = os.getenv("HARPERDB_USERNAME") HARPERDB_PASSWORD = os.getenv("HARPERDB_PASSWORD") HARPERDB_SCHEMA = os.getenv("HARPERDB_SCHEMA", "prd") FILEPATH = "entry.csv" db = harperdb.HarperDB( url=HARPERDB_URL, username=HARPERDB_USERNAME, password=HARPERDB_PASSWORD,) db.csv_data_load(HARPERDB_SCHEMA, "entry_urls", FILEPATH, action="upsert") </code></pre> <p>無料開発で一番ネックになるのがDBですが、正直これだけで大概のアプリは作れてしまうのではないでしょうか?</p> <h2 id="OGP画像を得るには?"><a href="#OGP%E7%94%BB%E5%83%8F%E3%82%92%E5%BE%97%E3%82%8B%E3%81%AB%E3%81%AF%EF%BC%9F">OGP画像を得るには?</a></h2> <p>OGPとは<strong>O</strong>pen <strong>G</strong>raph <strong>P</strong>rotocolの略で、TwitterやFacebookにURLリンクを貼り付けると出てくるあれです。</p> <p><img src="https://i.imgur.com/4LAaL3b.png" alt="img" /></p> <p>実際OGP作成を実装された方ならわかりますが、OGPはHTMLのHeaderに決まりきったmetaタグを記載して表現しております。</p> <pre><code class="html"><meta property="og:type" content="article" data-react-helmet="true"> <meta property="og:url" content="https://blog.tubone-project24.xyz/2021/01/01/mqtt-nenga" data-react-helmet="true"> <meta property="og:title" content="MQTTと電子ペーパーを使って年賀状を作る" data-react-helmet="true"> <meta property="og:description" content="年賀書きたくないマン Table of Contents 一年の計は元旦にあり 注意 年末年始はやってみようBOX MQTT React Hooks Tailwind CSS 電子ペーパー やらないことにしようBOX アーキテクチャー 辛かったこと Hooks…" data-react-helmet="true"> <meta property="og:image" content="https://i.imgur.com/tmkmoVA.png" data-react-helmet="true"> <meta name="twitter:title" content="MQTTと電子ペーパーを使って年賀状を作る" data-react-helmet="true"> <meta name="twitter:description" content="年賀書きたくないマン Table of Contents 一年の計は元旦にあり 注意 年末年始はやってみようBOX MQTT React Hooks Tailwind CSS 電子ペーパー やらないことにしようBOX アーキテクチャー 辛かったこと Hooks…" data-react-helmet="true"> <meta name="twitter:image" content="https://i.imgur.com/tmkmoVA.png" data-react-helmet="true"> </code></pre> <p>Slackのattachmentsに入れる画像はOGPのImageから取るようにします。</p> <h3 id="opengraph-py3"><a href="#opengraph-py3">opengraph-py3</a></h3> <p>PythonでOGPを解析するなら<a target="_blank" rel="nofollow noopener" href="https://pypi.org/project/opengraph_py3/">opengraph</a>ライブラリが便利です。ただし、</p> <pre><code>pip install opengraph </code></pre> <p>でインストールするとPython2用のライブラリがインストールされてしまいまともに動かないので、</p> <pre><code>pip install opengraph_py3 </code></pre> <p>でインストールするようにします。</p> <p>使い方もかんたんで、<strong>opengraph_py3.OpenGraph</strong>でインスタンスを作ってあげれば、<strong>ogp["image"]</strong>にOGPイメージURLが保存されます。</p> <p>一点注意としてopengraphは裏でBeautifulSoapが動いているようで、Headerのないページに対してOGPを取得しようとするとAttributeErrorが出てしまうので例外処理を入れております。</p> <p>本家にPR出すか迷いましたが、2017年から更新がないので骨折り損になりそうなので、やめておきます。</p> <pre><code class="python">import opengraph_py3 def get_ogp_image(link: str): try: ogp = opengraph_py3.OpenGraph(url=link) if ogp.is_valid(): return ogp["image"] else: return "" except AttributeError as e: logger.debug(f"No Head contents: {e}") return "" </code></pre> <h2 id="Favicon"><a href="#Favicon">Favicon</a></h2> <p>できれば、Slack投稿するときに技術ブログのアイコンをブログごとに変えたいなと思ったので、Faviconを取る実装も入れます。</p> <p>Pythonにはfavicon取るためのライブラリ<a target="_blank" rel="nofollow noopener" href="https://pypi.org/project/favicon/">favicon</a>があります。</p> <p>使い方も超かんたんで、<strong>favicon.get</strong>で取得したオブジェクトの配列0番目が一番大きなfaviconなのでそれを取るだけです。</p> <pre><code class="python">import favicon def get_favicon(link): icons = favicon.get(link) if len(icons) == 0: return "" else: return icons[0].url </code></pre> <h2 id="キーワード抽出"><a href="#%E3%82%AD%E3%83%BC%E3%83%AF%E3%83%BC%E3%83%89%E6%8A%BD%E5%87%BA">キーワード抽出</a></h2> <p>さて、今回の醍醐味のキーワード抽出ですがこちらもかんたんに実装できます。</p> <p><a target="_blank" rel="nofollow noopener" href="http://gensen.dl.itc.u-tokyo.ac.jp/pytermextract/">pytermextract</a>という専門用語抽出ツールと形態素解析ライブラリ<a target="_blank" rel="nofollow noopener" href="https://mocobeta.github.io/janome/">janome</a>を組み合わせることでかんたんに実現できます。</p> <p>janomeは本当に便利で、特にCIに乗っけてぐるぐるしたい人にはmecabをインストールする必要も辞書をコンパイルする必要もなく、pipで一発入れれば使えるので重宝しています。</p> <p>pytermextractはPyPI登録されているライブラリではないのでインストールは公式サイトから落としたZIPを展開しsetup.pyから行います。</p> <p>また、janomeもpipでインストールします。</p> <pre><code class="shell">unzip pytermextract-0_01.zip cd pytermextract-0_01 python setup.py install pip install janome </code></pre> <p>まずは、キーワード抽出したいテキストをjanomeのTokenizerにかけて、結果を頻出度から単名詞の左右の連接情報スコア(LR)を算出し、</p> <p>重要度スコアとしてはじき出す、という仕組みらしいです。とは言っても私にはよくわからなったのでサンプルコード丸パクリです。</p> <p>得られる結果は<strong>{"単語": スコア}</strong>となってますので、こちらをスコア順にリバースソートして上位6位を取得する形にしました。</p> <p>しょうもない知識ですが、janomeのTokenizerインスタンス作るところは処理コストがちょっと高いので、リファクタでモジュールトップレベルでの宣言にしてます。</p> <pre><code class="python">from janome.tokenizer import Tokenizer import termextract.janome import termextract.core t = Tokenizer() def extract_keyword(text): tokenize_text = t.tokenize(text) frequency = termextract.janome.cmp_noun_dict(tokenize_text) lr = termextract.core.score_lr( frequency, ignore_words=termextract.janome.IGNORE_WORDS, lr_mode=1, average_rate=1) term_imp = termextract.core.term_importance(frequency, lr) score_sorted_term_imp = sorted(term_imp.items(), key=lambda x: x[1], reverse=True) logger.debug(f"keywords: {score_sorted_term_imp}") return score_sorted_term_imp[:6] </code></pre> <h3 id="RSSのSummaryTextでは精度がでない、そりゃそうじゃ。"><a href="#RSS%E3%81%AESummaryText%E3%81%A7%E3%81%AF%E7%B2%BE%E5%BA%A6%E3%81%8C%E3%81%A7%E3%81%AA%E3%81%84%E3%80%81%E3%81%9D%E3%82%8A%E3%82%83%E3%81%9D%E3%81%86%E3%81%98%E3%82%83%E3%80%82">RSSのSummaryTextでは精度がでない、そりゃそうじゃ。</a></h3> <p>見出し通りですが、当初はfeedparserから取得できるEntry ItemのSummaryをpytermextractに食わせてましたが、SummaryTextが短すぎて全く期待する動作になりませんでしたので、BeautifulSoupを使って、実際の記事の本文を取得しpytermextractに食わせる実装に変更しました。</p> <pre><code class="python">from bs4 import BeautifulSoup import urllib.request as req def extract_html_text(url): res = req.urlopen(url) soup = BeautifulSoup(res, "html.parser") p_tag_list = soup.find_all("p") return " ".join([p.get_text() for p in p_tag_list]) </code></pre> <p>本文はpタグと判断しfind_allするちんけな実装です。ごめんなさい。</p> <h2 id="Slack投稿"><a href="#Slack%E6%8A%95%E7%A8%BF">Slack投稿</a></h2> <p>いよいよSlack投稿部分の作成です。</p> <p>Slack投稿はCustomIntegrationのIncoming Webhookで作ります。</p> <p>なので、<a target="_blank" rel="nofollow noopener" href="https://api.slack.com/reference/messaging/attachments">Slack attachment</a>が使えます。</p> <p>特質したことはないのですが、OGP画像はimage_urlに、faviconはauthor_imageにキーワードはfieldsに入れてます。</p> <h2 id="GitHub Actions化"><a href="#GitHub+Actions%E5%8C%96">GitHub Actions化</a></h2> <p>最後にGitHub Actionsに載せて、定期実行させます。</p> <p>その前にの<a href="#harperdb">#harperdb</a>でも書いたとおり、RSS追加時のHarperDBへのEntry追加の定義を書いていきます。</p> <p>特定のファイルに更新があった場合のみ動くGitHub Actionsを作る場合は、 on_pushなどの条件にpathsを入れることで実現できます。これだけです。</p> <pre><code class="yml">on: push: branches: - main paths: - "entry.csv" pull_request: branches: - main paths: - "entry.csv" </code></pre> <p>また、定期実行にはschedule cronが便利です。</p> <pre><code class="yml">on: push: branches: - main pull_request: branches: - main schedule: - cron: "*/30 * * * *" </code></pre> <h2 id="完成"><a href="#%E5%AE%8C%E6%88%90">完成</a></h2> <p>ということでできました。</p> <p><img src="https://i.imgur.com/Ip4IaYs.png" alt="img" /></p> <p>entry.csvに書いたRSS feedを30分ごとに確認しにいき、前回よりpublish_dateの更新があったばあいはOGP, favicon, キーワード付きでSlack投稿します。</p> <p>レポジトリはこちらです。</p> <p><a target="_blank" rel="nofollow noopener" href="https://github.com/tubone24/tech_blog_spider">https://github.com/tubone24/tech_blog_spider</a></p> <p>ForkするとGitHubA ctionsがうまく発火しないっぽいので、もし利用する際はgit cloneして自身のレポジトリに再Pushして使っていただければと思います。</p> <h2 id="結論"><a href="#%E7%B5%90%E8%AB%96">結論</a></h2> <p>HarperDBを使って何でもつくれそうな予感がするこの頃です。</p> tubone24 tag:crieit.net,2005:PublicArticle/16725 2021-03-09T01:32:10+09:00 2021-03-09T01:32:10+09:00 https://crieit.net/posts/React-Hook-Form-Getform-io React Hook FormとGetform.ioを使って、お問い合わせフォームを作ろう! <p><a target="_blank" rel="nofollow noopener" href="https://react-hook-form.com/jp/">React Hook Form</a>が便利らしいと聞いたので使ってみることにしました。</p> <h2 id="React Hook Form"><a href="#React+Hook+Form">React Hook Form</a></h2> <p>皆さん、<a target="_blank" rel="nofollow noopener" href="https://react-hook-form.com/jp/">React Hook Form</a>を知ってますか?</p> <p>最近トレンドに乗っかってきた、<strong>Form</strong>を<strong>React Hooks</strong>で簡単に作ることのできる代物です。</p> <p><img src="https://i.imgur.com/2dqEW7L.png" alt="img" /></p> <p>特徴として、Hooksを使って簡単にFormが作れる、そして再レンダリングが最小限に抑えられているのでパフォーマンスも高い、らしいです。</p> <h2 id="Getform.io"><a href="#Getform.io">Getform.io</a></h2> <p><a target="_blank" rel="nofollow noopener" href="https://getform.io/">Getform.io</a>はフォームのバックエンドを提供するすばらしいサービスです。</p> <p>詳しくは<a target="_blank" rel="nofollow noopener" href="https://blog.tubone-project24.xyz/2021/02/13/netlify-github-action#getformio">こちらの過去記事</a>をご確認いただければと思います。</p> <h2 id="React Hook Form + Getform.io"><a href="#React+Hook+Form+%2B+Getform.io">React Hook Form + Getform.io</a></h2> <p><strong>合体!</strong></p> <p>だめ~となるかと思いましたがうまいことできました。</p> <p><img src="https://i.imgur.com/yYJBK98.jpg" alt="img" /></p> <p>今回はこちらの2技術を使って、お問い合わせフォームを作っていきます。</p> <h2 id="実コード"><a href="#%E5%AE%9F%E3%82%B3%E3%83%BC%E3%83%89">実コード</a></h2> <p>こんな感じのコンポーネントができました。</p> <pre><code class="typescript">import React, {useState} from "react"; import { useForm } from "react-hook-form"; import Button from "./button"; type Inputs = { name: string, email: string, subject: string, message: string, }; const ContactForm = (): JSX.Element => { const [serverState, setServerState] = useState({ submitting: false, status: {ok: false, msg: ""} }); const { register, handleSubmit, errors } = useForm<Inputs>(); const handleServerResponse = (ok: boolean, msg: string) => { setServerState({ submitting: true, status: { ok, msg } }); }; const onSubmit = (data: Inputs, e: any) => { const formData = new FormData(); formData.append("name", data.name) formData.append("email", data.email) formData.append("subject", data.subject) formData.append("message", data.message) fetch('https://getform.io/f/8xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', { method: 'POST', body: formData }) .then(() => { e.target.reset(); handleServerResponse(true, "Submitted!"); }) .catch((error) => { alert(error) console.error(error) handleServerResponse(false, error.toString()); }); } return ( <form onSubmit={handleSubmit(onSubmit)}> <p> <label>Your Name<br/> <input name="name" placeholder="Enter your name" type="text" ref={register({ required: true })} /> {errors.name && <span>This field is required</span>} </label> </p> <p> <label> Your email<br/> <input name="email" type="email" placeholder="Enter your email" ref={register({ pattern: /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/i, required: true })} /> {errors.email && <span>This field is required and only email format</span>} </label> </p> <p> <label> Subject<br/> <input name="subject" type="text" maxLength={30} placeholder="Subject here..." ref={register({required: true })} /> {errors.subject && <span>This field is required</span>} </label> </p> <p> <label> Message<br /> <textarea name="message" placeholder="Something writing..." rows={6} cols={25} ref={register({required: true })}/> {errors.message && <span>This field is required</span>} </label> </p> <Button dark={serverState.submitting && serverState.status.ok} disabled={serverState.submitting && serverState.status.ok}> { serverState.submitting && serverState.status.ok ? serverState.status.msg: 'Submit'} </Button> </form> ); } export default ContactForm </code></pre> <h2 id="解説"><a href="#%E8%A7%A3%E8%AA%AC">解説</a></h2> <h3 id="準備"><a href="#%E6%BA%96%E5%82%99">準備</a></h3> <p>まず、フォームの項目に該当するTypeを作ります。</p> <pre><code class="typescript">type Inputs = { name: string, email: string, subject: string, message: string, }; </code></pre> <p>今回は名前、email、題名、メッセージを設定します。</p> <p>次に<strong>React Hook Form</strong>のuseFormを使って<strong>register</strong>などを作っていきます。正直これができれば基本的な機能は8割くらい完成です。</p> <pre><code class="typescript">const { register, handleSubmit, errors } = useForm<Inputs>(); </code></pre> <p>とりあえず用意するのは、formのrefに設定する<strong>register</strong>、onSubmitをコントロールできる<strong>handleSubmit</strong>、requireを検査できる<strong>errors</strong>です。</p> <p>ほかにも、form全体の項目検査のformState.isValidなども使うことができます。</p> <h3 id="Submit"><a href="#Submit">Submit</a></h3> <p>そして肝心な送信(Submit)部分ですがこちらは<a target="_blank" rel="nofollow noopener" href="https://blog.tubone-project24.xyz/2021/02/13/netlify-github-action#getformio">前記事</a>とほぼ同じように<strong>onSubmit</strong>に合わせて処理する関数を用意して、formの<strong>onsubmit属性</strong>に渡してあげればいいだけです。</p> <p>........いいだけですが一つ注意として、渡す際に<strong>handleSubmit</strong>で関数をラップしないと、form情報がうまく取れない、ということです。忘れずに設定してくださいませ。</p> <pre><code class="typescript"> <form onSubmit={handleSubmit(onSubmit)}> </form> </code></pre> <p>またGetform.ioへのPOSTはJSONではなく<strong>mulitpart/form-data</strong>で渡さないと行けないので、FormDataにappendする形でFormのデータを差し込みます。</p> <pre><code class="typescript"> const onSubmit = (data: Inputs, e: any) => { const formData = new FormData(); formData.append("name", data.name) formData.append("email", data.email) formData.append("subject", data.subject) formData.append("message", data.message) fetch('https://getform.io/f/8xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', { method: 'POST', body: formData }) .then(() => { e.target.reset(); handleServerResponse(true, "Submitted!"); }) .catch((error) => { alert(error) console.error(error) handleServerResponse(false, error.toString()); }); } </code></pre> <p>また、第二引数として、formのeventが取得できます。おそらくEventは<strong>React.BaseSyntheticEvent</strong>だとは思うのですがうまく型が通せなくて悩みだしてしまいましたのでとりあえずanyにしてしまいました。</p> <p>一応、<a target="_blank" rel="nofollow noopener" href="https://github.com/react-hook-form/react-hook-form/discussions/4376">こちら</a>で質問は投げてますが英語がへたくそで誰も答えてくれそうにありませんね。</p> <p>特にeventでデータを取る必要はなさそうですが、たとえば送信時にFormの内容をリセットするなどの処理を書きたいときは</p> <pre><code class="typescript"> e.target.reset(); handleServerResponse(true, "Submitted!"); </code></pre> <p>とやってあげればOKです。</p> <h3 id="Formを書く"><a href="#Form%E3%82%92%E6%9B%B8%E3%81%8F">Formを書く</a></h3> <p>さて、あとは普通のFormを作るようにJSXを書いていきます。</p> <p>唯一違うところはinputやtextareaのref属性にregisterをつけなければいけないですが、それだけで大丈夫です。</p> <pre><code class="typescript"> <textarea name="message" placeholder="Something writing..." rows={6} cols={25} ref={register({required: true })}/> </code></pre> <p>ちなみに、registerのパラメーターで、必須項目やパターンの検査もできます。</p> <pre><code class="typescript"> <input name="email" type="email" placeholder="Enter your email" ref={register({ pattern: /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/i, required: true })} /> </code></pre> <p>また、検査が通っていないときに警告メッセージを出すのはerrorsをつかうことで実現できます。</p> <pre><code class="typescript"> {errors.subject && <span>This field is required</span>} </code></pre> <p>Form部分をすべて実装するとこんな感じです。</p> <pre><code class="typescript"> <form onSubmit={handleSubmit(onSubmit)}> <p> <label>Your Name<br/> <input name="name" placeholder="Enter your name" type="text" ref={register({ required: true })} /> {errors.name && <span>This field is required</span>} </label> </p> <p> <label> Your email<br/> <input name="email" type="email" placeholder="Enter your email" ref={register({ pattern: /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/i, required: true })} /> {errors.email && <span>This field is required and only email format</span>} </label> </p> <p> <label> Subject<br/> <input name="subject" type="text" maxLength={30} placeholder="Subject here..." ref={register({required: true })} /> {errors.subject && <span>This field is required</span>} </label> </p> <p> <label> Message<br /> <textarea name="message" placeholder="Something writing..." rows={6} cols={25} ref={register({required: true })}/> {errors.message && <span>This field is required</span>} </label> </p> <Button dark={serverState.submitting && serverState.status.ok} disabled={serverState.submitting && serverState.status.ok}> { serverState.submitting && serverState.status.ok ? serverState.status.msg: 'Submit'} </Button> </form> </code></pre> <p>もう完成です。実に簡単ですね。</p> <p>React Hook Formを使わないと、<a target="_blank" rel="nofollow noopener" href="https://blog.tubone-project24.xyz/2021/02/13/netlify-github-action#getformio">前記事</a>のように、formのonChangeのたびに、setStateしなきゃいけないのですが、すっきり実装できました。</p> <p>React Hook Formを使わないと</p> <pre><code class="typescript"> handleChange(e) { this.setState({ [e.target.name]: e.target.value }); } (中略) <input type="text" name="name" className="form-control" maxLength="30" minLength="2" required placeholder="Enter your name" onChange={this.handleChange} /> </code></pre> <p>となります。</p> <p>出来上がりはただのFormですのでかっこいいCSSを当ててくださいね。</p> <p><img src="https://i.imgur.com/DsrFLOE.png" alt="img" /></p> <h2 id="結論"><a href="#%E7%B5%90%E8%AB%96">結論</a></h2> <p>楽に実装できたので余った時間は担当<strong>ウマ娘</strong>に捧げます。</p> tubone24 tag:crieit.net,2005:PublicArticle/16704 2021-03-01T01:31:55+09:00 2021-03-01T01:31:55+09:00 https://crieit.net/posts/Next-js-Vercel-Recoil-Material-Table-AWS Next.jsとVercelとRecoilとMaterial Tableを使ってAWSのステータスダッシュボードを作ってみた話 <h2 id="AWSのステータス確認難しいよね"><a href="#AWS%E3%81%AE%E3%82%B9%E3%83%86%E3%83%BC%E3%82%BF%E3%82%B9%E7%A2%BA%E8%AA%8D%E9%9B%A3%E3%81%97%E3%81%84%E3%82%88%E3%81%AD">AWSのステータス確認難しいよね</a></h2> <p>AWSを使ったことのある人ならばわかると思いますが、公式がAWSの障害情報を掲載する<a target="_blank" rel="nofollow noopener" href="https://status.aws.amazon.com/">AWS Service Health Dashboard</a>があまり使いやすくないです。</p> <p><img src="https://i.imgur.com/XghDulZ.png" alt="img" /></p> <p>それぞれのリージョンの障害がRSSで配信される形式になっているのですが、わざわざRSSを登録するのもめんどくさいし、Slackとかの連携に乗っけるのもそれはそれで便利なのですが、そもそもSlackを見ていないほかの人でも障害情報を共有したいです。</p> <p>実は、AWS Service Health Dashboardの情報はJSONで取得することができます。</p> <p><a target="_blank" rel="nofollow noopener" href="https://status.aws.amazon.com/data.json">https://status.aws.amazon.com/data.json</a></p> <p>こちらのJSONを活用して勉強がてら使いやすいダッシュボードを作っていきます。</p> <h2 id="クビになるぞ!"><a href="#%E3%82%AF%E3%83%93%E3%81%AB%E3%81%AA%E3%82%8B%E3%81%9E%EF%BC%81">クビになるぞ!</a></h2> <p>最近、これといった新しい技術に触れておらず、このままだとクビになりそうなので、そろそろ重い腰を上げてNext.jsを勉強することにしました。</p> <p>また、Next.jsを使う場合はVercelが便利だよーとのことですので、こちらも使っていきます。</p> <h2 id="Next.js"><a href="#Next.js">Next.js</a></h2> <p>Next.jsではpages/api配下に格納したコードについては、サーバーサイドとして振る舞います。</p> <p>クライアントから直接status情報がかかれたJSONを読みとってもよかったのですが、HTMLの面倒なサニタイジング処理やら、値の補完など面倒なことはサーバーサイドに持ってこようということで、<br /> statusJSONを取得して、フロントに返却するサーバーコードを書いていきます。</p> <p>次のようなコードになりました。</p> <pre><code class="typescript">import { NextApiRequest, NextApiResponse } from 'next' import axios from 'axios' export interface AwsStatusResp { archive: AwsStatusArchive[] } export interface AwsStatusArchive { service_name: string summary: string date: string status: string details: string description: string service: string } const handler = (req: NextApiRequest, res: NextApiResponse) => { axios .get<AwsStatusResp>('https://status.aws.amazon.com/data.json') .then((resp) => { const handlerResp = resp.data.archive.map((x) => ({ // eslint-disable-next-line @typescript-eslint/camelcase service_name: x.service_name, summary: x.summary, region: x.service.includes('management-console') ? 'global' : x.service.split('-').slice(1).join('-') === '' ? 'global' : x.service.split('-').slice(1).join('-'), date: x.date, status: x.status, details: x.details, service: x.service.includes('management-console') ? 'management-console' : x.service.split('-')[0], description: x.description .replace(/<("[^"]*"|'[^']*'|[^'">])*>/g, '') .replace(/&nbsp;/g, '\n'), })) res.statusCode = 200 // eslint-disable-next-line no-console console.log(handlerResp) res.json(handlerResp) }) .catch((error) => { console.error(error.response) res.statusCode = error.response.status || 500 res.statusMessage = error.response.statusText || 'InternalServerError' res.json({ error: error.response.statusText || 'InternalServerError' }) }) } export default handler </code></pre> <p>注意点として、必ずハンドラーはexport defaultを指定してあげないこと以外はいたって直感的なコードとなっております。</p> <p>Vercelに載っけるとわかるのですが、こちらのコード、Lambdaにデプロイされることになります。たしかに見覚えある感じですね。</p> <p>また、Next.jsと関係ないのですが、axiosのレスポンスに型がつけられるって知ってましたか?</p> <pre><code class="typescript">export interface AwsStatusResp { archive: AwsStatusArchive[] } export interface AwsStatusArchive { service_name: string summary: string date: string status: string details: string description: string service: string } axios .get<AwsStatusResp>('https://status.aws.amazon.com/data.json') .then((resp) => { .......... </code></pre> <h2 id="Material Table"><a href="#Material+Table">Material Table</a></h2> <p>Material UI準拠のテーブルとして、Material Tableなるものがありましたので今回採用することにしました。</p> <pre><code class="typescript">import MaterialTable from 'material-table' import tableIcons from '../components/tableIcons' <MaterialTable icons={tableIcons} columns={[ { title: 'Service Name', field: 'service_name' }, { title: 'Service', field: 'service', width: 10 }, { title: 'Region', field: 'region', lookup: regionNameMapping }, { title: 'Summary', field: 'summary' }, { title: 'Date (' + dayjs.tz.guess() + ')', field: 'date', render: (rowData) => ( <div> {dayjs .unix(Number(rowData.date)) .format('YYYY-MM-DDTHH:mm:ssZ[Z]')} </div> ), defaultSort: 'desc', type: 'string', }, { title: 'Status', field: 'status', lookup: statusMapping, }, ]} data={aws} detailPanel={[ { tooltip: 'Details', render: (rowData) => { return ( <> <div className="title">{rowData.summary}</div> <div className="description"> {dayjs .unix(Number(rowData.date)) .format('YYYY-MM-DDTHH:mm:ss')}{' '} {rowData.service_name} </div> <div className="code">{rowData.description}</div> </> ) }, }, ]} options=<span>{</span><span>{</span> filtering: true, grouping: true, exportButton: true, exportFileName: 'exported', headerStyle: { backgroundColor: '#e77f2f', color: '#FFF', }, <span>}</span><span>}</span> isLoading={loading} actions={[ { // Issue: https://github.com/mbrn/material-table/issues/51 //@ts-ignore icon: tableIcons.BarChartIcon, tooltip: 'Show Bar Chart', isFreeAction: true, disabled: loading, onClick: async () => { setShowGraph(!showG) }, }, { // Issue: https://github.com/mbrn/material-table/issues/51 //@ts-ignore icon: tableIcons.Refresh, tooltip: 'Refresh Data', isFreeAction: true, disabled: loading, onClick: async () => { setLoading(true) await getAwsStatus() }, }, ]} title={ <div className="header"> <img src="/awslogo.png" /> <a href="https://aws-health-dashboard.vercel.app/"> AWS Health Dashboard </a> </div> } /> </code></pre> <p>使い方もシンプルかつ比較的高機能でいい感じです。</p> <p>いい感じですが後述するRecoilとの相性問題とDatetimeの扱いが微妙なのがツラミでした。</p> <p>本当はDate型を渡してあげるとSearchableの際、カレンダーでの絞り込みができるのかなと思ったのですが、こちらがうまくいきませんでした。</p> <p>あと、微妙に型もおかしく例えば、actionsはactionを複数指定することができるはずですが、型チェックで怒られるので、仕方なくts-ignoreしてます。</p> <p>あなたが直せばいいじゃんアゼルバイジャンって言われそうですが、めんどくさくなってしまいIssueだけあげてしまいました。申し訳ねぇ...。</p> <p><a target="_blank" rel="nofollow noopener" href="https://github.com/mbrn/material-table/issues/2762">https://github.com/mbrn/material-table/issues/2762</a></p> <h2 id="Recoil"><a href="#Recoil">Recoil</a></h2> <p>RecoilとはReactの新しい状態管理ライブラリで、いわゆるReact HooksでGlobal Storeを作ろうというものです。</p> <p>基本的な使い方はまず、storeとしてatomという共有ステートを作成します。</p> <p>atomのkeyはプロジェクトで一意にする必要がありますが、今回はそこまで大規模なプロジェクトではないのでawsとかいうクソ名をつけてます。</p> <p>storeなので、store/aws.ts として格納します。</p> <pre><code class="typescript">import { atom } from 'recoil' const awsState = atom({ key: 'aws', default: [ { // eslint-disable-next-line @typescript-eslint/camelcase service_name: 'Auto Scaling (N. Virginia)', summary: '[RESOLVED] Example Error', date: '1542849575', status: '1', details: '', description: 'The issue has been resolved and the service is operating normally.', service: 'autoscaling', region: 'us-east-1', }, ], dangerouslyAllowMutability: true, }) export default awsState </code></pre> <p>次にステートを共有したいコンポーネントのルートにRecoilRootを設置します。</p> <p>Next.jsの場合、_app.tsxが全ページのルートにあたるのでここに置けばいいですね。</p> <pre><code class="typescript">import { AppProps } from 'next/app' import Head from 'next/head' import { RecoilRoot } from 'recoil' import React from 'react' const App = ({ Component, pageProps }: AppProps) => ( <> <RecoilRoot> <Head> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" /> <title>AWS Health Dashboard</title> </Head> <Component {...pageProps} /> </RecoilRoot> </> ) export default App </code></pre> <p>そして、利用するときはuseRecoilStateをReact Hooksのように利用するだけです。簡単ですね。</p> <pre><code class="typescript">import React, { useState, useEffect } from 'react' import { useRecoilState } from 'recoil' import awsState from '../store/aws' import showGraph from '../store/showGraph' import axios from 'axios' import dayjs from 'dayjs' dayjs.extend(utc) dayjs.extend(timezone) const Alert = (props: AlertProps) => { return <MuiAlert elevation={6} variant="filled" {...props} /> } export const Table = (): JSX.Element => { // 20200112: dangerouslyAllowMutabilityでできた const [aws, setAws] = useRecoilState(awsState) const [showG, setShowGraph] = useRecoilState(showGraph) const [loading, setLoading] = useState(true) const [slackBarOpen, setSlackBarOpen] = React.useState(false) const [apiErrorMsg, setApiErrorMsg] = React.useState('') useEffect(() => { getAwsStatus() setLoading(false) }, []) const getAwsStatus = () => { axios .get('/api/aws') .then((resp) => { setAws(resp.data) setLoading(false) }) .catch((error) => { console.error(error.response) setSlackBarOpen(true) setApiErrorMsg(error.response.statusText || 'Error') setAws([]) setLoading(false) }) } </code></pre> <p>stateの読み込みはgetterから、書き込みはsetterから行います。</p> <p>React Hooksに慣れていれば簡単ですね。</p> <h2 id="思わぬ落とし穴 Material TablesでRecoilが使えない"><a href="#%E6%80%9D%E3%82%8F%E3%81%AC%E8%90%BD%E3%81%A8%E3%81%97%E7%A9%B4+Material+Tables%E3%81%A7Recoil%E3%81%8C%E4%BD%BF%E3%81%88%E3%81%AA%E3%81%84">思わぬ落とし穴 Material TablesでRecoilが使えない</a></h2> <p>Recoilのatomは基本値の書き換えはset stateを使うことが求められます。ですが、material tablesはテーブルを作るときにdataにIDの書き込みが発生するようでそのままだと怒られてしまいます。</p> <pre><code>Cannot add property tableData, object is not extensible </code></pre> <p>これの解決策はRecoilにstateへの直接的な書き換えを許可することです。こちらはatomのoptionでdangerouslyAllowMutabilityを有効にすることで解決できます。</p> <pre><code class="typescript">import { atom } from 'recoil' const awsState = atom({ key: 'aws', default: [ { }, ], dangerouslyAllowMutability: true, }) </code></pre> <p>これがわかるのに半日くらい使っちまいました。</p> <h2 id="Chart.js"><a href="#Chart.js">Chart.js</a></h2> <p>さて、無事にRecoilでstateの共有ができたのでせっかくなので別コンポーネントも作ります。</p> <p>意味があるかどうか不明ですが、AWSの障害発生状況を可視化してみようと思います。</p> <p>ということで、採用したのがChart.js。</p> <p>次のようにデータを渡すだけできれいめなグラフを書いてくれます。</p> <pre><code class="typescript">import { useRecoilValue } from 'recoil' import awsState from '../store/aws' import React from 'react' import { regionNameMapping, } from './const' import BarGraph from './barGraph' export const AlertPerRegion = (): JSX.Element => { const aws = useRecoilValue(awsState) const labels = Array.from( new Set(aws.map((data) => regionNameMapping[data.region])) ) const data = [] for (const r of labels) { data.push( aws .map((data) => regionNameMapping[data.region]) .reduce((total, x) => { return x === r ? total + 1 : total }, 0) ) } return ( <div className="container"> <BarGraph labels={labels} data={data} title="Alert per region" /> </div> ) } </code></pre> <p>どうでもいい実装ですが、各グラフを一覧で見れる画面を用意し、実際のグラフは遷移先で表示するようにしてます。</p> <p><img src="https://i.imgur.com/tfnpq4w.png" alt="img" /></p> <p><img src="https://i.imgur.com/hpJ70fR.png" alt="img" /></p> <h2 id="Vercelにデプロイ"><a href="#Vercel%E3%81%AB%E3%83%87%E3%83%97%E3%83%AD%E3%82%A4">Vercelにデプロイ</a></h2> <p>さて、実装ができたので後はVercelにあげるだけです。</p> <p>もうここはほとんど書くことがないのですが、Next.jsで作ったアプリケーションはVercelでレポジトリと使っているフレームワークを設定するだけで簡単にデプロイ出来てしまいます。</p> <p>これはすごい。</p> <h2 id="完成"><a href="#%E5%AE%8C%E6%88%90">完成</a></h2> <p>ということで、AWS Health Dashboardが完成しました。</p> <p>アクセスすると、Next.jsのapiをコールし、AWSのstatusを取得加工したものを返却します。</p> <p>フロントでは受け取ったデータをRecoilのatomに格納しつつ、material tableで表として描画します。</p> <p>また右上のグラフボタンを押すことで色々な切り口の可視化を行うことができます。</p> <p><a target="_blank" rel="nofollow noopener" href="https://aws-health-dashboard.vercel.app/">https://aws-health-dashboard.vercel.app/</a></p> <p>できれば使う場面にならないことを祈りつつ、ご活用いただければとおもいます。</p> <h2 id="まとめ"><a href="#%E3%81%BE%E3%81%A8%E3%82%81">まとめ</a></h2> <p>食わず嫌いでやらなかったNext.js+Recoilをやってみましたが、楽しく実装ができました。</p> <h2 id="2021/02/20追記"><a href="#2021%2F02%2F20%E8%BF%BD%E8%A8%98">2021/02/20追記</a></h2> <p>2021/02/19~20にかけて起きた<a target="_blank" rel="nofollow noopener" href="https://status.aws.amazon.com/rss/ec2-ap-northeast-1.rss">AWS EC2障害</a>ですが、本ダッシュボードでは更新がされませんでした。</p> <p>どうやら、data.jsonはRSSとは違い、同期的に更新されないようです。</p> <p>大変ご迷惑をおかけしました。改めて、改修しRSS更新にも対応できるように頑張ります。</p> tubone24 tag:crieit.net,2005:PublicArticle/16694 2021-02-20T15:13:23+09:00 2021-02-20T15:13:43+09:00 https://crieit.net/posts/Netlify-GitHub-Actions-0 Netlifyのビルド時間をGitHub Actionsで0時間にして月末のヒヤヒヤから解放されよう! <h1 id="Netlifyのビルド時間をGitHub Actionsで0時間にして月末のヒヤヒヤから解放されよう!"><a href="#Netlify%E3%81%AE%E3%83%93%E3%83%AB%E3%83%89%E6%99%82%E9%96%93%E3%82%92GitHub+Actions%E3%81%A70%E6%99%82%E9%96%93%E3%81%AB%E3%81%97%E3%81%A6%E6%9C%88%E6%9C%AB%E3%81%AE%E3%83%92%E3%83%A4%E3%83%92%E3%83%A4%E3%81%8B%E3%82%89%E8%A7%A3%E6%94%BE%E3%81%95%E3%82%8C%E3%82%88%E3%81%86%EF%BC%81">Netlifyのビルド時間をGitHub Actionsで0時間にして月末のヒヤヒヤから解放されよう!</a></h1> <h2 id="Netlify"><a href="#Netlify">Netlify</a></h2> <p>みなさんもご存じ超便利ありがたサービスNetlifyですが、<strong>無料で使ってる貧民</strong>には毎月とある悩みがでてきます。</p> <p><em>今月のビルド時間は残り○○分</em></p> <p><img src="https://i.imgur.com/TSm24w0.png" alt="img" /></p> <p>NetlifyはGitHubのレポジトリと連携して、フロントのビルドを実行した上で、デプロイするという超便利機能があるのですが、このビルドを回すのに時間の制約があり、</p> <p>無料民だと月300分となっております。(それ以上はPro版月19ドル課金すれば問題なく使えます。課金も経験済み)</p> <p>300分あれば大丈夫そう、とそう思う気もしなくなくもないですが、</p> <p>複数レポジトリにわたってNetlifyを使っていたり、Gatsby.jsで画像をたくさん使っていて<strong>Sharp</strong>の画像リサイズに時間がかかったり、<strong>Dependabot</strong>で定期的にPRが出てPreview deployが発生したりすると<br /> 案外ぎりぎりだったりします。</p> <p><img src="https://i.imgur.com/y7ixbEG.png" alt="img" /></p> <p>なので、私のような貧民は月末になると、Netlifyのビルド時間が気になって<strong>このブログの記事を書かなくなったり</strong>、<strong>サイトリファクターのペースが落ちて</strong>しまいます。</p> <p>特にブログ更新は顕著で、例えば今書いている記事も通勤の電車の中でスマホから書いているわけなので、細かくコミットを打って保存したいのですが、コミットを打ってプッシュしてしまうと、ビルドが走ることになるので、WIPでのコミットが億劫になり、結果的に家のようなまとめてプッシュできるような作業スペースがある場所でないと、<br /> ブログを書かなくなってしまいました。</p> <p>せっかく<a target="_blank" rel="nofollow noopener" href="https://blog.tubone-project24.xyz/2019-09-01-netlify-and-gatsby#cms%E3%81%AE%E7%AE%A1%E7%90%86%E7%94%BB%E9%9D%A2%E3%82%92%E8%A8%AD%E5%AE%9A%E3%81%99%E3%82%8B">Netlify CMS化</a>した意味がないですね。</p> <h2 id="この悩みGitHub Actionsにお任せください"><a href="#%E3%81%93%E3%81%AE%E6%82%A9%E3%81%BFGitHub+Actions%E3%81%AB%E3%81%8A%E4%BB%BB%E3%81%9B%E3%81%8F%E3%81%A0%E3%81%95%E3%81%84">この悩みGitHub Actionsにお任せください</a></h2> <p>ということでこの悩み、GitHub Actionsで解決してみたいと思います。</p> <p>なんか工務店のCMみたいな表現になってしまいました。</p> <p><img src="https://i.imgur.com/JlvUJ4zl.png" alt="ojisan" /></p> <h2 id="Netlifyのビルド時やっていることを洗い出して自前でやってみる"><a href="#Netlify%E3%81%AE%E3%83%93%E3%83%AB%E3%83%89%E6%99%82%E3%82%84%E3%81%A3%E3%81%A6%E3%81%84%E3%82%8B%E3%81%93%E3%81%A8%E3%82%92%E6%B4%97%E3%81%84%E5%87%BA%E3%81%97%E3%81%A6%E8%87%AA%E5%89%8D%E3%81%A7%E3%82%84%E3%81%A3%E3%81%A6%E3%81%BF%E3%82%8B">Netlifyのビルド時やっていることを洗い出して自前でやってみる</a></h2> <p>基本的にNetlifyがビルド時やってることは、例えばGatsby.jsであれば、gatsby buildコマンドを実行し、特定のディレクトリー(大概は./public)に配置されたビルド済みJSをデプロイする動きなので、<br /> それをそっくりGitHub Actionsに移行すればいいのですが、Netlifyがビルド済みJSに対して後処理(PostProcess)を実行してるパターンもあります。</p> <p>私の場合、JSやイメージを最適化してくれる<strong>Asset optimization</strong>とFormタグに属性をつければ勝手にFormを作ってくれる<strong>Form detection</strong>の二つが設定されていましたのでそれぞれまず無効化します。</p> <p>Form detectionの解説は<a target="_blank" rel="nofollow noopener" href="https://blog.tubone-project24.xyz/2019/09/30/netlify-form">こちら</a>を参照ください。</p> <p><img src="https://i.imgur.com/ytjbJQA.png" alt="img" /></p> <p><img src="https://i.imgur.com/LfL70Br.png" alt="img" /></p> <p>こちら、Netlifyで実施してくれなくなりますので、こちらで実装し直す必要があります。</p> <h2 id="gatsby-plugin-minify"><a href="#gatsby-plugin-minify">gatsby-plugin-minify</a></h2> <p>Asset optimizationのうち、JSやCSSのminiferは<a target="_blank" rel="nofollow noopener" href="https://www.gatsbyjs.com/plugins/gatsby-plugin-minify/">gatsby-plugin-minify</a>を使うことでhtmlやJS、CSSをminifyできます。</p> <p>インストールはいつも通りNPM(yarn)から</p> <pre><code>npm install gatsby-plugin-minify </code></pre> <p>使い方はgatsby-config.jsのpluginsに次のように設定すればできます。</p> <pre><code> { resolve: 'gatsby-plugin-minify', options: { caseSensitive: false, collapseBooleanAttributes: true, useShortDoctype: false, removeEmptyElements: false, removeComments: true, removeAttributeQuotes: false, minifyCSS: true, minifyJS: true, }, }, </code></pre> <p>minifyCSSとminifyJSをtrueにすることにより、CSSについては<a target="_blank" rel="nofollow noopener" href="https://github.com/jakubpawlowicz/clean-css">clean-css</a>、JSについては<a target="_blank" rel="nofollow noopener" href="https://github.com/mishoo/UglifyJS">UglifyJS</a>を使って一緒にminifyされます。また、gatsby-plugin-minifyの裏側は<a target="_blank" rel="nofollow noopener" href="https://github.com/kangax/html-minifier">html-minifier</a>をgatsby-node.jsでpostbuildで全掛けしているだけなので、細かいオプションは<a target="_blank" rel="nofollow noopener" href="https://github.com/kangax/html-minifier#options-quick-reference">html-minifier</a>で設定できる感じです。</p> <p>ちなみに、気を付けないといけないのが<strong>removeAttributeQuotes</strong>のオプションをfalseにすること。</p> <p>これをtrueにすると、HTMLタグ内のアトリビュートにダブルクオートが入らなくなりちょっとファイルが軽くなるのですが、<a target="_blank" rel="nofollow noopener" href="https://berss.com/feed/Find.aspx">berss.com</a>のようにサイトのRSSリンクを取得するようなシステムでうまく読み込めなくなってしまい、サイト更新が最悪通知できなくなってしまう現象が発生しました。</p> <p>これで1日使ってしまった...。</p> <p>RSSのリンクをページのLinkとして仕込んでいる人は要注意です。</p> <h2 id="imgurを使うことで、画像ホスティングとリサイズを同時にやっちゃう"><a href="#imgur%E3%82%92%E4%BD%BF%E3%81%86%E3%81%93%E3%81%A8%E3%81%A7%E3%80%81%E7%94%BB%E5%83%8F%E3%83%9B%E3%82%B9%E3%83%86%E3%82%A3%E3%83%B3%E3%82%B0%E3%81%A8%E3%83%AA%E3%82%B5%E3%82%A4%E3%82%BA%E3%82%92%E5%90%8C%E6%99%82%E3%81%AB%E3%82%84%E3%81%A3%E3%81%A1%E3%82%83%E3%81%86">imgurを使うことで、画像ホスティングとリサイズを同時にやっちゃう</a></h2> <p><a target="_blank" rel="nofollow noopener" href="https://imgur.com/">imgur</a>というサービスがあります。</p> <p>主にRedditとかGifをあげるための画像ホスティングサービスとして有名なのですが、こちらを使うことで簡単に画像のリサイズとホスティングを実現できるため、このブログではimgurを使ってます。</p> <p>画像URLの後ろに画像サイズに合わせたキーワードを入れることで実現できます。</p> <p>例えばこちらのURLの画像を</p> <pre><code>[https://i.imgur.com/Wfz9G0B.png](https://i.imgur.com/Wfz9G0B.png) </code></pre> <p>160x160にリサイズするには後ろに<strong>b</strong>をくっつけます。</p> <pre><code>[https://i.imgur.com/Wfz9G0Bb.png](https://i.imgur.com/Wfz9G0Bb.png) </code></pre> <p>これで、画像最適化も完了です。</p> <h2 id="getform.io"><a href="#getform.io">getform.io</a></h2> <p><a target="_blank" rel="nofollow noopener" href="https://getform.io/">Getform.io</a>はフォームのバックエンドを提供するすばらしいサービスです。</p> <p>便利なインテグレーションを使うには有料版が必要ですが、フォームに投稿されたら指定したメールアドレスに通知メール飛ばす、くらいのことであれば無料でできます。</p> <p>これで、NetlifyのForm detectionを置き換えていきます。</p> <p>まず、新しいフォームを作ると、FormのAction先URLが発行できます。</p> <p>Formの作り方は下記のブログにわかりやすく纏めてあったので参照いただければと思います。</p> <p><a target="_blank" rel="nofollow noopener" href="https://blog.nakamu.life/posts/getform-io">https://blog.nakamu.life/posts/getform-io</a></p> <p>さて、Formができたらチュートリアルに沿ってそのまま、FormタグのactionにこちらのURLを設定してもいいのですが、GetFormは無料版だと、<strong>Form投稿後のThanksページが設定</strong>できません。</p> <pre><code class="html"><!-- * Add your getform endpoint into "action" attribute * Set a unique "name" field * Start accepting submissions --> <form action="{getform-endpoint}" method="POST"> <input type="text" name="name"> <input type="email" name="email"> <button type="submit">Send</button> </form> </code></pre> <p><img src="https://i.imgur.com/sT5vhFE.png" alt="img" /></p> <p>まぁこれでも十分なのですが、せっかく<strong>React</strong>を使ってるので、裏側でgetform.ioのURLをPOST fetchしながら、actionsで定義した自分のThanks URLに飛ばすように指定しましょう。</p> <p>まずは、Formに<strong>onSubmit</strong>を設定します。</p> <p>```typescript{numberLines: 1}{5}<br /> <form name="contact" method="post" action="/thanks/" onSubmit={this.handleSubmit} ><br /> <label><br /> <span className="icon-user" /> Your name<br /><br /> <input type="text" name="name" className="form-control" maxLength="30" minLength="2" required placeholder="Enter your name" onChange={this.handleChange} /><br /> </label><br /> </p></p> <pre><code><br />そして、別途にonSubmitで発火する関数を定義します。 ```typescript handleSubmit(e) { e.preventDefault(); const form = e.target; fetch('https://getform.io/f/xxxxxxxxxxxxxxxxxxxxxxxxx', { method: 'POST', body: Contact.encode({ 'form-name': form.getAttribute('name'), ...this.state, }), }) } </code></pre> <p>Formの送信なので、fetchでは<a target="_blank" rel="nofollow noopener" href="https://developer.mozilla.org/ja/docs/Web/API/FormData">FormData</a>に要素をappendしたものを送信しないといけません。</p> <pre><code> static encode(data) { const formData = new FormData(); // eslint-disable-next-line no-restricted-syntax for (const key of Object.keys(data)) { formData.append(key, data[key]); } return formData; } </code></pre> <p>繰り返しになりますがReactではFormで、actionのほか、onSubmitを関数としてすることができます。</p> <p>ただし、onSubmitが押されたタイミングで、Formの入力項目をPOST Fetchで渡さないといけないので、Formの入力で発生するchangeEventごとに、Formの値をstateとして保存しておくようにします。</p> <p>```typescript{numberLines: 1}{1-7,21}<br /> handleChange(e) {<br /> this.setState({ [e.target.name]: e.target.value });<br /> }</p> <p>handleAttachment(e) {<br /> this.setState({ [e.target.name]: e.target.files[0] });<br /> }</p> <p>(中略)</p> <pre><code> <label> <span className="icon-user" />&nbsp;Your name<br /> <input type="text" name="name" className="form-control" maxLength="30" minLength="2" required placeholder="Enter your name" onChange={this.handleChange} /> </label> </p> </code></pre> <pre><code><br />また、onSubmitを使ってしまうと、Form規定のactionでは飛ばなくなるので自前でGatsbyのnavigateを使ってPost処理が終わったらThanksページに飛ぶようにします。 ```typescript{numberLines: 1}{11-12} handleSubmit(e) { e.preventDefault(); const form = e.target; fetch('https://getform.io/f/897f187e-876d-42a7-b300-7c235af72e6d', { method: 'POST', body: Contact.encode({ 'form-name': form.getAttribute('name'), ...this.state, }), }) .then(() => navigateTo(form.getAttribute('action'))) .catch((error) => alert(error)); } </code></pre> <p>これでGetForm無料版でも自前のThanksページを作ることができます。</p> <p><img src="https://i.imgur.com/gumRkbF.png" alt="img" /></p> <h2 id="GitHub Actionsでビルドとデプロイ"><a href="#GitHub+Actions%E3%81%A7%E3%83%93%E3%83%AB%E3%83%89%E3%81%A8%E3%83%87%E3%83%97%E3%83%AD%E3%82%A4">GitHub Actionsでビルドとデプロイ</a></h2> <p>ここまで来たらあとはGitHub Actionsでビルドとデプロイを行うだけです。</p> <p>masterブランチへのPRでPreviewデプロイ、masterへのコミットで本番デプロイをするように2つactionsを作ります。</p> <p>まずはPreviewデプロイ</p> <pre><code class="yaml">name: DeployToNetlifyPreview on: pull_request: branches: - master jobs: build: runs-on: ubuntu-latest steps: - name: Checkout source code uses: actions/checkout@v2 - name: Cache node_modules uses: actions/cache@v1 with: path: node_modules key: $<span>{</span><span>{</span> runner.OS <span>}</span><span>}</span>-build-$<span>{</span><span>{</span> hashFiles('**/package-lock.json') <span>}</span><span>}</span> restore-keys: | $<span>{</span><span>{</span> runner.OS <span>}</span><span>}</span>-build- $<span>{</span><span>{</span> runner.OS <span>}</span><span>}</span> - name: Setup Node uses: actions/setup-node@v1 with: node-version: 12.x - name: npm install and build env: GATSBY_GITHUB_CLIENT_SECRET: $<span>{</span><span>{</span>secrets.GATSBY_GITHUB_CLIENT_SECRET<span>}</span><span>}</span> GATSBY_GITHUB_CLIENT_ID: $<span>{</span><span>{</span>secrets.GATSBY_GITHUB_CLIENT_ID<span>}</span><span>}</span> GATSBY_ALGOLIA_SEARCH_API_KEY: $<span>{</span><span>{</span>secrets.GATSBY_ALGOLIA_SEARCH_API_KEY<span>}</span><span>}</span> GATSBY_ALGOLIA_INDEX_NAME: $<span>{</span><span>{</span>secrets.GATSBY_ALGOLIA_INDEX_NAME<span>}</span><span>}</span> GATSBY_ALGOLIA_APP_ID: $<span>{</span><span>{</span>secrets.GATSBY_ALGOLIA_APP_ID<span>}</span><span>}</span> GATSBY_ALGOLIA_ADMIN_API_KEY: $<span>{</span><span>{</span>secrets.GATSBY_ALGOLIA_ADMIN_API_KEY<span>}</span><span>}</span> FAUNADB_SERVER_SECRET: $<span>{</span><span>{</span>secrets.FAUNADB_SERVER_SECRET<span>}</span><span>}</span> run: | npm install npm run build - name: Deploy to netlify run: npx netlify-cli deploy --dir=./public > cli.txt env: NETLIFY_AUTH_TOKEN: $<span>{</span><span>{</span> secrets.NETLIFY_AUTH_TOKEN <span>}</span><span>}</span> NETLIFY_SITE_ID: $<span>{</span><span>{</span> secrets.NETLIFY_SITE_ID <span>}</span><span>}</span> - name: Cat cli.txt run: | cat cli.txt sed -i -z 's/\n/\\n/g' cli.txt - name: Post Netlify CLI Comment env: GITHUB_TOKEN: $<span>{</span><span>{</span> secrets.GITHUB_TOKEN <span>}</span><span>}</span> URL: $<span>{</span><span>{</span> github.event.pull_request.comments_url <span>}</span><span>}</span> run: | curl -X POST \ -H "Authorization: token ${GITHUB_TOKEN}" \ -d "{\"body\": \"$(cat cli.txt)\"}" \ ${URL} </code></pre> <p>node setupやnpm install, buildはいつも通りです。</p> <p>GitHub ActionsではSecretを指定することができますので、Algolia searchやFaunaDBのAPIキーはシークレットとしてビルド時の環境変数で渡してます。</p> <p>ちなみに、環境変数で<strong>GATSBY_XXXX</strong>としておくと、ビルドされたJSにも環境変数が入る形になります。(JSから環境変数を使う場合はこれを忘れないこと。)これ結構詰まるポイント。</p> <p>デプロイには<a target="_blank" rel="nofollow noopener" href="https://docs.netlify.com/cli/get-started/">netlify-cli</a>を使います。</p> <p>必要な環境変数はサイトIDとAUTH TOKENです。</p> <p>ちょっと特徴として、netlify-cliでデプロイが成功すると、<strong>デプロイURLが標準出力</strong>に出ますので、それをいったん適当なtextファイルに書き出し、</p> <p>PRコメントにもURLを送るようにしています。</p> <p>GitHub Actionsの素晴らしいところは、GITHUB TOKENについては、特に設定しなくてもsecrets.GITHUB_TOKENで取り出すことができますので簡単にPRコメントに送信できます。</p> <pre><code class="yaml"> - name: Deploy to netlify run: npx netlify-cli deploy --dir=./public > cli.txt env: NETLIFY_AUTH_TOKEN: $<span>{</span><span>{</span> secrets.NETLIFY_AUTH_TOKEN <span>}</span><span>}</span> NETLIFY_SITE_ID: $<span>{</span><span>{</span> secrets.NETLIFY_SITE_ID <span>}</span><span>}</span> - name: Cat cli.txt run: | cat cli.txt sed -i -z 's/\n/\\n/g' cli.txt - name: Post Netlify CLI Comment env: GITHUB_TOKEN: $<span>{</span><span>{</span> secrets.GITHUB_TOKEN <span>}</span><span>}</span> URL: $<span>{</span><span>{</span> github.event.pull_request.comments_url <span>}</span><span>}</span> run: | curl -X POST \ -H "Authorization: token ${GITHUB_TOKEN}" \ -d "{\"body\": \"$(cat cli.txt)\"}" \ ${URL} </code></pre> <p>次に本番へのデプロイです。</p> <pre><code class="yaml">name: DeployToNetlifyPRD on: push: branches: - master jobs: build: runs-on: ubuntu-latest steps: - name: Checkout source code uses: actions/checkout@v2 - name: Cache node_modules uses: actions/cache@v1 with: path: node_modules key: $<span>{</span><span>{</span> runner.OS <span>}</span><span>}</span>-build-$<span>{</span><span>{</span> hashFiles('**/package-lock.json') <span>}</span><span>}</span> restore-keys: | $<span>{</span><span>{</span> runner.OS <span>}</span><span>}</span>-build- $<span>{</span><span>{</span> runner.OS <span>}</span><span>}</span> - name: Setup Node uses: actions/setup-node@v1 with: node-version: 12.x - name: npm install and build env: GATSBY_GITHUB_CLIENT_SECRET: $<span>{</span><span>{</span>secrets.GATSBY_GITHUB_CLIENT_SECRET<span>}</span><span>}</span> GATSBY_GITHUB_CLIENT_ID: $<span>{</span><span>{</span>secrets.GATSBY_GITHUB_CLIENT_ID<span>}</span><span>}</span> GATSBY_ALGOLIA_SEARCH_API_KEY: $<span>{</span><span>{</span>secrets.GATSBY_ALGOLIA_SEARCH_API_KEY<span>}</span><span>}</span> GATSBY_ALGOLIA_INDEX_NAME: $<span>{</span><span>{</span>secrets.GATSBY_ALGOLIA_INDEX_NAME<span>}</span><span>}</span> GATSBY_ALGOLIA_APP_ID: $<span>{</span><span>{</span>secrets.GATSBY_ALGOLIA_APP_ID<span>}</span><span>}</span> GATSBY_ALGOLIA_ADMIN_API_KEY: $<span>{</span><span>{</span>secrets.GATSBY_ALGOLIA_ADMIN_API_KEY<span>}</span><span>}</span> FAUNADB_SERVER_SECRET: $<span>{</span><span>{</span>secrets.FAUNADB_SERVER_SECRET<span>}</span><span>}</span> run: | npm install npm run build - name: Deploy to netlify run: npx netlify-cli deploy --prod --dir=./public env: NETLIFY_AUTH_TOKEN: $<span>{</span><span>{</span> secrets.NETLIFY_AUTH_TOKEN <span>}</span><span>}</span> NETLIFY_SITE_ID: $<span>{</span><span>{</span> secrets.NETLIFY_SITE_ID <span>}</span><span>}</span> </code></pre> <p>ほとんど同じですが、netlify-cliでdeployコマンドに --prodオプションを入れることで、本番環境へデプロイされます。</p> <pre><code class="yaml"> - name: Deploy to netlify run: npx netlify-cli deploy --prod --dir=./public env: NETLIFY_AUTH_TOKEN: $<span>{</span><span>{</span> secrets.NETLIFY_AUTH_TOKEN <span>}</span><span>}</span> NETLIFY_SITE_ID: $<span>{</span><span>{</span> secrets.NETLIFY_SITE_ID <span>}</span><span>}</span> </code></pre> <h2 id="結論"><a href="#%E7%B5%90%E8%AB%96">結論</a></h2> <p>これで、Netlifyのビルド時間は0になり、精神的に安心できるようになりました。</p> <p><img src="https://i.imgur.com/ugdUr9l.png" alt="img" /></p> <p>リファクタや記事の執筆もはかどっていいですね!!</p> tubone24