GreenSnap TECH BLOG GreenSnapのエンジニアチームの取り組みや使っている技術を紹介します 2023-12-17T22:37:46+09:00 greensnap-tech Hatena::Blog hatenablog://blog/26006613696017271 AmazonLinux2にメールサーバーを構築する(SPF,DKIM,DMARC,TLS対応) hatenablog://entry/6801883189066301691 2023-12-17T22:37:46+09:00 2023-12-17T22:37:46+09:00 2024年2月に変更されるGoogleのメール送信者ガイドラインに対応するため、AWSのEC2インスタンス上でSPF、DKIM、DMARC、TLSをサポートするメールサーバーを構築する方法について解説しています。具体的な手順には、EC2インスタンスの設定、逆引きレコードの設定、Postfixのインストールと設定、SPFレコード、DKIM、DMARCの設定、Let's Encryptを使用したTLS化を行います。 <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/masahide318/20231213/20231213003220.png" width="1200" height="686" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2 id="はじめに">はじめに</h2> <p>2024年2月より、Googleのメール送信者のガイドラインが変更になります。<br/> Gmailアカウントに1日あたり5,000件を超えるメールを送信する送信者は、送信ドメインにSPFレコード・DKIM署名・DMARCメール認証の設定が必要になりました。<br/> <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fsupport.google.com%2Fmail%2Fanswer%2F81126%3Fhl%3Dja%23requirements-5k%26zippy%3D%252C%25E6%2597%25A5%25E3%2581%2582%25E3%2581%259F%25E3%2582%258A-%25E4%25BB%25B6%25E4%25BB%25A5%25E4%25B8%258A%25E3%2581%25AE%25E3%2583%25A1%25E3%2583%25BC%25E3%2583%25AB%25E3%2582%2592%25E9%2580%2581%25E4%25BF%25A1%25E3%2581%2599%25E3%2582%258B%25E5%25A0%25B4%25E5%2590%2588%25E3%2581%25AE%25E8%25A6%2581%25E4%25BB%25B6" title="メール送信者のガイドライン - Gmail ヘルプ" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://support.google.com/mail/answer/81126?hl=ja#requirements-5k&zippy=%2C%E6%97%A5%E3%81%82%E3%81%9F%E3%82%8A-%E4%BB%B6%E4%BB%A5%E4%B8%8A%E3%81%AE%E3%83%A1%E3%83%BC%E3%83%AB%E3%82%92%E9%80%81%E4%BF%A1%E3%81%99%E3%82%8B%E5%A0%B4%E5%90%88%E3%81%AE%E8%A6%81%E4%BB%B6">support.google.com</a></cite></p> <p>1日あたり5,000件を超えるという条件に入っていなくとも、迷惑メールとして受信される確率を減らすために対応しておいたほうが良いでしょう。<br/> この記事では、クラウドメール配信のSaaSが使用できない方向けに、AWSのEC2インスタンス上でSPF、DKIM、DMARC、TLSに対応したメールサーバーを構築する方法を紹介します。</p> <p>やることのフローとしては以下の項目になります。</p> <ul> <li>メールサーバー用のEC2インスタンスの立ち上げ</li> <li>逆引きレコードの設定</li> <li>postfixのインストール</li> <li>SPFレコードの設定</li> <li>DKIMの設定</li> <li>DMARCの設定</li> <li>Let's EncryptでTLS化</li> <li>設定がうまくいってるか確認</li> <li>トラブルシューティング</li> </ul> <h2 id="EC2インスタンスを立ち上げる">EC2インスタンスを立ち上げる</h2> <p>まずはじめにメールサーバーとして稼働させるEC2インスタンスをAWSのコンソールから立ち上げてください。<br/> 本記事ではAmazonLinux2、インスタンスタイプを「c5.large」を前提に進めます。<br/> メールサーバーの負荷に応じてインスタンスのサイズは変更してください。</p> <h2 id="逆引きレコードの設定">逆引きレコードの設定</h2> <p>メールサーバーに逆引きレコードを設定するため、Elastic IPを割り当てます。 <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/masahide318/20231210/20231210225326.png" width="909" height="336" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>次に、Route53からメールのFromドメインとなるホストゾーンを作成します。本記事ではホストゾーンの記述を「xxxxx.xxx」にしているので環境に合わせて変更してください。<br/> 作成したホストゾーンにAレコードを追加します。</p> <p>レコード名にサンプルとして「mail」値にはElastic IPのPublicIPアドレスを入力してください。<br/> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/masahide318/20231211/20231211002756.png" width="1083" height="713" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>再びElastic IPの設定画面へ行き、先程EC2インスタンスに関連付けたElastic IPの<br/> 「逆引きDNSを更新」をします。<br/> Route53の反映に時間がかかるかもしれませんが、成功すれば「逆引きレコードDNSレコード」の項目が入力されます。 <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/masahide318/20231210/20231210231834.png" width="981" height="175" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2 id="postfixのインストール">postfixのインストール</h2> <p>これより以下に登場するコマンドはすべてrootユーザーで実行することを想定しています。<br/> 以下のコマンドを実行し、Postfixをインストールします。Amazon Linux 2では通常、Postfixがプリインストールされていますので必要ないかもしれません。</p> <pre class="code" data-lang="" data-unlink>yum update yum install postfix systemctl start postfix systemctl enable postfix</pre> <h3 id="etcpostfixmaincfの編集">/etc/postfix/main.cfの編集</h3> <p>Postfixの初期設定を行います。最低限以下の部分を設定してください。</p> <pre class="code" data-lang="" data-unlink>myhostname = mail.xxxxx.xxx(逆引きDNS名) mydomain = xxxxx.xxx(Route53のホストゾーンのドメイン名) mydestination = $myhostname, localhost mynetworks = 127.0.0.0/8, XXX.XXX.XXX.XXX(メールサーバーと連携するEC2インスタンスなどがあればそのIPアドレスやVPCのCIDRなど) </pre> <h2 id="SPFレコードの追加">SPFレコードの追加</h2> <p>Route53のホストゾーンにTXTレコードを以下のように追加します。SPFの対応はこれだけで完了です。 <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/masahide318/20231212/20231212232442.png" width="1125" height="716" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2 id="DKIMの設定">DKIMの設定</h2> <p>opendikmを利用して、秘密鍵と公開鍵を作成します。</p> <pre class="code" data-lang="" data-unlink>amazon-linux-extras install epel -y yum install opendkim opendkim-tools -y systemctl start opendikm systemctl enable opendkim</pre> <pre class="code" data-lang="" data-unlink>鍵を作成するディレクトリを作成 mkdir /etc/opendkim/keys/xxxxx.xxx cd /etc/opendkim/keys/xxxxx.xxx 鍵を作成 opendkim-genkey -d xxxxx.xxx -s mail オーナー変更 chown -R opendkim:opendkim /etc/opendkim/keys</pre> <p>/etc/opendkim/keys/xxxxx.xxx/ ディレクトリにdefault.privateとdefault.txtが作成されていれば成功です。</p> <h3 id="etcopendkimconfの編集">/etc/opendkim.confの編集</h3> <pre class="code" data-lang="" data-unlink>Mode sv ← svに変更 #KeyfileではなくKeyTableを利用する。 #KeyFile /etc/opendkim/keys/default.private ←コメントアウト KeyTable refile:/etc/opendkim/KeyTable ←コメントイン #署名するドメインの指定を可能とする。 SigningTable refile:/etc/opendkim/SigningTable ←コメントイン #外部ホストの特定 ExternalIgnoreList refile:/etc/opendkim/TrustedHosts ←コメントイン #内部ホストの特定 InternalHosts refile:/etc/opendkim/TrustedHosts ←コメントイン </pre> <h3 id="etcopendkimKeyTableファイルの編集">/etc/opendkim/KeyTableファイルの編集</h3> <pre class="code" data-lang="" data-unlink># OPENDKIM KEY TABLE # To use this file, uncomment the #KeyTable option in /etc/opendkim.conf, # then uncomment the following line and replace example.com with your domain # name, then restart OpenDKIM. Additional keys may be added on separate lines. mail._domainkey.xxxxx.xxx xxxxx.xxx:mail:/etc/opendkim/keys/xxxxx.xxx/mail.private</pre> <h3 id="etcopendkimSigningTableファイルの編集">/etc/opendkim/SigningTableファイルの編集</h3> <p>SigningTableには電子署名を行う送信元アドレスを指定します。</p> <pre class="code" data-lang="" data-unlink>*@xxxxx.xxx mail._domainkey.xxxxx.xxx ←追加</pre> <h3 id="etcopendkimTrustedHostsファイルを必要に応じて編集">/etc/opendkim/TrustedHostsファイルを必要に応じて編集</h3> <p>メールサーバー自身がメールを送信する場合は編集不要です。<br/> 他サーバーからSMTPでメールを送信する場合は必要です。</p> <pre class="code" data-lang="" data-unlink># OPENDKIM TRUSTED HOSTS # To use this file, uncomment the #ExternalIgnoreList and/or the #InternalHosts # option in /etc/opendkim.conf then restart OpenDKIM. Additional hosts # may be added on separate lines (IP addresses, hostnames, or CIDR ranges). # The localhost IP (127.0.0.1) should always be the first entry in this file. 127.0.0.1 #メール送信を受け付ける他のEC2インスタンスのホストやVPCのCIDRなど必要に応じて追加してください xxx.xxx.xxx.xxx ::1 #host.example.com #192.168.1.0/24</pre> <p>opendkim再起動</p> <pre class="code" data-lang="" data-unlink>systemctl reload opendkim</pre> <h3 id="etcpostfixmaincfの編集-1">/etc/postfix/main.cfの編集</h3> <p>/etc/postfix/main.cfに以下を追加</p> <pre class="code" data-lang="" data-unlink>smtpd_milters = inet:127.0.0.1:8891 non_smtpd_milters = inet:localhost:8891 milter_default_action = accept</pre> <p>postfixの再起動</p> <pre class="code" data-lang="" data-unlink>systemctl reload postfix</pre> <h3 id="Route53にDKIMのレコード追加">Route53にDKIMのレコード追加</h3> <pre class="code" data-lang="" data-unlink>DKIMのkeyを確認 cat /etc/opendkim/keys/xxxxx.xxx/default.txt</pre> <p>以下のようなテキストがあるのでカッコの中身をRoute53に追加</p> <pre class="code" data-lang="" data-unlink>mail._domainkey IN TXT ( &#34;v=DKIM1; k=rsa; &#34; &#34;p=*********************************&#34; ) ; ----- DKIM key default for xxxxx.xxx</pre> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/masahide318/20231211/20231211003100.png" width="1058" height="703" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2 id="DMARCの設定">DMARCの設定</h2> <p>Route53にレコードをTXTレコードを追加するだけです。</p> <p>DMARCレコードは、以下の要素で構成されます:</p> <ul> <li>バージョン(v): DMARCのバージョンを指定します。通常は "DMARC1" と記載されます。</li> <li>ポリシー(p): ドメインのメールがDMARC検証に合格しなかった場合の取扱いを指定します。「none」、「quarantine」、「reject」のいずれかを設定できます。</li> <li>SPFとDKIMの両方に合格する必要性(adkim、aspf): これらのタグは、SPFとDKIMがどの程度厳密にチェックされるべきかを指定します。通常は "r"(リラックス)または "s"(ストリクト)で指定されます。</li> <li>サブドメインポリシー(sp): メインドメインとは異なるポリシーをサブドメインに適用する場合に使用します。</li> <li>パーセンテージ(pct): ポリシーを適用するメールの割合を指定します。</li> <li>レポートの送信先(rua、ruf): DMARC集計レポートと失敗レポートの受信先をメールアドレスで指定します。</li> </ul> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/masahide318/20231211/20231211004923.png" width="1087" height="721" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2 id="Lets-EncryptでTLS化">Let's EncryptでTLS化</h2> <p>メールをTLS化します。<br/> 必要に応じて、セキュリティグループから80 と 443 ポートを許可してください。 もしかしたら環境によっては工夫が必要なところかもしれませんが何とかして発行します。</p> <pre class="code" data-lang="" data-unlink>yum install certbot certbot certonly --standalone -d xxxxx.xxx</pre> <p>成功すれば2つのファイルが作成されます。 - /etc/letsencrypt/live/xxxxx.xxx/fullchain.pem - /etc/letsencrypt/live/xxxxx.xxx/privkey.pem</p> <h3 id="etcpostfixmaincfの編集-2">/etc/postfix/main.cfの編集</h3> <p>以下を追加する</p> <pre class="code" data-lang="" data-unlink>smtpd_tls_cert_file = /etc/letsencrypt/live/xxxxx.xxx/fullchain.pem smtpd_tls_key_file = /etc/letsencrypt/live/xxxxx.xxx/privkey.pem smtpd_use_tls = yes smtp_tls_security_level = may smtpd_tls_security_level = may</pre> <p>postfixの再起動</p> <pre class="code" data-lang="" data-unlink>systemctl reload postfix</pre> <h2 id="設定がうまくいっているか確認">設定がうまくいっているか確認</h2> <p>ここまで駆け足で設定して来ましたが最後に実際にメールをメールサーバー経由で送信して設定がうまくいっているか確認します。 実際にメールサーバー経由で送信されたメールを試しにGmailアカウントへ送信し、受信したメールのソースを確認します。<br/> SPF、DKIM、DMARCがそれぞれPASSになっていれば成功です。 <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/masahide318/20231212/20231212234258.png" width="986" height="399" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span> また暗号化のアイコンが 灰色(TLS - 標準的な暗号化)TLS(標準的な暗号化)になっていること確認します。 <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/masahide318/20231213/20231213000323.png" width="583" height="346" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span> <a href="https://support.google.com/mail/answer/6330403?visit_id=638378214817835141-2897860703&p=tls&hl=ja&rd=1">&#x9001;&#x53D7;&#x4FE1;&#x6642;&#x306E;&#x30E1;&#x30FC;&#x30EB;&#x306E;&#x6697;&#x53F7;&#x5316; - Gmail &#x30D8;&#x30EB;&#x30D7;</a></p> <h2 id="トラブルシューティング">トラブルシューティング</h2> <h4 id="メールが送信されない">メールが送信されない</h4> <ul> <li><code>/var/log/maillog</code> を確認してエラーが出ていないか確認してください。エラー内容に沿って修正してください</li> <li><code>ps aux | grep postfix</code> でpostfixのプロセスがあるか確認してください。postfixを起動してください</li> <li>他のサーバーのアプリケーションからプログラム上でSMTPでメールサーバーと連携している場合、mynetworksにそのアプリケーションのサーバーのプライベートIPアドレスの記述があるか確認してください</li> </ul> <h4 id="SPFがPASSにならない">SPFがPASSにならない</h4> <ul> <li>Route53にSPFレコードが追加されているか確認してください</li> </ul> <h4 id="DKIMがPASSにならない">DKIMがPASSにならない</h4> <ul> <li><code>/var/log/maillog</code> で <code>opendkim not authenticated</code> のようなログが出ているか確認してください。でている場合は何かしら設定がおかしいはずです</li> <li>Route53 に DKIMのレコードが追加されているか確認してください</li> <li><code>/etc/postfix/main.cf</code> にsmtpd_miltersやnon_smtpd_miltersの設定があるか確認してください</li> <li>他のサーバーのアプリケーションからプログラム上でSMTPでメールサーバーと連携している場合、TrustedHostsにアプリケーションのサーバーのPrivateIPアドレスの記述があるか確認してください</li> <li>SigningTableに記述しているドメイン名とメールの送信元ドメインが正しいか確認してください</li> </ul> <h4 id="DMARCがPASSにならない">DMARCがPASSにならない</h4> <ul> <li>Route53にDMARCのレコードが追加されているか確認してください</li> </ul> <h2 id="さいごに">さいごに</h2> <p>自前でメールサーバーを用意するのは大変なので、可能ならはじめからクラウドメール配信のSaaSを利用したり、AWSのSESを利用することをおすすめします</p> <h2 id="参考資料">参考資料</h2> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.rem-system.com%2Fdkim-postfix04%2F" title="CentOSのPostfixで迷惑メール判定されないようDKIMを設定する" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.rem-system.com/dkim-postfix04/">www.rem-system.com</a></cite></p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fqiita.com%2FSabuuuuu---nn%2Fitems%2Ffc5448b7f5e3057365a1" title="自ドメインのDNSとPostfixをSPF/DKIM対応させてみた - Qiita" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://qiita.com/Sabuuuuu---nn/items/fc5448b7f5e3057365a1">qiita.com</a></cite></p> masahide318 Shopifyの在庫切れ通知に返信して在庫を追加する hatenablog://entry/4207112889900168484 2023-04-10T11:22:41+09:00 2023-04-10T11:26:39+09:00 初めまして、GreenSnapのエンジニアの澤田です。 以前、当ブログにてご紹介したshopifyの在庫切れ通知によって、リアルタイムでどの商品が売り切れたか把握することが可能になりました。 ですが商品が売り切れる度に、担当者がshopifyにログインし在庫変更の操作をする必要がありました。 こちらが運用上の負担となっていたため、在庫切れ通知機能を拡張しslack上からshopifyへ在庫を変更できる機能を実装しました。 今回はそちらについてご紹介したいと思います。 在庫切れ通知の実装手順は下記の記事を参照ください。 greensnap-tech.hatenablog.com 仕様 在庫切れ通… <p>初めまして、GreenSnapのエンジニアの澤田です。</p> <p>以前、当ブログにてご紹介したshopifyの在庫切れ通知によって、リアルタイムでどの商品が売り切れたか把握することが可能になりました。</p> <p>ですが商品が売り切れる度に、担当者がshopifyにログインし在庫変更の操作をする必要がありました。 こちらが運用上の負担となっていたため、在庫切れ通知機能を拡張しslack上からshopifyへ在庫を変更できる機能を実装しました。</p> <p>今回はそちらについてご紹介したいと思います。 在庫切れ通知の実装手順は下記の記事を参照ください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgreensnap-tech.hatenablog.com%2Fentry%2F2021%2F05%2F18%2F131801" title="shopifyに在庫切れ通知を実装して在庫管理の運用改善 - GreenSnap TECH BLOG" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://greensnap-tech.hatenablog.com/entry/2021/05/18/131801">greensnap-tech.hatenablog.com</a></cite></p> <h4 id="仕様">仕様</h4> <p>在庫切れ通知に対して数値をリプライ。 スレッド返信された数値分の在庫が追加される。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/b/bigJury/20230410/20230410102436.png" width="798" height="1052" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/b/bigJury/20230410/20230410102451.png" width="1200" height="184" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h4 id="手順">手順</h4> <ol> <li>shopifyのWebhookを受けるサーバーを用意する</li> <li>在庫切れ通知に対して権限を追加</li> <li>Webhookを受けたあとの処理の実装をする</li> </ol> <p><strong>1. shopifyのWebhookを受けるサーバーを用意する</strong></p> <p>本記事ではfirebaseのcloud functionsで実装します。 cloud functionsの導入方法は公式のチュートリアルに従って導入してください <a href="https://firebase.google.com/docs/functions/get-started?hl=ja">https://firebase.google.com/docs/functions/get-started?hl=ja</a></p> <p><strong>2. 在庫切れ通知に対して権限を追加する</strong></p> <p>スレッドの情報を取得するためには「thread_ts」を特定する必要があります。 取得した「thread_ts」をキーとしてslackのapi「<a href="https://slack.com/api/conversations.replies">https://slack.com/api/conversations.replies</a>」にリクエストを行い結果を取得します。 また、上記APIにリクエストするためには事前にchannnels:historyの権限を付与する必要があります。</p> <p><strong>thread_ts</strong></p> <p>thread_tsはスレッドごとに一意の値です。tsは「タイムスタンプ」のことで、スレッドの親メッセージのタイムスタンプの値のようです。 ともかく同じスレッドのメッセージであれば同じthread_tsの値を持っています。 また、このthread_tsはそもそもスレッドに紐づいた情報にしかない属性なので、この有無をメッセージがスレッドに属するかの判定に利用することもできます。</p> <p><strong>channnels:history</strong></p> <p>conversation.repliesをSlack Appで利用する場合、事前にAppのスコープとしてチャンネルの履歴の読み取りを指定しておく必要があります。 Slack Appの設定から、[OAuth &amp; Permissions]->[Scopes]->[Add an OAuth Scope]でスコープとしてchannnels:historyを追加してください。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/b/bigJury/20220718/20220718172621.png" width="1200" height="1030" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fapi.slack.com%2Fmethods%2Fconversations.replies" title="conversations.replies API method" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://api.slack.com/methods/conversations.replies">api.slack.com</a></cite></p> <p><strong>3. Webhookを受けたあとの処理の実装をする </strong></p> <pre class="code lang-javascript" data-lang="javascript" data-unlink><span class="synStatement">const</span> functions = require(<span class="synConstant">'firebase-functions'</span>); <span class="synStatement">const</span> fetch = require(<span class="synConstant">'node-fetch'</span>); <span class="synComment">//shopifyのlocationId</span> <span class="synStatement">const</span> locationId = <span class="synConstant">&quot;各自置き換えてください&quot;</span> exports.addStock = functions.https.onRequest(async (request, response) =&gt; <span class="synIdentifier">{</span> <span class="synStatement">const</span> changeStockBySlack = () =&gt; async (req, res) =&gt; <span class="synIdentifier">{</span> <span class="synStatement">const</span> body = req.body; <span class="synComment">//requestにthread_tsがある場合、在庫切れ通知へのスレッド返信とみなす</span> <span class="synStatement">if</span> (body.<span class="synStatement">event</span>.thread_ts !== <span class="synStatement">undefined</span>) <span class="synIdentifier">{</span> <span class="synStatement">const</span> ts = body.<span class="synStatement">event</span>.thread_ts; <span class="synComment">//slackのチャンネルを取得</span> <span class="synStatement">const</span> channel = body.<span class="synStatement">event</span>.channel; <span class="synComment">//チャンネルで入力された会話の内容を取得</span> <span class="synStatement">const</span> replies = await getConversationsReplies(ts, channel); <span class="synComment">//チャンネルで入力された在庫Id, 発注数量を取得</span> <span class="synStatement">const</span> stockId = replies.stockId; <span class="synStatement">const</span> quantity = replies.quantity; <span class="synComment">//shopifyの在庫変更APIにリクエスト</span> await postInventoryLevel(locationId, stockId, quantity); res.send(<span class="synConstant">&quot;OK&quot;</span>); <span class="synIdentifier">}</span> <span class="synIdentifier">}</span>; <span class="synComment">//チャンネルで入力された会話の内容を取得する</span> <span class="synStatement">const</span> getConversationsReplies = async (channel, ts) =&gt; <span class="synIdentifier">{</span> <span class="synStatement">const</span> response = await fetch(<span class="synConstant">`https://slack.com/api/conversations.replies?channel=</span><span class="synSpecial">${channel}</span><span class="synConstant">&amp;ts=</span><span class="synSpecial">${ts}</span><span class="synConstant">`</span>, <span class="synIdentifier">{</span> method: <span class="synConstant">&quot;GET&quot;</span>, headers: <span class="synIdentifier">{</span> <span class="synConstant">&quot;Authorization&quot;</span>: <span class="synConstant">`Bearer </span><span class="synSpecial">${token}</span><span class="synConstant">`</span>, <span class="synConstant">&quot;Content-Type&quot;</span>: <span class="synConstant">&quot;application/json; charset=utf-8&quot;</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span>); <span class="synStatement">if</span> (response.<span class="synStatement">status</span> !== 200) <span class="synIdentifier">{</span> <span class="synStatement">throw</span> <span class="synStatement">new</span> Error(response.statusText); <span class="synIdentifier">}</span> <span class="synStatement">else</span> <span class="synIdentifier">{</span> <span class="synComment">//レスポンスのjsonをオブジェクトに変換</span> <span class="synStatement">const</span> replies = await response.json().messages.map((msg) =&gt; <span class="synIdentifier">{</span> <span class="synStatement">return</span> <span class="synStatement">new</span> ConversationsReply(msg); <span class="synIdentifier">}</span>); <span class="synStatement">return</span> replies; <span class="synIdentifier">}</span> <span class="synIdentifier">}</span>; <span class="synComment">//shopifyの在庫変更APIにリクエストを行う</span> <span class="synStatement">const</span> postInventoryLevel = async (locationId, inventoryItemId, availableAdjustment) =&gt; <span class="synIdentifier">{</span> <span class="synStatement">const</span> data = <span class="synIdentifier">{</span> location_id: locationId, inventory_item_id: inventoryItemId, available_adjustment: availableAdjustment <span class="synIdentifier">}</span>; <span class="synStatement">const</span> response = await fetch(<span class="synConstant">`admin/api/</span><span class="synSpecial">${API_VERSION}</span><span class="synConstant">/inventory_levels/adjust.json`</span>, <span class="synIdentifier">{</span> method: <span class="synConstant">&quot;POST&quot;</span>, headers: <span class="synIdentifier">{</span> <span class="synConstant">&quot;Content-Type&quot;</span>: <span class="synConstant">&quot;application/json&quot;</span>, <span class="synConstant">&quot;X-Shopify-Access-Token&quot;</span>: accessToken <span class="synIdentifier">}</span>, body: JSON.stringify(data) <span class="synIdentifier">}</span>); <span class="synStatement">if</span> (response.<span class="synStatement">status</span> !== 200) <span class="synIdentifier">{</span> <span class="synStatement">throw</span> <span class="synStatement">new</span> Error(response.statusText); <span class="synIdentifier">}</span> <span class="synStatement">else</span> <span class="synIdentifier">{</span> <span class="synComment">//レスポンスのjsonをオブジェクトに変換</span> <span class="synStatement">const</span> inventoryLevel = <span class="synStatement">new</span> InventoryLevelResponse(response.json().inventory_level); <span class="synStatement">return</span> inventoryLevel; <span class="synIdentifier">}</span> <span class="synIdentifier">}</span>; <span class="synIdentifier">}</span>); </pre> <h4 id="最後に">最後に</h4> <p>他サービスのAPIを触ってみると色々発見や学びがあります。学んだことをGreenSnapの発展に活かしていきたいと思います。</p> <p>弊社では絶賛エンジニア募集中です。サーバーサイド、iOS、Androidエンジニアはもちろんのこと これから作成するデータ基盤とそれを活用できるデータサイエンティストの方も募集しています。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.wantedly.com%2Fcompanies%2Fgreensnap" title="GreenSnap株式会社の会社情報 - Wantedly" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.wantedly.com/companies/greensnap">www.wantedly.com</a></cite></p> bigJury Next.js(TypeScript) / Axios /SpringBootでmultipart/form-dataを送信する hatenablog://entry/4207112889948925699 2022-12-28T18:31:01+09:00 2022-12-28T18:33:46+09:00 はじめに 今年、約4年振りにGreenSnapに舞い戻ってきた伊藤です。 以前はサーバーサイド(Scala)と、iOS(Swift)の開発をしていましたが、戻ってきてからはサーバーサイドエンジニアとして、ScalaとKotlinをメインに開発しております。 今回のテーマ 業務で開発しているサービスでお問い合わせ画面を開発する機会があり、 その際に添付ファイルをアップロードする必要があった為、表題の通りNext.jsとSpringBootを使ってファイルアップロードを実装してみたいと思います。 割と簡単かと思いますが、随所にハマりポイントがあったので、誰かのお役に立てばと思います。 今回使用する… <h1 id="はじめに">はじめに</h1> <p>今年、約4年振りにGreenSnapに舞い戻ってきた伊藤です。 以前はサーバーサイド(Scala)と、iOS(Swift)の開発をしていましたが、戻ってきてからはサーバーサイドエンジニアとして、ScalaとKotlinをメインに開発しております。</p> <h1 id="今回のテーマ">今回のテーマ</h1> <p>業務で開発しているサービスでお問い合わせ画面を開発する機会があり、 その際に添付ファイルをアップロードする必要があった為、表題の通りNext.jsとSpringBootを使ってファイルアップロードを実装してみたいと思います。 割と簡単かと思いますが、随所にハマりポイントがあったので、誰かのお役に立てばと思います。</p> <p>今回使用する技術スタックは次の通りです。</p> <ul> <li><p>Next.js(TypeScript) 12.3.0</p></li> <li><p>Axios 0.27.2</p></li> <li><p>Sprint Boot 2.6.3</p></li> </ul> <h1 id="今回作るもの">今回作るもの</h1> <p>よくある画面だと思いますが、名前とメールアドレス、それに加えて画像ファイルを添付できるお問い合わせ画面です。 添付する画像ファイルは複数枚対応できるものとします。</p> <p>画面イメージは次の感じです。 <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/cobonas/20221228/20221228154048.png" width="1200" height="441" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h1 id="ソースコード">ソースコード</h1> <p>実際のソースコードです。</p> <h3 id="Nextjs側">Next.js側</h3> <h4 id="ページpagesFileUploadTesttsx">ページ(/pages/FileUploadTest.tsx)</h4> <p>まずはNext.jsの問い合わせページのコードです。 formを設置して、適当に各input要素を置いてます。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">import</span> <span class="synIdentifier">{</span>ChangeEvent<span class="synStatement">,</span> FormEvent<span class="synStatement">,</span> useState<span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">&quot;react&quot;</span><span class="synStatement">;</span> <span class="synStatement">import</span> axios <span class="synStatement">from</span> <span class="synConstant">&quot;axios&quot;</span><span class="synStatement">;</span> <span class="synStatement">export</span> <span class="synStatement">default</span> <span class="synStatement">function</span> FileUploadTest<span class="synStatement">()</span> <span class="synIdentifier">{</span> <span class="synType">const</span> <span class="synIdentifier">[</span>name<span class="synStatement">,</span> setName<span class="synIdentifier">]</span> <span class="synStatement">=</span> useState<span class="synStatement">&lt;</span><span class="synType">string</span><span class="synStatement">&gt;(</span><span class="synConstant">''</span><span class="synStatement">)</span> <span class="synType">const</span> <span class="synIdentifier">[</span>mailAddress<span class="synStatement">,</span> setMailAddress<span class="synIdentifier">]</span> <span class="synStatement">=</span> useState<span class="synStatement">&lt;</span><span class="synType">string</span><span class="synStatement">&gt;(</span><span class="synConstant">''</span><span class="synStatement">)</span> <span class="synType">const</span> <span class="synIdentifier">[</span>images<span class="synStatement">,</span> setImages<span class="synIdentifier">]</span> <span class="synStatement">=</span> useState<span class="synStatement">(new</span> <span class="synSpecial">Map</span><span class="synStatement">&lt;</span><span class="synType">string</span><span class="synStatement">,</span> File<span class="synStatement">&gt;())</span> <span class="synStatement">return</span> <span class="synStatement">(</span> <span class="synStatement">&lt;</span>form method<span class="synStatement">=</span><span class="synIdentifier">{</span><span class="synConstant">'post'</span><span class="synIdentifier">}</span> <span class="synSpecial">onSubmit</span><span class="synStatement">=</span><span class="synIdentifier">{</span><span class="synStatement">(</span>e<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> register<span class="synStatement">(</span>e<span class="synStatement">)</span><span class="synIdentifier">}</span><span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>p<span class="synStatement">&gt;</span> 名前: <span class="synStatement">&lt;</span>input <span class="synStatement">type=</span><span class="synIdentifier">{</span><span class="synConstant">'text'</span><span class="synIdentifier">}</span> value<span class="synStatement">=</span><span class="synIdentifier">{</span>name<span class="synIdentifier">}</span> required<span class="synStatement">=</span><span class="synIdentifier">{</span><span class="synConstant">true</span><span class="synIdentifier">}</span> onChange<span class="synStatement">=</span><span class="synIdentifier">{</span><span class="synStatement">(</span>e<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> setName<span class="synStatement">(</span>e.target.value<span class="synStatement">)</span><span class="synIdentifier">}</span><span class="synStatement">&gt;&lt;</span>/input<span class="synStatement">&gt;</span> メールアドレス: <span class="synStatement">&lt;</span>input <span class="synStatement">type=</span><span class="synIdentifier">{</span><span class="synConstant">'email'</span><span class="synIdentifier">}</span> value<span class="synStatement">=</span><span class="synIdentifier">{</span>mailAddress<span class="synIdentifier">}</span> required<span class="synStatement">=</span><span class="synIdentifier">{</span><span class="synConstant">true</span><span class="synIdentifier">}</span> onChange<span class="synStatement">=</span><span class="synIdentifier">{</span><span class="synStatement">(</span>e<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> setMailAddress<span class="synStatement">(</span>e.target.value<span class="synStatement">)</span><span class="synIdentifier">}</span><span class="synStatement">&gt;&lt;</span>/input<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>/p<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>hr/<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>input id<span class="synStatement">=</span><span class="synConstant">&quot;file01&quot;</span> <span class="synStatement">type=</span><span class="synConstant">&quot;file&quot;</span> accept<span class="synStatement">=</span><span class="synConstant">&quot;image/jpeg, image/png&quot;</span> onChange<span class="synStatement">=</span><span class="synIdentifier">{</span><span class="synStatement">(</span>e<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> handleUpload<span class="synStatement">(</span>e<span class="synStatement">)</span><span class="synIdentifier">}</span>/<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>input id<span class="synStatement">=</span><span class="synConstant">&quot;file02&quot;</span> <span class="synStatement">type=</span><span class="synConstant">&quot;file&quot;</span> accept<span class="synStatement">=</span><span class="synConstant">&quot;image/jpeg, image/png&quot;</span> onChange<span class="synStatement">=</span><span class="synIdentifier">{</span><span class="synStatement">(</span>e<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> handleUpload<span class="synStatement">(</span>e<span class="synStatement">)</span><span class="synIdentifier">}</span>/<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>hr/<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>button <span class="synStatement">type=</span><span class="synIdentifier">{</span><span class="synConstant">'submit'</span><span class="synIdentifier">}</span><span class="synStatement">&gt;</span>アップロード<span class="synStatement">&lt;</span>/button<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>/form<span class="synStatement">&gt;</span> <span class="synStatement">)</span> <span class="synStatement">async</span> <span class="synStatement">function</span> handleUpload<span class="synStatement">(</span>e: ChangeEvent<span class="synStatement">&lt;</span>HTMLInputElement<span class="synStatement">&gt;)</span> <span class="synIdentifier">{</span> <span class="synStatement">if</span> <span class="synStatement">(</span><span class="synConstant">!</span>e.target.files<span class="synStatement">)</span> <span class="synStatement">return</span> <span class="synType">const</span> file <span class="synStatement">=</span> e.target.files<span class="synIdentifier">[</span><span class="synConstant">0</span><span class="synIdentifier">]</span> setImages<span class="synStatement">(</span>before <span class="synStatement">=&gt;</span> <span class="synStatement">new</span> <span class="synSpecial">Map</span><span class="synStatement">(</span>before.<span class="synStatement">set(</span>e.target.id<span class="synStatement">,</span> file<span class="synStatement">)))</span> <span class="synIdentifier">}</span> <span class="synStatement">async</span> <span class="synStatement">function</span> register<span class="synStatement">(</span>event: FormEvent<span class="synStatement">&lt;</span>HTMLFormElement<span class="synStatement">&gt;)</span> <span class="synIdentifier">{</span> event.preventDefault<span class="synStatement">()</span> <span class="synType">const</span> formData <span class="synStatement">=</span> <span class="synStatement">new</span> FormData<span class="synStatement">()</span> <span class="synComment">// axiosへ渡すデータとしてFormDataを使用</span> formData.append<span class="synStatement">(</span><span class="synConstant">'name'</span><span class="synStatement">,</span> name<span class="synStatement">)</span> formData.append<span class="synStatement">(</span><span class="synConstant">'mailAddress'</span><span class="synStatement">,</span> mailAddress<span class="synStatement">)</span> images.forEach<span class="synStatement">((</span>image<span class="synStatement">,</span> key<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> formData.append<span class="synStatement">(</span><span class="synConstant">'files'</span><span class="synStatement">,</span> image<span class="synStatement">))</span> <span class="synComment">// filesというキーでFileを複数セット</span> <span class="synStatement">await</span> axios.post<span class="synStatement">(</span> <span class="synConstant">`/api/fileUploadTest/upload`</span><span class="synStatement">,</span> <span class="synComment">// Next.jsのAPI Routesを呼ぶ</span> formData<span class="synStatement">,</span> <span class="synIdentifier">{</span> headers: <span class="synIdentifier">{</span><span class="synConstant">'Content-Type'</span>: <span class="synConstant">'multipart/form-data'</span><span class="synIdentifier">}}</span> <span class="synComment">// contentTypeに 'multipart/form-data' を指定</span> <span class="synStatement">)</span>.then<span class="synStatement">(</span>response <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> <span class="synStatement">alert(</span><span class="synConstant">'アップロードしました'</span><span class="synStatement">)</span> <span class="synIdentifier">}</span><span class="synStatement">)</span>.<span class="synSpecial">catch</span><span class="synStatement">(</span>error <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> <span class="synStatement">alert(</span><span class="synConstant">'エラーが発生しました'</span><span class="synStatement">)</span> <span class="synIdentifier">}</span><span class="synStatement">)</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span> </pre> <p>ポイントとしては、</p> <ol> <li>FormDataを使用する</li> <li>axiosでNext.jsのAPI Routesを呼ぶ(コードは後述)</li> <li>axios.post時にcontentTypeに <code>multipart/form-data</code> を指定する</li> </ol> <p>でしょうか。</p> <h4 id="API-RoutespagesapifileUploadTestuploadts">API Routes(/pages/api/fileUploadTest/upload.ts)</h4> <p>次にページから呼ばれるNext.jsのAPI Routesのコードです。</p> <p>ページからリクエストを受け取り、サーバ側(SpringBoot)のエンドポイントにリクエストを投げます。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">import</span> <span class="synStatement">type</span> <span class="synIdentifier">{</span> NextApiRequest<span class="synStatement">,</span> NextApiResponse <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'next'</span> <span class="synStatement">import</span> axios <span class="synStatement">from</span> <span class="synConstant">&quot;axios&quot;</span><span class="synStatement">;</span> <span class="synStatement">export</span> <span class="synType">const</span> config <span class="synStatement">=</span> <span class="synIdentifier">{</span> api: <span class="synIdentifier">{</span> bodyParser: <span class="synConstant">false</span> <span class="synIdentifier">}</span> <span class="synComment">// bodyParserを無効にする</span> <span class="synIdentifier">}</span> <span class="synStatement">export</span> <span class="synStatement">default</span> <span class="synStatement">function</span> handler<span class="synStatement">(</span> req: NextApiRequest<span class="synStatement">,</span> res: NextApiResponse <span class="synStatement">)</span> <span class="synIdentifier">{</span> <span class="synStatement">if</span> <span class="synStatement">(</span>req.method <span class="synStatement">!==</span> <span class="synConstant">'POST'</span><span class="synStatement">)</span> <span class="synIdentifier">{</span> <span class="synStatement">return</span> res.<span class="synStatement">status(</span><span class="synConstant">405</span><span class="synStatement">)</span>.end<span class="synStatement">()</span> <span class="synIdentifier">}</span> axios.post<span class="synStatement">(</span> <span class="synConstant">`http://localhost:8080/api/fileUploadTest/upload`</span><span class="synStatement">,</span> <span class="synComment">// SpringBoot側のエンドポイント</span> req<span class="synStatement">,</span> <span class="synComment">// { headers: {'Content-Type': 'multipart/form-data'}} // ← NG</span> <span class="synIdentifier">{</span> headers: <span class="synIdentifier">{</span><span class="synConstant">'Content-Type'</span>: req.headers<span class="synIdentifier">[</span><span class="synConstant">&quot;content-type&quot;</span><span class="synIdentifier">]}}</span> <span class="synStatement">)</span> .then<span class="synStatement">(</span>response <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> <span class="synStatement">if</span> <span class="synStatement">(</span>response.<span class="synStatement">status</span> <span class="synStatement">===</span> <span class="synConstant">200</span><span class="synStatement">)</span> <span class="synIdentifier">{</span> res.<span class="synStatement">status(</span><span class="synConstant">200</span><span class="synStatement">)</span>.json<span class="synStatement">(</span><span class="synIdentifier">{</span> <span class="synStatement">status</span>: <span class="synConstant">'OK'</span> <span class="synIdentifier">}</span><span class="synStatement">)</span> <span class="synIdentifier">}</span> <span class="synStatement">else</span> <span class="synIdentifier">{</span> res.<span class="synStatement">status(</span><span class="synConstant">500</span><span class="synStatement">)</span>.json<span class="synStatement">(</span><span class="synIdentifier">{</span> <span class="synStatement">status</span>: <span class="synConstant">'NG'</span> <span class="synIdentifier">}</span><span class="synStatement">)</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span><span class="synStatement">)</span> <span class="synIdentifier">}</span> </pre> <p>ポイントとしては、</p> <ol> <li>bodyParseを無効にする</li> <li>axios.post時にcontentTypeに <code>req.headers["content-type"]</code> を指定する</li> </ol> <p>です。</p> <p>bodyParserを無効にしないと、ページから渡ってきた<code>multipart/form-data</code> を上手く解釈できません。 また、サーバ側へのcontentTypeには<code>req.headers["content-type"]</code>を指定する必要があります。</p> <p>この時、contentTypeを指定しなかったり、<code>multipart/form-data</code> を再指定したりすると、SpringBoot側へ正しいリクエストとして届かずエラーになります。 自分はここに気づくのに結構時間を取られました。</p> <h3 id="SpringBoot側">SpringBoot側</h3> <p>最後にSpringBootで書いてあるサーバサイドのControllerです。</p> <h5 id="FileUploadTestControllerkt">FileUploadTestController.kt</h5> <pre class="code lang-kotlin" data-lang="kotlin" data-unlink><span class="synPreProc">import</span> org.springframework.http.HttpEntity <span class="synPreProc">import</span> org.springframework.http.ResponseEntity <span class="synPreProc">import</span> org.springframework.web.bind.<span class="synType">annotation</span>.<span class="synStatement">*</span> <span class="synPreProc">import</span> org.springframework.web.multipart.MultipartFile <span class="synIdentifier">@RestController</span> <span class="synIdentifier">@RequestMapping</span>(<span class="synConstant">&quot;/api/fileUploadTest&quot;</span>) <span class="synType">class</span> FileUploadTestController { <span class="synIdentifier">@PostMapping</span>(<span class="synConstant">&quot;/upload&quot;</span>) <span class="synType">fun</span> upload( <span class="synComment">// @RequestParam(&quot;name&quot;) name: String, RequestParamでは文字化けする</span> <span class="synIdentifier">@RequestPart</span>(<span class="synConstant">&quot;name&quot;</span>) name: <span class="synType">String</span>, <span class="synIdentifier">@RequestParam</span>(<span class="synConstant">&quot;mailAddress&quot;</span>) mailAddress: <span class="synType">String</span>, <span class="synIdentifier">@RequestPart</span>(<span class="synConstant">&quot;files&quot;</span>) files: <span class="synType">List</span>&lt;MultipartFile&gt;? ): HttpEntity&lt;<span class="synStatement">*</span>&gt; { println(name) println(mailAddress) println(files?.<span class="synStatement">get</span>(<span class="synConstant">0</span>)?.originalFilename) println(files?.<span class="synStatement">get</span>(<span class="synConstant">1</span>)?.originalFilename) <span class="synStatement">return</span> ResponseEntity.EMPTY } } </pre> <p>ポイントとしては、</p> <ol> <li>name, mailAddressなどのテキストデータは<code>@RequestPart</code> または<code>@RequestParam</code>で受け取る</li> <li>nameなどマルチバイト文字を<code>@ RequestParam</code>で受け取ると(なぜか)文字化けする</li> <li>ファイルは<code>@RequestPart</code>で受け取り<code>MultipartFile</code>として処理する</li> </ol> <p>です。</p> <h1 id="まとめ">まとめ</h1> <p>以上簡単ですが、Next.jsとAxiosとSpringBootでmultipart/form-dataを送信する方法でした。 調べれば、それなりに情報は出てくるのですが、(自分調べでは)断片的なものばかりで、色々な情報を組み合わせて実装するのに大変だったので、今回記事にしてみました。</p> <h2 id="最後に">最後に</h2> <p>弊社では絶賛エンジニア募集中です。BtoCのサービス開発をしてみたい方や、植物に興味のある方は是非応募してください。 カジュアルに話だけでも聞きたいという方もお待ちしてます。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.wantedly.com%2Fcompanies%2Fgreensnap" title="GreenSnap株式会社の会社情報 - Wantedly" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.wantedly.com/companies/greensnap">www.wantedly.com</a></cite></p> cobonas 社内slackでスタンプを押したらブロックチェーンに記録するWeb3サービスをchatGPTに丸投げして作ってみた話 hatenablog://entry/4207112889948656477 2022-12-27T18:54:06+09:00 2022-12-28T20:53:02+09:00 はじめに web3とは chatGPTとは 実装手順 1. hardhatでローカルにブロックチェーン環境をたてる 2. ブロックチェーン上にデータを記録するためのスマートコントラクトを作成する 3. スマートコントラクトをデプロイする 4. slack bolt経由でスマートコントラクトを呼び出す 5. slack Boltを起動して試す まとめ 最後に 裏話 はじめに この記事は、AIの自然言語生成モデルであるchatGPTによって作成されました。そのため、内容に誤りがある可能性があります。また一部人間によって訂正されたり、修正された場合があることをご了承ください。 web3とは web3… <ul class="table-of-contents"> <li><a href="#はじめに">はじめに</a></li> <li><a href="#web3とは">web3とは</a></li> <li><a href="#chatGPTとは">chatGPTとは</a></li> <li><a href="#実装手順">実装手順</a><ul> <li><a href="#1-hardhatでローカルにブロックチェーン環境をたてる">1. hardhatでローカルにブロックチェーン環境をたてる</a></li> <li><a href="#2-ブロックチェーン上にデータを記録するためのスマートコントラクトを作成する">2. ブロックチェーン上にデータを記録するためのスマートコントラクトを作成する</a></li> <li><a href="#3-スマートコントラクトをデプロイする">3. スマートコントラクトをデプロイする</a></li> <li><a href="#4-slack-bolt経由でスマートコントラクトを呼び出す">4. slack bolt経由でスマートコントラクトを呼び出す</a></li> <li><a href="#5-slack-Boltを起動して試す">5. slack Boltを起動して試す</a></li> </ul> </li> <li><a href="#まとめ">まとめ</a></li> <li><a href="#最後に">最後に</a></li> <li><a href="#裏話">裏話</a></li> </ul> <h2 id="はじめに"><strong>はじめに</strong></h2> <p>この記事は、AIの自然言語生成モデルであるchatGPTによって作成されました。そのため、内容に誤りがある可能性があります。また一部人間によって訂正されたり、修正された場合があることをご了承ください。</p> <h2 id="web3とは"><strong>web3とは</strong></h2> <p>web3は、ブロックチェーンを利用するためのアプリケーションプログラミングインターフェース(API)を提供するものです。つまり、ブロックチェーンにアクセスするためのインターフェースを提供し、ブロックチェーンを利用するアプリケーションを作成するためのものです。</p> <blockquote><p>【補足】 ここでchatGPTが説明されているのは、javascriptのweb3ライブラリだと思われます。一般的なWeb3について質問し直したところ、以下のような回答が返ってきました。</p></blockquote> <p>Web3とは、インターネット上で動くアプリケーションのプラットフォームのことです。Web3は、Web 2.0よりも新しい技術を使用しており、Web 2.0が中心としているのはサーバーであるのに対し、Web3はブロックチェーンを中心としています。</p> <p>ブロックチェーンとは、データを複数のコンピューター(ノード)に分散して保存する技術です。そのため、Web3アプリケーションは、データが一つのサーバーに保存されるのではなく、複数のコンピューターに分散して保存されるため、安全で信頼性が高く、偽の情報の挿入やデータの改ざんが困難です。</p> <p>Web3は、仮想通貨やスマートコントラクトなど、さまざまな技術を採用しており、新しいビジネスモデルやサービスを創出するための潜在的なプラットフォームとして注目されています。</p> <h2 id="chatGPTとは"><strong>chatGPTとは</strong></h2> <p>chatGPTは、OpenAIが開発した自然言語生成モデルです。入力された文章をもとに、自然な文章を生成することができます。このモデルは、自然言語処理タスクやチャットボットなどに利用されています。</p> <h2 id="実装手順"><strong>実装手順</strong></h2> <h3 id="1-hardhatでローカルにブロックチェーン環境をたてる"><strong>1. hardhatでローカルにブロックチェーン環境をたてる</strong></h3> <p>chatGPTから、web3のおすすめのフレームワークであるhardhatをおすすめされました。hardhatは、Ethereumのブロックチェーンを利用するためのフレームワークです。</p> <p>まずは、hardhatをローカル環境にインストールします。以下のコマンドを実行してください。</p> <pre class="code" data-lang="" data-unlink>$ npm install hardhat</pre> <p>次に、ローカル環境でブロックチェーンを立ち上げます。以下のコマンドを実行してください。</p> <pre class="code" data-lang="" data-unlink>$ npx hardhat node</pre> <p>立ち上げた際に、オーナーアドレスが表示されるので、このアドレスを保存しておきましょう。このアドレスを使用して、スマートコントラクトをデプロイしたり、トランザクションを送信することができます。</p> <h3 id="2-ブロックチェーン上にデータを記録するためのスマートコントラクトを作成する"><strong>2. ブロックチェーン上にデータを記録するためのスマートコントラクトを作成する</strong></h3> <p>スマートコントラクトを作成することで、ブロックチェーン上にデータを記録することができます。スマートコントラクトは、Ethereumブロックチェーン上で実行されるプログラムです。</p> <p>今回は、社内のslackでスタンプを押したときに、そのスタンプを記録するスマートコントラクトを作成します。そのために、ThankYouStamp.solという名前のスマートコントラクトファイルを作成します。</p> <p>以下は、ThankYouStamp.solのソースコードです。</p> <pre class="code lang-solidity" data-lang="solidity" data-unlink><span class="synStatement">pragma</span> solidity <span class="synStatement">^</span><span class="synConstant">0.8</span>.<span class="synConstant">9</span>; <span class="synType">contract</span> <span class="synIdentifier">ThankYouStamp</span> { <span class="synComment"> // スタンプを送信するためのデータ構造</span> <span class="synStatement">struct</span> Thanks { <span class="synType">bytes32</span> sender; <span class="synComment">// 送信者のslackユーザーIDをKeccak256でハッシュ化したもの</span> <span class="synType">bytes32</span> receiver; <span class="synComment">// 受信者のslackユーザーIDをKeccak256でハッシュ化したもの</span> <span class="synType">uint256</span> timestamp; <span class="synComment">// スタンプの送信日時をUNIX時間で保存</span> } <span class="synComment"> // 送信されたスタンプを保存するためのマップ</span> <span class="synType">mapping</span>(<span class="synType">bytes32</span> <span class="synStatement">=&gt;</span> Thanks) <span class="synStatement">public</span> thanks; <span class="synComment"> // 送信者と受信者のslackユーザーIDをハッシュ化したものをキーに、送信されたスタンプ数を保存するためのマップ</span> <span class="synType">mapping</span>(<span class="synType">bytes32</span> <span class="synStatement">=&gt;</span> <span class="synType">uint256</span>) <span class="synStatement">public</span> thanksCount; <span class="synComment"> // スタンプを送信する</span> <span class="synStatement">function</span> sendThanks(<span class="synType">string</span> <span class="synStatement">memory</span> senderId, <span class="synType">string</span> <span class="synStatement">memory</span> receiverId, <span class="synType">uint256</span> timestamp) <span class="synStatement">public</span> { <span class="synComment"> // 送信者と受信者のslackユーザーIDをハッシュ化</span> <span class="synType">bytes32</span> sender <span class="synStatement">=</span> <span class="synConstant">keccak256</span>(abi.encodePacked(senderId)); <span class="synType">bytes32</span> receiver <span class="synStatement">=</span> <span class="synConstant">keccak256</span>(abi.encodePacked(receiverId)); <span class="synComment"> // 自分自身にスタンプを送信することはできない</span> <span class="synStatement">require</span>(sender <span class="synStatement">!=</span> receiver, <span class="synConstant">&quot;Cannot send a stamp to yourself&quot;</span>); <span class="synComment"> // スタンプを送信する</span> thanks[sender] <span class="synStatement">=</span> Thanks(sender, receiver, timestamp); thanksCount[receiver]<span class="synStatement">++</span>; } <span class="synComment"> // 特定のslackユーザーが受け取ったスタンプの累積数を取得する</span> <span class="synStatement">function</span> getMyThanks(<span class="synType">string</span> <span class="synStatement">memory</span> userId) <span class="synStatement">public</span> <span class="synStatement">view</span> <span class="synStatement">returns</span> (<span class="synType">uint256</span>) { <span class="synComment"> // slackユーザーIDをハッシュ化</span> <span class="synType">bytes32</span> key <span class="synStatement">=</span> <span class="synConstant">keccak256</span>(abi.encodePacked(userId)); <span class="synStatement">return</span> thanksCount[key]; } } </pre> <p>このスマートコントラクトは、thanksCountという変数を定義しています。この変数は、各ユーザーが取得したスタンプの数を記録するものです。また、sendThanksという関数が定義されています。この関数を呼び出すことで、thanksCountを1増やすことができます。</p> <h3 id="3-スマートコントラクトをデプロイする"><strong>3. スマートコントラクトをデプロイする</strong></h3> <p>作成したスマートコントラクトをデプロイすることで、ブロックチェーン上に登録することができます。デプロイするためには、オーナーアドレスが必要になります。</p> <p>デプロイスクリプトは、deploy.tsとして以下のように書いておきます。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">import</span> <span class="synIdentifier">{</span> ethers <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">&quot;hardhat&quot;</span><span class="synStatement">;</span> <span class="synStatement">import</span> <span class="synIdentifier">{</span> ThankYouStamp <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">&quot;../typechain-types/ThankYouStamp&quot;</span><span class="synStatement">;</span> <span class="synStatement">async</span> <span class="synStatement">function</span> main<span class="synStatement">()</span> <span class="synIdentifier">{</span> <span class="synType">const</span> ThankYouStamp <span class="synStatement">=</span> <span class="synStatement">await</span> ethers.getContractFactory<span class="synStatement">(</span><span class="synConstant">&quot;ThankYouStamp&quot;</span><span class="synStatement">);</span> <span class="synComment">// スマートコントラクトのインスタンスを生成</span> <span class="synType">const</span> thankYouStamp <span class="synStatement">=</span> <span class="synStatement">await</span> ThankYouStamp.deploy<span class="synStatement">();</span> <span class="synComment">// デプロイされたスマートコントラクトのアドレスを表示</span> <span class="synSpecial">console</span>.log<span class="synStatement">(</span><span class="synConstant">&quot;ThankYouStamp deployed to: &quot;</span><span class="synStatement">,</span> thankYouStamp.address<span class="synStatement">);</span> <span class="synIdentifier">}</span> <span class="synComment">// We recommend this pattern to be able to use async/await everywhere</span> <span class="synComment">// and properly handle errors.</span> main<span class="synStatement">()</span>.<span class="synSpecial">catch</span><span class="synStatement">((</span>error<span class="synStatement">)</span> <span class="synStatement">=&gt;</span> <span class="synIdentifier">{</span> <span class="synSpecial">console</span>.error<span class="synStatement">(</span>error<span class="synStatement">);</span> <span class="synSpecial">process</span>.exitCode <span class="synStatement">=</span> <span class="synConstant">1</span><span class="synStatement">;</span> <span class="synIdentifier">}</span><span class="synStatement">);</span> </pre> <p>以下のコマンドを実行して、スマートコントラクトをデプロイします。</p> <pre class="code" data-lang="" data-unlink>$ npx hardhat run scripts/deploy.ts --network localhost</pre> <p>デプロイが完了すると、スマートコントラクトのアドレスが表示されます。このアドレスを保存しておきましょう。</p> <h3 id="4-slack-bolt経由でスマートコントラクトを呼び出す"><strong>4. slack bolt経由でスマートコントラクトを呼び出す</strong></h3> <p>次に、slack boltを使用して、スマートコントラクトを呼び出すようにします。slack boltは、slackアプリで利用するためのフレームワークです。</p> <p>slacl boltの詳細については、<a href="https://slack.dev/bolt-js/ja-jp/tutorial/getting-started">公式サイト</a> を参照ください。</p> <p>次に、slack boltを使用して、slackでスタンプを押したときに、スマートコントラクトを呼び出す処理を記述します。</p> <p>app.jsファイルを作成し、以下のように書いておきます。</p> <pre class="code lang-javascript" data-lang="javascript" data-unlink><span class="synStatement">const</span> <span class="synIdentifier">{</span>App, ExpressReceiver<span class="synIdentifier">}</span> = require(<span class="synConstant">'@slack/bolt'</span>); <span class="synStatement">const</span> request = require(<span class="synConstant">'request'</span>); <span class="synStatement">const</span> Web3 = require(<span class="synConstant">'web3'</span>); <span class="synComment">// Web3を初期化する</span> <span class="synComment">// URL for the Ethereum network</span> <span class="synStatement">const</span> networkUrl = <span class="synConstant">&quot;http://127.0.0.1:8545&quot;</span>; <span class="synComment">// ThankYouStampのABIをロードする</span> <span class="synStatement">const</span> thankYouStampABI = require(<span class="synConstant">'./ThankYouStamp.json'</span>).abi; <span class="synComment">// ThankYouStampのアドレスをロードする</span> <span class="synStatement">const</span> thankYouStampAddress = require(<span class="synConstant">'./ThankYouStampAddress.json'</span>).address; <span class="synComment">// Connect to the Ethereum network</span> <span class="synStatement">const</span> web3 = <span class="synStatement">new</span> Web3(networkUrl); <span class="synComment">// ThankYouStampのコントラクトを初期化する</span> <span class="synStatement">const</span> thankYouStamp = <span class="synStatement">new</span> web3.eth.Contract(thankYouStampABI, thankYouStampAddress); <span class="synStatement">const</span> TOKEN = <span class="synConstant">&quot;SLACK_API_TOKEN&quot;</span> <span class="synStatement">const</span> SIGNING_SECRET = <span class="synConstant">&quot;YOUR_SIGNING_SECRET&quot;</span> <span class="synComment">// ボットトークンとソケットモードハンドラーを使ってアプリを初期化します</span> <span class="synStatement">const</span> app = <span class="synStatement">new</span> App(<span class="synIdentifier">{</span> token: TOKEN, signingSecret: SIGNING_SECRET <span class="synIdentifier">}</span>); app.<span class="synStatement">event</span>(<span class="synConstant">'reaction_added'</span>, async (<span class="synIdentifier">{</span><span class="synStatement">event</span>, say, client<span class="synIdentifier">}</span>) =&gt; <span class="synIdentifier">{</span> <span class="synStatement">if</span> (<span class="synStatement">event</span>.reaction === <span class="synConstant">'thankyou'</span>) <span class="synIdentifier">{</span> <span class="synComment">// スタンプを送信したuserIdを受け取る</span> <span class="synStatement">const</span> senderId = <span class="synStatement">event</span>.user; <span class="synComment">// リアクションしたメッセージを送信したuserIdを受け取る</span> <span class="synStatement">const</span> recipientId = <span class="synStatement">event</span>.item_user; <span class="synComment">// 自分自身に感謝スタンプを送ることはできないので、senderIdとrecipientIdが異なる場合のみ処理を行う</span> <span class="synStatement">if</span> (senderId !== recipientId) <span class="synIdentifier">{</span> <span class="synComment">// スタンプの送受信をブロックチェーン上に記録する</span> await thankYouStamp.methods.sendThanks(senderId, recipientId, <span class="synType">Date</span>.now()).send(<span class="synIdentifier">{</span> from: <span class="synConstant">&quot;OWNER_ADDRESS&quot;</span> <span class="synIdentifier">}</span>); <span class="synComment">// ボットが日本語で返答する</span> say(<span class="synConstant">`&lt;@</span><span class="synSpecial">${senderId}</span><span class="synConstant">&gt;さんから感謝スタンプが届きました。あなたの感謝スタンプは合計</span><span class="synSpecial">${await thankYouStamp.methods.getMyThanks(recipientId).call()}</span><span class="synConstant">個になりました。`</span>); <span class="synIdentifier">}</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span>); (async () =&gt; <span class="synIdentifier">{</span> <span class="synComment">// アプリを起動します</span> await app.start(process.env.PORT || 3000); console.log(<span class="synConstant">'⚡️ Bolt app is running!'</span>); <span class="synIdentifier">}</span>)(); </pre> <p>上記のコードでは、thankyouというスタンプが押されたときに、スマートコントラクトを呼び出す処理が記述されています。</p> <p>上記コード内の、ThankYouStamp.jsonについては、手順3で生成されたものになります。また、ThankYouStampAddress.jsonについては、手順3でデプロイした際に生成されるコントラクトアドレスを記載しております。</p> <p>また、OWNER_ADDRESSに関しては、手順1で保管しておいたオーナーアドレスになります。</p> <h3 id="5-slack-Boltを起動して試す"><strong>5. slack Boltを起動して試す</strong></h3> <p>最後に、slack boltを起動して、動作を確認します。以下のコマンドを実行してください。</p> <pre class="code" data-lang="" data-unlink>node app.js</pre> <p>slack boltが起動したら、thankyouスタンプを押してみましょう。すると、スマートコントラクトが呼び出され、以下のようなメッセージが返ってくれば成功です。 <figure class="figure-image figure-image-fotolife" title="slack thankyou"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yamano-hidenori/20221227/20221227171223.png" width="1024" height="394" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>slack thankyou</figcaption></figure></p> <h2 id="まとめ"><strong>まとめ</strong></h2> <p>今回は、社内slackでスタンプを押したらブロックチェーンに記録するweb3サービスをchatGPTに丸投げして作ってみた話をご紹介しました。スマートコントラクトを作成し、slack boltを使用してスマートコントラクトを呼び出すことで、簡単にブロックチェーンを利用するアプリケーションを作成することができました。</p> <p>web3を使用することで、ブロックチェーンを利用するアプリケーションを作成することができます。また、chatGPTを使用することで、自然言語を生成することができるので、お手軽に文章を作成することができます。</p> <p>今回は、スタンプを押したときに、スマートコントラクトを呼び出す処理を実装しましたが、web3を使用することで、様々なタイプのアプリケーションを作成することができます。是非、web3とchatGPTを試してみてください。</p> <h2 id="最後に">最後に</h2> <p>GreenSnapではエンジニアを募集中です。サーバーサイド、iOS、Androidエンジニアはもちろんのこと これから作成するデータ基盤とそれを活用できるデータサイエンティストの方も募集しています。<br/> 一人ひとりが幅広くいろんな技術領域を担当しているので、特定の技術だけを担当するより多くの技術に触れたい方や挑戦したい方は是非お声がけください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.wantedly.com%2Fcompanies%2Fgreensnap" title="GreenSnap株式会社の会社情報 - Wantedly" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.wantedly.com/companies/greensnap">www.wantedly.com</a></cite></p> <h2 id="裏話">裏話</h2> <p>この記事に出てくるソースコードをchatGPTに書いてもらったときの質問内容がこちらです。</p> <pre class="code" data-lang="" data-unlink>以下の要件を満たす社内の感謝スタンプシステムを作るために、下記の条件を満たすスマートコントラクトThankYouStamp.solのソースコードと、slackのAPIをハンドリングするapp.jsのソースコードを書いてください。 ## 仕様 - 感謝スタンプはslackのスタンプを使います - slack上のdevelopチャンネルの中でのメッセージにリアクションすることで感謝スタンプを送信できます - 自分自身に感謝スタンプを送ることはできません ## 技術要件 - ThankYouStamp.solについて - solidityのバージョンは0.8.9です - ThankYouStamp.solというスマートコントラクトを定義します - string型は文字列比較できないことに注意します - ThankYouStampのなかで、sendThanks関数を定義します - sendThanks関数は、送信者のslackのユーザーIDと受信者のslackのユーザーIDとスタンプの送信日時を受け取り、ブロックチェーン上に保存します - ThankYouStampのなかで、getMyThanks関数を定義します - getMyThanks関数はslackのユーザーIDを受け取り、スタンプの合計取得数を返します - app.jsについて - ThankYouStampのABIをThankYouStamp.jsonからロードします - ThankYouStampのアドレスをThankYouStampAddress.jsonからロードします - slackAppを初期化して変数appに格納します - reaction_addedイベントをハンドリングし、結果をevent, say, clientとして受け取ります - event.reactionの中身が「thankyou」の場合、スタンプを送信したuserIdと、リアクションしたメッセージを送信したuserIdを受け取ります - スタンプの送受信をブロックチェーン上に記録します - say()を使ってボットが日本語で返答します - 〇さんから感謝スタンプが届きました。あなたの感謝スタンプは合計△個になりました。 - 上記の〇には感謝スタンプを押したユーザーのユーザーIDが@メンション付きで入り、△は感謝スタンプを受け取ったユーザーの累積のスタンプ数が入ります。</pre> <p>最初はかなり雑な質問をしていたので、手取り足取り教えてくれましたが、そこから実際にコードを書いて、動かなかったところが修正されるように質問内容を調整することで、このような質問にたどり着きました。質問の仕方次第で全く異なる回答が返ってきたりするので、ググり力ならぬ、chatGPTへの質問力みたいなものが、今後求められる時代が来るのかもしれません。</p> yamano-hidenori Lambda@Edgeで画像リサイズをGUIでイチから設定する hatenablog://entry/4207112889936605493 2022-11-17T21:48:41+09:00 2022-11-19T21:41:40+09:00 はじめに aws.amazon.com こちらの記事を参考に画像をリサイズするLambda@Edgeを作ろうとしたが環境構築がCloudFormationを使って0の状態から作成されており、すでに本番環境で動いてるS3やcloudfrontに対して適用するのにGUIからやりたかったのでこれを実際にGUIで設定しようとしたらどうなるのか?というのをやってみました。 一部、元の記事の設定ではうまく動かない部分やハマった部分についても記述します。もしかしたらうまく設定できない方がいれば何かの参考になるかもしれません。 CloudFormationのテンプレートを読み解く yamlファイルは元の記事を… <h2 id="はじめに">はじめに</h2> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Faws.amazon.com%2Fjp%2Fblogs%2Fnews%2Fresizing-images-with-amazon-cloudfront-lambdaedge-aws-cdn-blog%2F" title="Amazon CloudFront &amp; Lambda@Edge で画像をリサイズする | Amazon Web Services" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://aws.amazon.com/jp/blogs/news/resizing-images-with-amazon-cloudfront-lambdaedge-aws-cdn-blog/">aws.amazon.com</a></cite> こちらの記事を参考に画像をリサイズするLambda@Edgeを作ろうとしたが環境構築がCloudFormationを使って0の状態から作成されており、すでに本番環境で動いてるS3やcloudfrontに対して適用するのにGUIからやりたかったのでこれを実際にGUIで設定しようとしたらどうなるのか?というのをやってみました。<br/> 一部、元の記事の設定ではうまく動かない部分やハマった部分についても記述します。もしかしたらうまく設定できない方がいれば何かの参考になるかもしれません。</p> <h2 id="CloudFormationのテンプレートを読み解く">CloudFormationのテンプレートを読み解く</h2> <p>yamlファイルは元の記事を見てもらうとして、やってることは以下のようなことになります。</p> <ol> <li>Lambda@Edge用のIAMRoleを作る</li> <li>S3のバケットを用意し、1で作ったIAMRoleに権限付与する</li> <li>viewer-requestとorigin-responseのlambda関数を作成する。</li> <li>CloudFrontを用意する。2で作ったS3をオリジンにし、3で作ったlambda関数をlambdaEdge関数として適用する</li> </ol> <p>ただ今回はGUIでやっていくので多少順番を入れ替えて以下のような作業工程で完成を目指します。</p> <ol> <li>Lambda@Edge用のIAMRoleを作る</li> <li>S3のバケットを用意し、1で作ったIAMRoleに権限付与する</li> <li>2で作ったS3をオリジンとするCloudFrontを用意する</li> <li>viewer-requestとorigin-responseのlambda関数を作成する</li> <li>lambdaEdge関数にデプロイする</li> </ol> <p>これらを順番にGUIで設定していけば画像をリサイズするLamda@Edgeが完成し<br/> <code>cloudfrontドメイン/path?d=100x100</code><br/> のようなURLで画像ファイルにアクセスすると100x100のリサイズされた画像が表示されるようになります。 順番に設定してみましょう。</p> <h2 id="1-LambdaEdge用のIAMRoleを作る">1. Lambda@Edge用のIAMRoleを作る</h2> <pre class="code lang-yaml" data-lang="yaml" data-unlink> <span class="synIdentifier">EdgeLambdaRole</span><span class="synSpecial">:</span> <span class="synIdentifier">Type</span><span class="synSpecial">:</span> <span class="synConstant">&quot;AWS::IAM::Role&quot;</span> <span class="synIdentifier">Properties</span><span class="synSpecial">:</span> <span class="synIdentifier">AssumeRolePolicyDocument</span><span class="synSpecial">:</span> <span class="synIdentifier">Version</span><span class="synSpecial">:</span> <span class="synConstant">&quot;2012-10-17&quot;</span> <span class="synIdentifier">Statement</span><span class="synSpecial">:</span> <span class="synIdentifier">Effect</span><span class="synSpecial">:</span> <span class="synConstant">&quot;Allow&quot;</span> <span class="synIdentifier">Principal</span><span class="synSpecial">:</span> <span class="synIdentifier">Service</span><span class="synSpecial">:</span> <span class="synStatement">- </span><span class="synConstant">&quot;lambda.amazonaws.com&quot;</span> <span class="synStatement">- </span><span class="synConstant">&quot;edgelambda.amazonaws.com&quot;</span> <span class="synIdentifier">Action</span><span class="synSpecial">:</span> <span class="synStatement">- </span><span class="synConstant">&quot;sts:AssumeRole&quot;</span> <span class="synIdentifier">Path</span><span class="synSpecial">:</span> <span class="synConstant">&quot;/service-role/&quot;</span> <span class="synIdentifier">ManagedPolicyArns</span><span class="synSpecial">:</span> <span class="synStatement">- </span><span class="synConstant">&quot;arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole&quot;</span> </pre> <p>CloudFormationでいうところこの部分を作成します</p> <p>IAMRoleの管理画面から、カスタム信頼ポリシーを選択し<br/> 信頼関係に</p> <ul> <li>edgelambda.amazonaws.com</li> <li>lambda.amazonaws.com</li> </ul> <p>の二つを追加したIAMRoleを作成します</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/masahide318/20221114/20221114220032.png" width="1200" height="666" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <pre class="code lang-json" data-lang="json" data-unlink><span class="synSpecial">{</span> &quot;<span class="synStatement">Version</span>&quot;: &quot;<span class="synConstant">2012-10-17</span>&quot;, &quot;<span class="synStatement">Statement</span>&quot;: <span class="synSpecial">[</span> <span class="synSpecial">{</span> &quot;<span class="synStatement">Effect</span>&quot;: &quot;<span class="synConstant">Allow</span>&quot;, &quot;<span class="synStatement">Principal</span>&quot;: <span class="synSpecial">{</span> &quot;<span class="synStatement">Service</span>&quot;: <span class="synSpecial">[</span> &quot;<span class="synConstant">edgelambda.amazonaws.com</span>&quot;, &quot;<span class="synConstant">lambda.amazonaws.com</span>&quot; <span class="synSpecial">]</span> <span class="synSpecial">}</span>, &quot;<span class="synStatement">Action</span>&quot;: &quot;<span class="synConstant">sts:AssumeRole</span>&quot; <span class="synSpecial">}</span> <span class="synSpecial">]</span> <span class="synSpecial">}</span> </pre> <p>IAMRoleは以上です。このIAMRoleをS3やlambda関数に使っていきます。</p> <h2 id="2-S3のバケットを用意し手順1で作ったIAMRoleに権限付与する">2. S3のバケットを用意し、手順1で作ったIAMRoleに権限付与する</h2> <pre class="code lang-yaml" data-lang="yaml" data-unlink> <span class="synIdentifier">ImageBucketPolicy</span><span class="synSpecial">:</span> <span class="synIdentifier">Type</span><span class="synSpecial">:</span> AWS::S3::BucketPolicy <span class="synIdentifier">Properties</span><span class="synSpecial">:</span> <span class="synIdentifier">Bucket</span><span class="synSpecial">:</span> <span class="synType">!Ref</span> ImageBucket <span class="synIdentifier">PolicyDocument</span><span class="synSpecial">:</span> <span class="synIdentifier">Statement</span><span class="synSpecial">:</span> <span class="synStatement">- </span><span class="synIdentifier">Action</span><span class="synSpecial">:</span> <span class="synStatement">- </span>s3:GetObject <span class="synIdentifier">Effect</span><span class="synSpecial">:</span> Allow <span class="synIdentifier">Principal</span><span class="synSpecial">:</span> <span class="synConstant">&quot;*&quot;</span> <span class="synIdentifier">Resource</span><span class="synSpecial">:</span> <span class="synType">!Sub</span> arn:aws:s3:::${ImageBucket}/* <span class="synStatement">- </span><span class="synIdentifier">Action</span><span class="synSpecial">:</span> <span class="synStatement">- </span>s3:PutObject <span class="synIdentifier">Effect</span><span class="synSpecial">:</span> Allow <span class="synIdentifier">Principal</span><span class="synSpecial">:</span> <span class="synIdentifier">AWS</span><span class="synSpecial">:</span> <span class="synType">!GetAtt</span> EdgeLambdaRole.Arn <span class="synIdentifier">Resource</span><span class="synSpecial">:</span> <span class="synType">!Sub</span> arn:aws:s3:::${ImageBucket}/* <span class="synStatement">- </span><span class="synIdentifier">Action</span><span class="synSpecial">:</span> <span class="synStatement">- </span>s3:GetObject <span class="synIdentifier">Effect</span><span class="synSpecial">:</span> Allow <span class="synIdentifier">Principal</span><span class="synSpecial">:</span> <span class="synIdentifier">AWS</span><span class="synSpecial">:</span> <span class="synType">!GetAtt</span> EdgeLambdaRole.Arn <span class="synIdentifier">Resource</span><span class="synSpecial">:</span> <span class="synType">!Sub</span> arn:aws:s3:::${ImageBucket}/* </pre> <p>CloudFormationでいうところこの部分を作成します。</p> <p>S3バケットを作成し PutObjectを1で作ったIAMRoleに権限付与し、 GetObjectとListBucketは全体公開します。<br/> ListBucketの権限はCloudFormation側にはないのですが、これを追加しておかないと後々うまく動かない部分があるので付けておいたほうが良いです。</p> <pre class="code lang-json" data-lang="json" data-unlink><span class="synSpecial">{</span> &quot;<span class="synStatement">Version</span>&quot;: &quot;<span class="synConstant">2012-10-17</span>&quot;, &quot;<span class="synStatement">Statement</span>&quot;: <span class="synSpecial">[</span> <span class="synSpecial">{</span> &quot;<span class="synStatement">Effect</span>&quot;: &quot;<span class="synConstant">Allow</span>&quot;, &quot;<span class="synStatement">Principal</span>&quot;: <span class="synSpecial">{</span> &quot;<span class="synStatement">AWS</span>&quot;: &quot;<span class="synError">arn</span>:<span class="synError">aws</span>:<span class="synError">iam</span>::<span class="synError">XXXXXX</span>:<span class="synError">role</span>/<span class="synError">EdgeLambdaRole</span>&quot; <span class="synError">// 手順1で作成したIAMRole</span> <span class="synSpecial">}</span>, &quot;<span class="synStatement">Action</span>&quot;: &quot;<span class="synConstant">s3:PutObject</span>&quot;, &quot;<span class="synStatement">Resource</span>&quot;: &quot;<span class="synConstant">arn:aws:s3:::lambdaedge-test/*</span>&quot; <span class="synSpecial">}</span>, <span class="synSpecial">{</span> &quot;<span class="synStatement">Effect</span>&quot;: &quot;<span class="synConstant">Allow</span>&quot;, &quot;<span class="synStatement">Principal</span>&quot;: &quot;<span class="synConstant">*</span>&quot;, &quot;<span class="synStatement">Action</span>&quot;: &quot;<span class="synConstant">s3:GetObject</span>&quot;, &quot;<span class="synStatement">Resource</span>&quot;: &quot;<span class="synConstant">arn:aws:s3:::lambdaedge-test/*</span>&quot; <span class="synSpecial">}</span>, <span class="synSpecial">{</span> &quot;<span class="synStatement">Effect</span>&quot;: &quot;<span class="synConstant">Allow</span>&quot;, &quot;<span class="synStatement">Principal</span>&quot;: &quot;<span class="synConstant">*</span>&quot;, &quot;<span class="synStatement">Action</span>&quot;: &quot;<span class="synConstant">s3:ListBucket</span>&quot;, &quot;<span class="synStatement">Resource</span>&quot;: &quot;<span class="synConstant">arn:aws:s3:::lambdaedge-test</span>&quot; <span class="synSpecial">}</span> <span class="synSpecial">]</span> <span class="synSpecial">}</span> </pre> <h2 id="3-2で作ったS3をオリジンとするCloudFrontを用意する">3. 2で作ったS3をオリジンとするCloudFrontを用意する</h2> <pre class="code lang-yaml" data-lang="yaml" data-unlink> <span class="synIdentifier">MyDistribution</span><span class="synSpecial">:</span> <span class="synIdentifier">Type</span><span class="synSpecial">:</span> AWS::CloudFront::Distribution <span class="synIdentifier">Properties</span><span class="synSpecial">:</span> <span class="synIdentifier">DistributionConfig</span><span class="synSpecial">:</span> <span class="synIdentifier">Origins</span><span class="synSpecial">:</span> <span class="synStatement">- </span><span class="synIdentifier">DomainName</span><span class="synSpecial">:</span> <span class="synType">!Sub</span> ${ImageBucket}.s3.amazonaws.com <span class="synIdentifier">Id</span><span class="synSpecial">:</span> myS3Origin <span class="synIdentifier">S3OriginConfig</span><span class="synSpecial">:</span> <span class="synSpecial">{}</span> <span class="synIdentifier">Enabled</span><span class="synSpecial">:</span> <span class="synConstant">'true'</span> <span class="synIdentifier">Comment</span><span class="synSpecial">:</span> distribution for content delivery <span class="synIdentifier">DefaultRootObject</span><span class="synSpecial">:</span> index.html <span class="synIdentifier">DefaultCacheBehavior</span><span class="synSpecial">:</span> <span class="synIdentifier">TargetOriginId</span><span class="synSpecial">:</span> myS3Origin <span class="synIdentifier">LambdaFunctionAssociations</span><span class="synSpecial">:</span> <span class="synStatement">- </span><span class="synIdentifier">EventType</span><span class="synSpecial">:</span> <span class="synConstant">'viewer-request'</span> <span class="synIdentifier">LambdaFunctionARN</span><span class="synSpecial">:</span> <span class="synType">!Ref</span> ViewerRequestFunctionVersion <span class="synStatement">- </span><span class="synIdentifier">EventType</span><span class="synSpecial">:</span> <span class="synConstant">'origin-response'</span> <span class="synIdentifier">LambdaFunctionARN</span><span class="synSpecial">:</span> <span class="synType">!Ref</span> OriginResponseFunctionVersion <span class="synIdentifier">ForwardedValues</span><span class="synSpecial">:</span> <span class="synIdentifier">QueryString</span><span class="synSpecial">:</span> <span class="synConstant">'true'</span> <span class="synIdentifier">QueryStringCacheKeys</span><span class="synSpecial">:</span> <span class="synStatement">- </span>d <span class="synIdentifier">Cookies</span><span class="synSpecial">:</span> <span class="synIdentifier">Forward</span><span class="synSpecial">:</span> <span class="synConstant">'none'</span> <span class="synIdentifier">ViewerProtocolPolicy</span><span class="synSpecial">:</span> allow-all <span class="synIdentifier">MinTTL</span><span class="synSpecial">:</span> <span class="synConstant">'100'</span> <span class="synIdentifier">SmoothStreaming</span><span class="synSpecial">:</span> <span class="synConstant">'false'</span> <span class="synIdentifier">Compress</span><span class="synSpecial">:</span> <span class="synConstant">'true'</span> <span class="synIdentifier">PriceClass</span><span class="synSpecial">:</span> PriceClass_All <span class="synIdentifier">ViewerCertificate</span><span class="synSpecial">:</span> <span class="synIdentifier">CloudFrontDefaultCertificate</span><span class="synSpecial">:</span> <span class="synConstant">'true'</span> </pre> <p>CloudFormationでいうところこの部分を作成します。</p> <p>先にキャッシュポリシーを作成します。<br/> キャッシュキーの設定のクエリ文字列に「d」を設定すれば他はデフォルトでOKです。<br/> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/masahide318/20221114/20221114223204.png" width="817" height="835" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>CloudFrontを作成します。<br/> オリジンを2で作成したS3を選択します。<br/> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/masahide318/20221114/20221114224439.png" width="794" height="363" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span> キャッシュポリシーを先ほど作成したキャッシュポリシーを選択します。<br/> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/masahide318/20221114/20221114224449.png" width="796" height="383" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>以上でCloudFrontの設定は完了です。<br/> ためしにS3に画像をアップロードしてCloudFrontのドメインで表示されればOKです。</p> <h2 id="4-viewer-requestとorigin-responseのlambda関数を作成しlambdaEdge関数にデプロイする">4. viewer-requestとorigin-responseのlambda関数を作成し、lambdaEdge関数にデプロイする</h2> <p><strong>※lambda関数はus-east-1のリージョンに作成します</strong></p> <pre class="code lang-yaml" data-lang="yaml" data-unlink> <span class="synIdentifier">ViewerRequestFunction</span><span class="synSpecial">:</span> <span class="synIdentifier">Type</span><span class="synSpecial">:</span> AWS::Serverless::Function <span class="synIdentifier">Properties</span><span class="synSpecial">:</span> <span class="synIdentifier">CodeUri</span><span class="synSpecial">:</span> s3://&lt;code-bucket&gt;/viewer-request-function.zip <span class="synIdentifier">Handler</span><span class="synSpecial">:</span> index.handler <span class="synIdentifier">Runtime</span><span class="synSpecial">:</span> nodejs6.10 <span class="synIdentifier">MemorySize</span><span class="synSpecial">:</span> <span class="synConstant">128</span> <span class="synIdentifier">Timeout</span><span class="synSpecial">:</span> <span class="synConstant">1</span> <span class="synIdentifier">Role</span><span class="synSpecial">:</span> <span class="synType">!GetAtt</span> EdgeLambdaRole.Arn <span class="synIdentifier">ViewerRequestFunctionVersion</span><span class="synSpecial">:</span> <span class="synIdentifier">Type</span><span class="synSpecial">:</span> <span class="synConstant">&quot;AWS::Lambda::Version&quot;</span> <span class="synIdentifier">Properties</span><span class="synSpecial">:</span> <span class="synIdentifier">FunctionName</span><span class="synSpecial">:</span> <span class="synType">!Ref</span> ViewerRequestFunction <span class="synIdentifier">Description</span><span class="synSpecial">:</span> <span class="synConstant">&quot;A version of ViewerRequestFunction&quot;</span> <span class="synIdentifier">OriginResponseFunction</span><span class="synSpecial">:</span> <span class="synIdentifier">Type</span><span class="synSpecial">:</span> AWS::Serverless::Function <span class="synIdentifier">Properties</span><span class="synSpecial">:</span> <span class="synIdentifier">CodeUri</span><span class="synSpecial">:</span> s3://&lt;code-bucket&gt;/origin-response-function.zip <span class="synIdentifier">Handler</span><span class="synSpecial">:</span> index.handler <span class="synIdentifier">Runtime</span><span class="synSpecial">:</span> nodejs6.10 <span class="synIdentifier">MemorySize</span><span class="synSpecial">:</span> <span class="synConstant">512</span> <span class="synIdentifier">Timeout</span><span class="synSpecial">:</span> <span class="synConstant">5</span> <span class="synIdentifier">Role</span><span class="synSpecial">:</span> <span class="synType">!GetAtt</span> EdgeLambdaRole.Arn <span class="synIdentifier">OriginResponseFunctionVersion</span><span class="synSpecial">:</span> <span class="synIdentifier">Type</span><span class="synSpecial">:</span> <span class="synConstant">&quot;AWS::Lambda::Version&quot;</span> <span class="synIdentifier">Properties</span><span class="synSpecial">:</span> <span class="synIdentifier">FunctionName</span><span class="synSpecial">:</span> <span class="synType">!Ref</span> OriginResponseFunction <span class="synIdentifier">Description</span><span class="synSpecial">:</span> <span class="synConstant">&quot;A version of OriginResponseFunction&quot;</span> </pre> <p>CloudFormationでいうところこの部分を作成します。<br/> どちらもzipにしたソースコードをS3にアップロードしてそれをlambda関数にしていますが<br/> Viewer-Request関数に関しては直接ソースコードを記述しても問題ありません。 Origin-Response関数もS3にアップしてますが直接zipをアップロードしてもデプロイ可能なので好みの方法でデプロイしてください。<br/> 注意点としては、S3のバケットを両方とも正しく設定することと、decodeURIしてrequest.uriの値を変数にいれておかないと、S3にアップされたファイル名にマルチバイト文字が含まれているとエラーになります。</p> <pre class="code" data-lang="" data-unlink> // read the required path. Ex: uri /images/100x100/webp/image.jpg let path = decodeURI(request.uri); //decodeURIしておく</pre> <h2 id="5-LambdaEdgeへデプロイする">5. Lambda@Edgeへデプロイする</h2> <p>Viewer-Request関数とOrigin-Response関数が作成できたらCloudFrontのlambdaEdgeにデプロイします。</p> <p><strong>Viewer-Request関数のLambda@Edgeへのデプロイ </strong></p> <p>Lambdaの右上のActionボタンから「Lambda@Edgeへのデプロイ」を選択します。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/masahide318/20221115/20221115012341.png" width="479" height="245" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>Distributionに作成したCloudFrontを選択し、CloudFront eventにViewer requestを選択しデプロイします。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/masahide318/20221115/20221115164515.png" width="823" height="880" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p><strong>Origin-Response関数のLambda@Edgeへのデプロイ</strong> 同様の手順でOrigin-Response関数もLambda@Edgeへのデプロイします。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/masahide318/20221115/20221115164732.png" width="816" height="796" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>デプロイがうまく行っていれば、CloudFront側のビヘイビアの設定をみるとLambda@Edgeが紐づいているのがわかります。<br/> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/masahide318/20221115/20221115165118.png" width="803" height="363" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>以上ですべての設定が完了です。 S3に画像をアップロードして <code>cloudfrontドメイン/path?d=100x100</code><br/> <code>cloudfrontドメイン/path?d=200x200</code><br/> クエリパラメーターをつけたURLにアクセスし、リサイズされた画像が表示されれば完成です。</p> <h2 id="その他補足">その他補足</h2> <p>GUIで設定していく上でいくつかハマった点やおまけの情報をいくつか紹介します。</p> <h3 id="LambdaEdgeへのデプロイでエラーになる場合">Lambda@Edgeへのデプロイでエラーになる場合</h3> <p>Lambda@Edgeへのデプロイで以下のようなエラーが表示された場合 <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/masahide318/20221115/20221115014539.png" width="807" height="128" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><br/> IAMRoleが正しいか確認しましょう</p> <h3 id="LambdaEdgeの関数をテストしたいとき">Lambda@Edgeの関数をテストしたいとき</h3> <p><strong>Viewer-Request関数の場合</strong></p> <p>cloudfront-access-request-in-response のテンプレートを選択しuriとquerystringの部分を修正する</p> <pre class="code lang-json" data-lang="json" data-unlink><span class="synSpecial">{</span> <span class="synError">//省略</span> &quot;<span class="synStatement">clientIp</span>&quot;: &quot;<span class="synConstant">2001:cdba::3257:9652</span>&quot;, &quot;<span class="synStatement">uri</span>&quot;: &quot;<span class="synConstant">/images/test-image.jpg</span>&quot;, <span class="synError">//s3のファイルのパス</span> &quot;<span class="synStatement">querystring</span>&quot;: &quot;<span class="synConstant">d=200x200</span>&quot;, <span class="synError">//リサイズの指定</span> &quot;<span class="synStatement">method</span>&quot;: &quot;<span class="synError">GET</span>&quot; <span class="synError">//省略</span> <span class="synSpecial">}</span> </pre> <p>これでテストを実行し、成功するかどうかでチェックできます。</p> <p><strong>Origin-Response関数の場合</strong></p> <p>cloudfront-access-request-in-response のテンプレートを選択しuri,querystring,responseのstatusを変更します S3の<code>images/test-image.jpg</code> のファイルがあると想定して</p> <pre class="code lang-json" data-lang="json" data-unlink><span class="synSpecial">{</span> &quot;<span class="synStatement">Records</span>&quot;: <span class="synSpecial">[</span> <span class="synError">// 省略</span> &quot;<span class="synStatement">clientIp</span>&quot;: &quot;<span class="synConstant">2001:cdba::3257:9652</span>&quot;, &quot;<span class="synStatement">uri</span>&quot;: &quot;<span class="synConstant">/images/200x200s/webp/test-image.jpg</span>&quot;, <span class="synError">//viewer-requestのfunctionが返してくるpath</span> &quot;<span class="synStatement">querystring</span>&quot;: &quot;<span class="synConstant">d=200x200</span>&quot;, <span class="synError">//リサイズの指定</span> &quot;<span class="synStatement">method</span>&quot;: &quot;<span class="synConstant">GET</span>&quot; }, &quot;<span class="synStatement">response</span>&quot;: <span class="synSpecial">{</span> &quot;<span class="synStatement">status</span>&quot;: &quot;<span class="synConstant">404</span>&quot;, <span class="synError">// 404にする</span> &quot;<span class="synStatement">statusDescription</span>&quot;: &quot;<span class="synConstant">Not Found</span>&quot;, <span class="synError">//省略</span> ] <span class="synSpecial">}</span> </pre> <p>この設定でテスト実行した結果 S3のパス<code>images/test-image.jpg</code> の画像がリサイズされたものが <code>/images/200x200s/webp/test-image.jpg</code> に作成されていれば成功しています。</p> <h3 id="CloudFrontのLambdaEdgeの関数のURLにアクセスしてエラーになる場合">CloudFrontのLambda@Edgeの関数のURLにアクセスしてエラーになる場合</h3> <p><strong>AccessDeniedが表示される場合</strong><br/> S3のバケットポリシーにs3:ListBucketの権限が付与されているか確認しましょう。<br/> 存在しない画像URLにアクセスした時に404が帰る状態になっていないとLambda@Edgeの関数がうまく動きません。</p> <h3 id="アスペクト比を保ったままリサイズしたい">アスペクト比を保ったままリサイズしたい</h3> <p>現在の実装だと正方形に切り取ることしかできないのでアスペクト比を保ったままリサイズしようとするとOrigin-Response関数に少し手を加えないといけません。 具体的にはクエリパラメーターのmodeというキーの値を元にS3から取得した元画像をどのように切り取るかを決定する処理を追加します。<br/> 以下のように修正します。</p> <pre class="code lang-javascript" data-lang="javascript" data-unlink>exports.handler = (<span class="synStatement">event</span>, context, callback) =&gt; <span class="synIdentifier">{</span> <span class="synIdentifier">let</span> response = <span class="synStatement">event</span>.Records<span class="synIdentifier">[</span>0<span class="synIdentifier">]</span>.cf.response; <span class="synComment">//check if image is not present</span> <span class="synStatement">if</span> (response.<span class="synStatement">status</span> == 404) <span class="synIdentifier">{</span> <span class="synIdentifier">let</span> request = <span class="synStatement">event</span>.Records<span class="synIdentifier">[</span>0<span class="synIdentifier">]</span>.cf.request; <span class="synIdentifier">let</span> params = querystring.parse(request.querystring); <span class="synComment">// if there is no dimension attribute, just pass the response</span> <span class="synStatement">if</span> (!params.d) <span class="synIdentifier">{</span> callback(<span class="synStatement">null</span>, response); <span class="synStatement">return</span>; <span class="synIdentifier">}</span> <span class="synComment">// read the dimension parameter value = width x height and split it by 'x'</span> <span class="synIdentifier">let</span> dimensionMatch = params.d.split(<span class="synConstant">&quot;x&quot;</span>); <span class="synComment">// read the required path. Ex: uri /images/100x100/webp/image.jpg</span> <span class="synIdentifier">let</span> path = decodeURI(request.uri); <span class="synComment">// read the S3 key from the path variable.</span> <span class="synComment">// Ex: path variable /images/100x100/webp/image.jpg</span> <span class="synIdentifier">let</span> key = path.substring(1); <span class="synComment">// parse the prefix, width, height and image name</span> <span class="synComment">// Ex: key=images/200x200/webp/image.jpg</span> <span class="synIdentifier">let</span> prefix, originalKey, match, width, height, mode, requiredFormat, imageName; <span class="synIdentifier">let</span> startIndex; <span class="synStatement">try</span> <span class="synIdentifier">{</span> match = key.match(/(.*)<span class="synIdentifier">\</span>/(<span class="synIdentifier">\</span>d+)x(<span class="synIdentifier">\</span>d+)(<span class="synIdentifier">[</span>sivh<span class="synIdentifier">]</span>?)<span class="synIdentifier">\</span>/(.*)<span class="synIdentifier">\</span>/(.*)/); prefix = match<span class="synIdentifier">[</span>1<span class="synIdentifier">]</span>; width = parseInt(match<span class="synIdentifier">[</span>2<span class="synIdentifier">]</span>, 10); height = parseInt(match<span class="synIdentifier">[</span>3<span class="synIdentifier">]</span>, 10); mode = match<span class="synIdentifier">[</span>4<span class="synIdentifier">]</span> ? match<span class="synIdentifier">[</span>4<span class="synIdentifier">]</span> : <span class="synConstant">&quot;s&quot;</span>; <span class="synComment">// correction for jpg required for 'Sharp'</span> requiredFormat = match<span class="synIdentifier">[</span>5<span class="synIdentifier">]</span> == <span class="synConstant">&quot;jpg&quot;</span> ? <span class="synConstant">&quot;jpeg&quot;</span> : match<span class="synIdentifier">[</span>5<span class="synIdentifier">]</span>; imageName = match<span class="synIdentifier">[</span>6<span class="synIdentifier">]</span>; originalKey = prefix + <span class="synConstant">&quot;/&quot;</span> + imageName; <span class="synIdentifier">}</span> <span class="synStatement">catch</span> (err) <span class="synIdentifier">{</span> <span class="synComment">// no prefix exist for image..</span> match = key.match(/(<span class="synIdentifier">\</span>d+)x(<span class="synIdentifier">\</span>d+)(<span class="synIdentifier">[</span>sivh<span class="synIdentifier">]</span>?)<span class="synIdentifier">\</span>/(.*)<span class="synIdentifier">\</span>/(.*)/); width = parseInt(match<span class="synIdentifier">[</span>1<span class="synIdentifier">]</span>, 10); height = parseInt(match<span class="synIdentifier">[</span>2<span class="synIdentifier">]</span>, 10); mode = match<span class="synIdentifier">[</span>3<span class="synIdentifier">]</span> ? match<span class="synIdentifier">[</span>3<span class="synIdentifier">]</span> : <span class="synConstant">&quot;s&quot;</span>; <span class="synComment">// correction for jpg required for 'Sharp'</span> requiredFormat = match<span class="synIdentifier">[</span>4<span class="synIdentifier">]</span> == <span class="synConstant">&quot;jpg&quot;</span> ? <span class="synConstant">&quot;jpeg&quot;</span> : match<span class="synIdentifier">[</span>4<span class="synIdentifier">]</span>; imageName = match<span class="synIdentifier">[</span>5<span class="synIdentifier">]</span>; originalKey = imageName; <span class="synIdentifier">}</span> <span class="synComment">// get the source image file</span> S3.getObject(<span class="synIdentifier">{</span> Bucket: BUCKET, Key: originalKey <span class="synIdentifier">}</span>).promise() <span class="synComment">// perform the resize operation</span> .then(data =&gt; <span class="synIdentifier">{</span> <span class="synIdentifier">let</span> sharpConfig = <span class="synIdentifier">{}</span>; <span class="synComment">/*</span> <span class="synComment"> * mode</span> <span class="synComment"> * - s(square) : 中央を正方形に切り取り(デフォルト)</span> <span class="synComment"> * - i(inside) : アスペクト比を変えずに指定の正方形サイズに収まる様に切り取り</span> <span class="synComment"> * - v(vertical) : アスペクト比を変えずに、縦にサイズを合わせる</span> <span class="synComment"> * - h(horizontal) : アスペクト比を変えずに、横にサイズを合わせる</span> <span class="synComment"> */</span> <span class="synStatement">switch</span>(mode)<span class="synIdentifier">{</span> <span class="synStatement">case</span> <span class="synConstant">'i'</span>: sharpConfig.fit = <span class="synConstant">'inside'</span>; sharpConfig.width = width; sharpConfig.height = height; <span class="synStatement">break</span>; <span class="synStatement">case</span> <span class="synConstant">'v'</span>: sharpConfig.height = height; <span class="synStatement">break</span>; <span class="synStatement">case</span> <span class="synConstant">'h'</span>: sharpConfig.width = width; <span class="synStatement">break</span>; <span class="synStatement">default</span>: sharpConfig.width = width; sharpConfig.height = height; <span class="synIdentifier">}</span> <span class="synStatement">return</span> Sharp(data.Body) .resize(sharpConfig) .toFormat(requiredFormat) .toBuffer(); <span class="synIdentifier">}</span>) .then(buffer =&gt; <span class="synIdentifier">{</span> <span class="synComment">// save the resized object to S3 bucket with appropriate object key.</span> S3.putObject(<span class="synIdentifier">{</span> Body: buffer, Bucket: BUCKET, ContentType: <span class="synConstant">'image/'</span> + requiredFormat, CacheControl: <span class="synConstant">'max-age=31536000'</span>, Key: key, StorageClass: <span class="synConstant">'STANDARD'</span> <span class="synIdentifier">}</span>).promise() <span class="synComment">// even if there is exception in saving the object we send back the generated</span> <span class="synComment">// image back to viewer below</span> .<span class="synStatement">catch</span>(() =&gt; <span class="synIdentifier">{</span> console.log(<span class="synConstant">&quot;Exception while writing resized image to bucket&quot;</span>)<span class="synIdentifier">}</span>); <span class="synComment">// generate a binary response with resized image</span> response.<span class="synStatement">status</span> = 200; response.body = buffer.toString(<span class="synConstant">'base64'</span>); response.bodyEncoding = <span class="synConstant">'base64'</span>; response.headers<span class="synIdentifier">[</span><span class="synConstant">'content-type'</span><span class="synIdentifier">]</span> = <span class="synIdentifier">[{</span> key: <span class="synConstant">'Content-Type'</span>, value: <span class="synConstant">'image/'</span> + requiredFormat <span class="synIdentifier">}]</span>; callback(<span class="synStatement">null</span>, response); <span class="synIdentifier">}</span>) .<span class="synStatement">catch</span>( err =&gt; <span class="synIdentifier">{</span> console.log(<span class="synConstant">&quot;Exception while reading source image :%j&quot;</span>,err); <span class="synIdentifier">}</span>); <span class="synIdentifier">}</span> <span class="synComment">// end of if block checking response statusCode</span> <span class="synStatement">else</span> <span class="synIdentifier">{</span> <span class="synComment">// allow the response to pass through</span> callback(<span class="synStatement">null</span>, response); <span class="synIdentifier">}</span> <span class="synIdentifier">}</span>; </pre> <p>修正後デプロイした後動作確認します。</p> <p>元画像<br/> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/masahide318/20221115/20221115162350.jpg" width="1200" height="630" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>d=300x300でアクセス</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/masahide318/20221115/20221115162442.jpg" width="300" height="300" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>d=300x300&amp;mode=i でアクセス</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/masahide318/20221115/20221115162531.jpg" width="300" height="158" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>以上のようにリサイズされていれば成功です。</p> <h2 id="最後に">最後に</h2> <p>長い文章になってしまい、やはりLambda@Edgeを手動で設定するのは大変ということがわかりました。<br/> 大人しくCloudFormationを勉強したいと思います。</p> <p>GreenSnapではエンジニアを募集中です。サーバーサイド、iOS、Androidエンジニアはもちろんのこと これから作成するデータ基盤とそれを活用できるデータサイエンティストの方も募集しています。<br/> 一人ひとりが幅広くいろんな技術領域を担当しているので、特定の技術だけを担当するより多くの技術に触れたい方や挑戦したい方は是非お声がけください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.wantedly.com%2Fcompanies%2Fgreensnap" title="GreenSnap株式会社の会社情報 - Wantedly" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://www.wantedly.com/companies/greensnap">www.wantedly.com</a></cite></p> masahide318 Bitriseキャッシュを使ってデプロイ 時間を1時間→20分にした話 hatenablog://entry/13574176438078698870 2022-04-01T12:08:40+09:00 2022-04-01T12:08:40+09:00 こんにちは、GreenSnapでiOSエンジニアをやっている山野です。 GreenSnapでは、iOSのビルドとデプロイのCI環境としてBitriseを利用しています。 これまで、ビルド時間に、ひどいときは1時間以上かかっており、生産性がかなり低かったのですが、 今回、ビルド時間が長い原因を調査し、改善することができたので、その内容をまとめてみました。 そもそもBitriseとは なぜデプロイに時間がかかっていたのか Bitriseのキャッシュの導入 事前準備 キャッシュの利用方法 Bitrise.ioキャッシュ:プルステップ Bitrise.ioキャッシュ:プッシュ キャッシュの管理 Bit… <p>こんにちは、GreenSnapでiOSエンジニアをやっている山野です。 GreenSnapでは、iOSのビルドとデプロイのCI環境としてBitriseを利用しています。 これまで、ビルド時間に、ひどいときは1時間以上かかっており、生産性がかなり低かったのですが、 今回、ビルド時間が長い原因を調査し、改善することができたので、その内容をまとめてみました。</p> <ul class="table-of-contents"> <li><a href="#そもそもBitriseとは">そもそもBitriseとは</a></li> <li><a href="#なぜデプロイに時間がかかっていたのか">なぜデプロイに時間がかかっていたのか</a></li> <li><a href="#Bitriseのキャッシュの導入">Bitriseのキャッシュの導入</a><ul> <li><a href="#事前準備">事前準備</a></li> <li><a href="#キャッシュの利用方法">キャッシュの利用方法</a><ul> <li><a href="#Bitriseioキャッシュプルステップ">Bitrise.ioキャッシュ:プルステップ</a></li> <li><a href="#Bitriseioキャッシュプッシュ">Bitrise.ioキャッシュ:プッシュ</a></li> </ul> </li> <li><a href="#キャッシュの管理">キャッシュの管理</a></li> <li><a href="#Bitriseのスケジュールビルド">Bitriseのスケジュールビルド</a></li> </ul> </li> <li><a href="#どれくらい改善されたか">どれくらい改善されたか</a></li> <li><a href="#まとめと今後について">まとめと今後について</a></li> <li><a href="#最後に">最後に</a></li> </ul> <h3 id="そもそもBitriseとは">そもそもBitriseとは</h3> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.bitrise.io%2F" title="Bitrise - Mobile Continuous Integration and Delivery" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;"></iframe><cite class="hatena-citation"><a href="https://www.bitrise.io/">www.bitrise.io</a></cite> Bitriseは、モバイルアプリのCI/CDを構築するためのSaaSです。 デフォルトで、様々なタスクが容易されており、それらをGUI上で操作することで、簡単にCI環境を構築することができます。</p> <h3 id="なぜデプロイに時間がかかっていたのか">なぜデプロイに時間がかかっていたのか</h3> <p>デプロイに時間がかかる理由は環境により様々あると思います。 弊社の場合、fastlaneを使って、pod, carthageライブラリのインストールとビルドからデプロイまでを一貫して行っていましたが、このfastlaneに非常に時間がかかっていました。</p> <pre class="code" data-lang="" data-unlink>+------------------------------------------------------------------------------+ | bitrise summary | +---+---------------------------------------------------------------+----------+ | | title | time (s) | +---+---------------------------------------------------------------+----------+ ︙ +---+---------------------------------------------------------------+----------+ | ✓ | fastlane | 54.2 min | +---+---------------------------------------------------------------+----------+ ︙ +---+---------------------------------------------------------------+----------+ | Total runtime: 54.9 min | +------------------------------------------------------------------------------+</pre> <p>また、fastlaneの内部を見てみたところ、</p> <pre class="code" data-lang="" data-unlink>+------+-----------------------------------------+-------------+ | fastlane summary | +------+-----------------------------------------+-------------+ | Step | Action | Time (in s) | +------+-----------------------------------------+-------------+ | 11 | cocoapods | 199 | | 12 | carthage | 928 | | 13 | gym | 1237 | | 14 | pilot | 750 | | 15 | deliver | 62 | | 16 | slack | 0 | +------+-----------------------------------------+-------------+</pre> <p>podとcarthageライブラリのインストールにそれなりに時間を要してしまっていることがわかりました。 そこで、これらのライブラリをキャッシュを使うことで、高速化できないか調べてみました。</p> <h3 id="Bitriseのキャッシュの導入">Bitriseのキャッシュの導入</h3> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fdevcenter.bitrise.io%2Fja%2Fbuilds%2Fcaching.html%23%25E3%2582%25AD%25E3%2583%25A3%25E3%2583%2583%25E3%2582%25B7%25E3%2583%25B3%25E3%2582%25B0" title="キャッシング" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;"></iframe><cite class="hatena-citation"><a href="https://devcenter.bitrise.io/ja/builds/caching.html#%E3%82%AD%E3%83%A3%E3%83%83%E3%82%B7%E3%83%B3%E3%82%B0">devcenter.bitrise.io</a></cite> Bitriseでは、公式でキャッシュ機能をサポートしています。 仕組みはシンプルで、2つのステップを追加するだけで導入可能です。</p> <h4 id="事前準備">事前準備</h4> <p>弊社ではfastlaneを用いてライブラリをインストールしていたので、あらかじめこれらをfastlaneから除き、 それぞれシェルを使ってインストールできるようにステップを編集しておきました。</p> <h4 id="キャッシュの利用方法">キャッシュの利用方法</h4> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fdevcenter.bitrise.io%2Fja%2Fbuilds%2Fcaching%2Fusing-caching-in-your-builds.html%23ot-lst-cnt" title="ビルドでのキャッシュの使用" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;"></iframe><cite class="hatena-citation"><a href="https://devcenter.bitrise.io/ja/builds/caching/using-caching-in-your-builds.html#ot-lst-cnt">devcenter.bitrise.io</a></cite> キャッシュの利用には、以下の2ステップを既存のワークフローに追加します。</p> <ul> <li><p>Bitrise.ioキャッシュ:プルステップ</p></li> <li><p>Bitrise.ioキャッシュ:プッシュ</p></li> </ul> <h5 id="Bitriseioキャッシュプルステップ">Bitrise.ioキャッシュ:プルステップ</h5> <p>キャッシュをダウンロードします。 レポジトリをclone後、ライブラリをインストールする前にこのステップを追加します。これだけです。 <figure class="figure-image figure-image-fotolife" title="Bitrise.ioキャッシュ:プル"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yamano-hidenori/20220401/20220401101457.png" alt="f:id:yamano-hidenori:20220401101457p:plain" width="1200" height="494" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Bitrise.ioキャッシュ:プル</figcaption></figure></p> <h5 id="Bitriseioキャッシュプッシュ">Bitrise.ioキャッシュ:プッシュ</h5> <p>キャッシュをアップロードします。 すべてのステップの最後にこのステップを追加します。 <figure class="figure-image figure-image-fotolife" title="Bitrise.ioキャッシュ:プッシュ"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yamano-hidenori/20220401/20220401101757.png" alt="f:id:yamano-hidenori:20220401101757p:plain" width="1200" height="539" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Bitrise.ioキャッシュ:プッシュ</figcaption></figure> どのディレクトリをキャッシュするかを設定しておく必要があるので、Cache pathsの欄に、</p> <pre class="code" data-lang="" data-unlink>$BITRISE_CACHE_DIR Pods -&gt; Podfile.lock Carthage -&gt; Cartfile.resolved vendor/bundle -&gt; Gemfile.lock</pre> <p>のように記載しました。 矢印の意味ですが、矢印の右側のファイルに変更があった場合は、左のキャッシュ対象を更新するという意味になります。</p> <h4 id="キャッシュの管理">キャッシュの管理</h4> <p>キャッシュはブランチ単位で管理されます。キャッシュをダウンロードしてくる際に、デプロイ対象のブランチにキャッシュが無い場合は、デフォルトのブランチから探してくれます。 また、キャッシュはデフォルトでは7日後に期限切れになります。7日間キャッシュした対象のブランチで何もしなかった場合は、期限切れのタイミングでキャッシュも削除されます。</p> <h4 id="Bitriseのスケジュールビルド">Bitriseのスケジュールビルド</h4> <p>Bitriseではスケジュールビルドを簡単に設定できます。 キャッシュ用のワークフローを作成し、1週間に1度定期実行するように設定すれば、7日後にキャッシュが消えることを回避できます。 設定は非常に簡単で、通常のビルド実行から、スケジュールビルドにチェックを入れ、曜日と時間を設定するたけです。 <figure class="figure-image figure-image-fotolife" title="スケジュールビルド"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yamano-hidenori/20220401/20220401105630.png" alt="f:id:yamano-hidenori:20220401105630p:plain" width="932" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>スケジュールビルド</figcaption></figure></p> <h3 id="どれくらい改善されたか">どれくらい改善されたか</h3> <p>キャッシュを使うことで、これまで数十分かかっていたライブラリのインストールですが、</p> <pre class="code" data-lang="" data-unlink>+---+---------------------------------------------------------------+----------+ | ✓ | Bundle Install | 16.19 sec| +---+---------------------------------------------------------------+----------+ | ✓ | Pod Install | 1.4 min | +---+---------------------------------------------------------------+----------+ | ✓ | Carthage Install | 25.05 sec| +---+---------------------------------------------------------------+----------+</pre> <p>全部合わせて2分ほどで完了するようになりました。これにより、全体のデプロイ時間も大幅に削減され、導入前は1時間ほどかかっていたものが、20分ほどで完了するようになりました。</p> <h3 id="まとめと今後について">まとめと今後について</h3> <p>今回は、Bitriseのキャッシュ機能を使うことで、iOSのデプロイ時間を大幅に削減することができた話をさせていただきました。 Bitriseはモバイルアプリに特化しているだけのことはあり、非常に簡単な操作でCI環境を導入できるのでおすすめです。 今後は、Bitriseを使って、自動テスト環境なども構築できればいいなと考えています。</p> <h2 id="最後に">最後に</h2> <p>弊社では絶賛エンジニア募集中です。BtoCのサービス開発をしてみたい方や、植物に興味のある方は是非応募してください。 カジュアルに話だけでも聞きたいという方もお待ちしてます。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.wantedly.com%2Fcompanies%2Fgreensnap" title="GreenSnap株式会社の会社情報 - Wantedly" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;"></iframe><cite class="hatena-citation"><a href="https://www.wantedly.com/companies/greensnap">www.wantedly.com</a></cite></p> yamano-hidenori GreenSnapのCTOとして2021年やったこと hatenablog://entry/13574176438047426853 2021-12-30T21:34:12+09:00 2021-12-30T21:39:23+09:00 こんにちは。 GreenSnapの取締役CTOの高畑です。2021年の8月にCTOに就任してから早くも4ヶ月が経ちました。 そこから正式に開発チームを任されるようになりましたが、それ以前からも開発チームのマネージャーとして色々と自由にやらさせてもらっていたので、GreenSnapのサービスと開発チームにどういう変化が起こったのか、自分がどんなことをしていたのかを今年1年の振り返りと共にサマリーを紹介します。 SLOを決める GreenSnapのサービスにSLOがなく、サーバーのエラーやAPIのレスポンス速度などの品質が悪くユーザーのレビューでも「アプリが重い」や「エラーがよく発生する」などのレ… <p>こんにちは。 GreenSnapの取締役CTOの高畑です。2021年の8月にCTOに就任してから早くも4ヶ月が経ちました。 そこから正式に開発チームを任されるようになりましたが、それ以前からも開発チームのマネージャーとして色々と自由にやらさせてもらっていたので、GreenSnapのサービスと開発チームにどういう変化が起こったのか、自分がどんなことをしていたのかを今年1年の振り返りと共にサマリーを紹介します。</p> <h3>SLOを決める</h3> <p>GreenSnapのサービスにSLOがなく、サーバーのエラーやAPIのレスポンス速度などの品質が悪くユーザーのレビューでも「アプリが重い」や「エラーがよく発生する」などのレビューが上がってくることが多かったです。アプリを使っていて動作がすごくもっさりしており、どうにかしないと気が済まなかったです。元々NewRelicによる監視ツールが導入されていたので</p> <ul> <li>NewRelicによる稼働率99.9%</li> <li>すべてのAPIのレスポンス速度を500ms以下にする</li> </ul> <p>の2点を最低限の目標としそれが下回るとNewRelicからSlackへ通知が来るようにしてアプリのパフォーマンスを改善しました。<br /> 稼働率は現在99.9%以上を維持しています。<br /> すべてのAPIのレスポンス速度を500ms以下にするは、一部ほぼリクエストが来ないAPIで達成できていないものがありますがほぼ改善しつつあります。 来年も引き続き稼働率とパフォーマンスを意識して開発を進めます。 この辺りの監視は入門監視を参考にしてとにかくユーザー体験に関わる部分から手をつけ始めました。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.oreilly.co.jp%2Fbooks%2F9784873118642%2F" title="入門 監視" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;"></iframe><cite class="hatena-citation"><a href="https://www.oreilly.co.jp/books/9784873118642/">www.oreilly.co.jp</a></cite></p> <h3>データベースをAuroraに移行</h3> <p>GreenSnapの本番環境でのデータベースはEC2インスタンスにMySQLを入れたものをDBサーバーとして運用していましたがデータ量の増加にともないバックアップの取得や、もしMySQLに何かあった時の復旧方法などの手順がなかったので、Auroraに移行してAWSに基本おまかせするようにしました。</p> <h3>タイムラインの機能に使ってるRedisのクラスター化</h3> <p>GreenSnapにはタイムラインの機能があり、ユーザーが投稿やフォローなどのアクションを行うたびにそのユーザーのアクションに関連するユーザーのタイムラインにデータを書き込みに行くのですが、そのデータの書き込み先が1台のRedisで行われていました。<br /> しかしRedisのCPU利用率が常に100%近くになっており、スペックを上げてもどうにもなりませんでした。<br /> それと同時にユーザーからタイムラインに反映されない、または反映されるのが遅いという声をいただいていたのでElastiCacheのクラスターモードをONにしたRedisを新規に立てて移行して、負荷分散を行いました。<br /> ざっくり現在のタイムラインの構成は以下のようになっており、メッセージキューにはkafkaが使われています。 <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/masahide318/20211230/20211230172924.png" alt="f:id:masahide318:20211230172924p:plain" width="1200" height="480" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h3>教えてカメラのバージョンアップ</h3> <p>教えてカメラの大幅バージョンアップを行いました。<br /> これに関しては協力していただいた。<a href="https://www.3-ize.jp/">株式会社トリプルアイズ</a>様に感謝にです。幅広い品種に対応しているので皆さんも家にある花や、散歩がてら道端に咲いてる植物などで試してください。<br /> レビューでたまに「AIの判定結果があたらない」という声をいただいていましたが、アップデートしてからそういう内容のレビューが今のところ0件になりました。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgreensnap.jp%2Fcompany%2Fnews%2Frelease%2Foshietecamera_midorinoqa" title="GreenSnap、「教えてカメラ」「みどりのQ&amp;A」ユーザーのニーズに応える機能アップデート・リリースを実施! ~写真を撮るだけで植物の名前がわかる「教えてカメラ」大幅アップデートに加え、ユーザーの集合知で植物の育て方などの悩みを解決する新機能「みどりのQ&amp;A」をリリース!~ | GreenSnap株式会社" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;"></iframe><cite class="hatena-citation"><a href="https://greensnap.jp/company/news/release/oshietecamera_midorinoqa">greensnap.jp</a></cite></p> <h3>EC2インスタンスの刷新</h3> <p>GreenSnapのアプリケーションサーバーがCentOS6で動いていたのですがサポートも切れており、後述にあるデータ基盤を作成するのにAWSとの連携で色々とやり辛かったのでAmazon Linux2に刷新しました。</p> <h3>GreenSnapSTOREとGreenSnapの連携</h3> <p>GreenSnapSTOREにはshopifyが使われておりますが、色々とデータ連携や運用改善のツールを作ってました。</p> <ul> <li>商品情報をGreenSnapのDBと連携させてアプリ側に商品情報を出したり</li> <li>在庫が切れそうな商品を自動で通知してくれるようにしたり</li> <li>アプリでshopifyのクーポンを配布したり</li> <li>注文が入った時自動でユーザーに配送予定日をお知らせるするメールを送ったり</li> </ul> <p>その他色々と連携させて、アプリとSTOREの連携を強めてまいりました</p> <h3>データ基盤の作成(途中)</h3> <p>GreenSnapのサービスに関するデータを横断して解析できるようなデータ基盤がなかったので現在作成中です。<br /> 現在の構想ではS3 + Athena + QuickSightで可視化の流れを考えています。<br /> ある程度のデータはS3に集まってきていますがGreenSnapSTOREのデータがまだ同期されていないので来年はSTOREの受注データや商品データも統合して、アプリ内のユーザーの行動データと購買データを突き合わせて分析できる基盤を用意していきます。 <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/masahide318/20211230/20211230165921.png" alt="f:id:masahide318:20211230165921p:plain" width="1200" height="970" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>これまで以上にGreenSnapをテック企業として成長させ、さらにデータをもとに仮説・検証を素早く行える組織を目指していきます。</p> <h3>社内勉強会の開始</h3> <p>「開発チームが抱える課題の解決になりそう」 + 「みんなが興味を持てる本」を選んで読書会をしました。 週に1回業務時間内でメンバー持ち回りで担当者を決めて発表してます。 これまでに以下の本を読みました。</p> <ul> <li><a href="https://www.amazon.co.jp/dp/B082WXZVPC/ref=dp-kindle-redirect?_encoding=UTF8&amp;btkr=1">ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本</a></li> <li><a href="https://www.amazon.co.jp/%E3%83%A6%E3%83%8B%E3%82%B3%E3%83%BC%E3%83%B3%E4%BC%81%E6%A5%AD%E3%81%AE%E3%81%B2%E3%81%BF%E3%81%A4-%E2%80%95Spotify%E3%81%A7%E5%AD%A6%E3%82%93%E3%81%A0%E3%82%BD%E3%83%95%E3%83%88%E3%82%A6%E3%82%A7%E3%82%A2%E3%81%A5%E3%81%8F%E3%82%8A%E3%81%A8%E5%83%8D%E3%81%8D%E6%96%B9-Jonathan-Rasmusson/dp/4873119464">ユニコーン企業の秘密</a></li> <li><a href="https://www.amazon.co.jp/Lean-Analytics-%E2%80%95%E3%82%B9%E3%82%BF%E3%83%BC%E3%83%88%E3%82%A2%E3%83%83%E3%83%97%E3%81%AE%E3%81%9F%E3%82%81%E3%81%AE%E3%83%87%E3%83%BC%E3%82%BF%E8%A7%A3%E6%9E%90%E3%81%A8%E6%B4%BB%E7%94%A8%E6%B3%95-LEAN-%E3%82%A2%E3%83%AA%E3%82%B9%E3%83%86%E3%82%A2%E3%83%BB%E3%82%AF%E3%83%AD%E3%83%BC%E3%83%AB/dp/4873117119/ref=sr_1_1?__mk_ja_JP=%E3%82%AB%E3%82%BF%E3%82%AB%E3%83%8A&amp;crid=PKWDXYX82ANA&amp;keywords=Lean+Analytics&amp;qid=1640865868&amp;s=books&amp;sprefix=lean+analytics+%2Cstripbooks%2C204&amp;sr=1-1">Lean Analytics ―スタートアップのためのデータ解析と活用法</a></li> </ul> <p>特にドメイン駆動設計入門はいわゆる軽量DDDという形での導入になってますが、ソースコードを書く上でチームの意識を統一させるのにすごく役立っています。 人によってどのロジックをどこに書くか?というのがバラバラだったのがこのロジックはこの層に書くべき、このロジックはこのクラスの責務であるべき、ServiceクラスやRepositoryクラスの依存関係はこうあるべきなどの判断基準が統一でき、ソースレビューのレビュー観点を持てたのがよかったです。</p> <h3>Google RecommendationsAIの導入</h3> <p>GreenSnapSTOREの商品とアプリとの連携において、おすすめの商品などを独自のロジックをもとに商品を表示していましたが試しにGoogleRecommendationsAIを導入しました。結果アプリからSTOREへのCVRが平均で0.5%ほど向上しました。まだまだ満足のいくレベルではないので、このあたりのおすすめ商品は先ほどのデータ基盤をもとに将来的に内製化しようと思います。</p> <h3>Notion + スクラムの導入</h3> <p>開発wiki的なものがなく、仕様書や設計書なども整備されておらず。お互いに暗黙知だらけでした。 チーム内でも何かドキュメントサービスを導入したいという声があがっていたのでNotionを導入することに決めました。 Notionの導入と同時に、それまでタスク管理ツールはClickUpを使っていたのですがタスクのチケットとwikiが別になるのも不便なのでClickUpのSprintのタスク管理機能とほぼ同じ使いみちになるようなテンプレートをNotionでつくって、タスク管理も仕様書も運用などもすべてNotion内で完結できるようにしました。 詳しくはこちらのブログを見てください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgreensnap-tech.hatenablog.com%2Fentry%2F2021%2F12%2F07%2F215722" title="Notionを導入してスクラムを始めました - greensnap-tech’s blog" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;"></iframe><cite class="hatena-citation"><a href="https://greensnap-tech.hatenablog.com/entry/2021/12/07/215722">greensnap-tech.hatenablog.com</a></cite></p> <h3>ポストモーテムの導入</h3> <p>過去に発生した障害の内容やどう対応したかの資料が欲しいという声と、Notionの導入 + そのタイミングでいくつか障害が発生したのでこれを機にポストモーテムを導入しました。 ポストモーテムを作成する基準や内容などは <a href="https://www.oreilly.co.jp/books/9784873117911/">SRE サイトリライアビリティエンジニアリング ―Googleの信頼性を支えるエンジニアリングチーム</a> を参考にしています。</p> <h3>エンジニアブログの開始</h3> <p>GreenSnapはテック企業としては知名度は高くないと課題を感じているので広報も兼ねてこのブログを始めました。 これからも定期的に発信していけるよう新しいことに挑戦し続けられる環境にしていきます。</p> <h3>最後に</h3> <p>今年自分ってどんなことしたかな?と思い書き出してみるとちゃんと色々な取り組みを始められていて良かったです。今回あげたもののいくつかは個別のブログとして切り出してもよさそうですね。<br /> 何よりも1年前に比べて開発チームの雰囲気や結束みたいなものは良くなってきたんじゃないかなぁと思います。<br /> もともと自分はAndroidアプリのエンジニアだったのですが、今年やったこと振り返るともうその影も形もありませんね……。<br /> 来年も引き続き多くのチャレンジをしたと思えるような環境を作っていきます。 弊社では絶賛エンジニア募集中です。サーバーサイド、iOS、Androidエンジニアはもちろんのこと これから作成するデータ基盤とそれを活用できるデータサイエンティストの方も募集しています。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.wantedly.com%2Fcompanies%2Fgreensnap" title="GreenSnap株式会社の会社情報 - Wantedly" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;"></iframe><cite class="hatena-citation"><a href="https://www.wantedly.com/companies/greensnap">www.wantedly.com</a></cite></p> masahide318 サーバーサイドエンジニア、Next.jsを触る hatenablog://entry/13574176438046109327 2021-12-28T10:03:29+09:00 2021-12-30T00:14:03+09:00 はじめに こんにちは、平川です。普段はサーバーサイドエンジニアとして業務に携わっております。 フロント側は基本的に手をつけない領域ですが、業務でNext.jsを触る可能性が出てきたので、実際に手を動かして知見を得ていこうと思います。 Next.jsとは Next.jsはReactベースのフロントエンドフレームワークです。 できることは色々あるのですが、大きな特徴としては、 SSR/SSG Zero Config File-system Routing Fast Refresh Image Optimization などが挙げられます(他にも色々あります) nextjs.org 今回触ってみるも… <h1>はじめに</h1> <p>こんにちは、平川です。普段はサーバーサイドエンジニアとして業務に携わっております。<br /> フロント側は基本的に手をつけない領域ですが、業務でNext.jsを触る可能性が出てきたので、実際に手を動かして知見を得ていこうと思います。</p> <h2>Next.jsとは</h2> <p>Next.jsはReactベースのフロントエンドフレームワークです。<br /> できることは色々あるのですが、大きな特徴としては、</p> <ul> <li>SSR/SSG</li> <li>Zero Config</li> <li>File-system Routing</li> <li>Fast Refresh</li> <li>Image Optimization</li> </ul> <p>などが挙げられます(他にも色々あります)</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fnextjs.org%2F" title="Next.js by Vercel - The React Framework" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;"></iframe><cite class="hatena-citation"><a href="https://nextjs.org/">nextjs.org</a></cite></p> <h2>今回触ってみるもの</h2> <p>個人的に気になった機能がいくつかあるので今回はそれらを実際にコードベースで触ってみて挙動などを理解していきます。</p> <ul> <li>Zero Config <ul> <li>configファイルがなくても自動コンパイルとバンドルが可能</li> </ul> </li> <li>SSR/SSG <ul> <li>あらかじめレンダリングして表示</li> </ul> </li> <li>API Routes <ul> <li>APIバックエンド機能を提供</li> </ul> </li> </ul> <h2>実際に触ってみる</h2> <h3>create next app</h3> <p>まずは開発する環境を作成します。 下記のコマンドを任意のコマンドラインで実行します。今回はTypeScriptに対応したいのでオプションに<code>--ts</code>をつけます</p> <pre class="code lang-zsh" data-lang="zsh" data-unlink>npx create-next-app@latest --ts </pre> <p>コマンドを実行するとアプリ名を尋ねられるので任意の名前をつけます。その後ディレクトリが作成されます。<br /> <figure class="figure-image figure-image-fotolife" title="next-js-generate-app"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/o/orca_gs/20211226/20211226120500.png" alt="f:id:orca_gs:20211226120500p:plain" width="600" height="834" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption></figcaption></figure> どのWebフロントエンドフレームワークもそうですが、この時点でWebアプリとして実行が可能になります。</p> <pre class="code" data-lang="" data-unlink>// アプリケーションをビルド npm run build // localhost:3000で起動 npm run start</pre> <p>起動するとlocalhost:3000でWebアプリが立ち上がっていることが確認できます。 <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/o/orca_gs/20211226/20211226120914.png" alt="f:id:orca_gs:20211226120914p:plain" width="900" height="942" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h3>Zero Config</h3> <p>文字通りwebpack等の設定ファイルを記述しなくても動かすことができるというものです。<br /> webpack(複数のjsファイルやcssファイル等を1つのファイルにまとめるツール)を扱う際にはwebpack.config.jsに設定内容を記述する必要があるのですが、初学者には少し学習コストが高かったり、設定方法が難しかったりします。<br /> そういった点において複雑な設定をはじめから請け負ってくれることは嬉しいですね。</p> <p>ただ、業務で扱う上で何かしらの設定を行いたいケースが出てくる場合もあると思います。<br /> その際は<code>next.config.js</code>に内容を記述することで自動で読み込み、設定してくれます。<br /> 今回はwebpackではありませんが、起動環境毎に環境変数を設定してページ上に表示してみます。<br /> next.config.jsでの環境変数の設定については下記リンクの記事を参考にしました。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Flightgauge.net%2Flanguage%2Fjavascript%2Fnext-config-js-use-evn" title="Next.jsでnext.config.jsを使って環境変数をあつかう方法" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;"></iframe><cite class="hatena-citation"><a href="https://lightgauge.net/language/javascript/next-config-js-use-evn">lightgauge.net</a></cite></p> <p><code>page/index.html</code>と<code>next.config.js</code>を下記のように追記していきます。</p> <pre class="code lang-html" data-lang="html" data-unlink><span class="synComment">&lt;!-- index.html --&gt;</span> ... <span class="synIdentifier">&lt;</span><span class="synStatement">main</span><span class="synIdentifier"> className=</span><span class="synConstant">{styles.main}</span><span class="synIdentifier">&gt;</span> <span class="synIdentifier">&lt;</span><span class="synStatement">h1</span><span class="synIdentifier"> className=</span><span class="synConstant">{styles.title}</span><span class="synIdentifier">&gt;</span> Welcome to <span class="synIdentifier">&lt;</span><span class="synStatement">a</span><span class="synIdentifier"> </span><span class="synType">href</span><span class="synIdentifier">=</span><span class="synConstant">&quot;https://nextjs.org&quot;</span><span class="synIdentifier">&gt;</span><span class="synUnderlined">Next.js!</span><span class="synIdentifier">&lt;/</span><span class="synStatement">a</span><span class="synIdentifier">&gt;</span> <span class="synIdentifier">&lt;/</span><span class="synStatement">h1</span><span class="synIdentifier">&gt;</span> <span class="synComment">&lt;!-- 追記 --&gt;</span> <span class="synIdentifier">&lt;</span><span class="synStatement">h2</span><span class="synIdentifier">&gt;</span>{process.env.TEST_ENV}<span class="synIdentifier">&lt;/</span><span class="synStatement">h2</span><span class="synIdentifier">&gt;</span> <span class="synComment">&lt;!-- 追記終了 --&gt;</span> </pre> <pre class="code lang-javascript" data-lang="javascript" data-unlink><span class="synComment">/* next.config.js */</span> <span class="synStatement">const</span> <span class="synIdentifier">{</span> PHASE_PRODUCTION_BUILD <span class="synIdentifier">}</span> = require( <span class="synConstant">&quot;next/constants&quot;</span> ) ; <span class="synStatement">const</span> <span class="synIdentifier">{</span> PHASE_PRODUCTION_SERVER <span class="synIdentifier">}</span> = require( <span class="synConstant">&quot;next/constants&quot;</span> ) ; <span class="synStatement">const</span> <span class="synIdentifier">{</span> PHASE_DEVELOPMENT_SERVER<span class="synIdentifier">}</span> = require( <span class="synConstant">&quot;next/constants&quot;</span> ) ; <span class="synStatement">const</span> <span class="synIdentifier">{</span> PHASE_EXPORT <span class="synIdentifier">}</span> = require( <span class="synConstant">&quot;next/constants&quot;</span> ) ; <span class="synComment">/** @type {import('next').NextConfig} */</span> module.exports = ( phase, <span class="synIdentifier">{</span> defaultConfig <span class="synIdentifier">}</span>) =&gt; <span class="synIdentifier">{</span> <span class="synStatement">if</span>( phase === PHASE_DEVELOPMENT_SERVER )<span class="synIdentifier">{</span> <span class="synStatement">return</span> <span class="synIdentifier">{</span> env: <span class="synIdentifier">{</span> TEST_ENV : <span class="synConstant">&quot;develop_env&quot;</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span> <span class="synStatement">else</span> <span class="synStatement">if</span>( phase === PHASE_PRODUCTION_SERVER ) <span class="synIdentifier">{</span> <span class="synStatement">return</span> <span class="synIdentifier">{</span> env: <span class="synIdentifier">{</span> TEST_ENV : <span class="synConstant">&quot;production_env&quot;</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span><span class="synStatement">else</span> <span class="synStatement">if</span>( phase === PHASE_EXPORT ) <span class="synIdentifier">{</span> <span class="synStatement">return</span> <span class="synIdentifier">{</span> env: <span class="synIdentifier">{</span> TEST_ENV : <span class="synConstant">&quot;export&quot;</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span><span class="synStatement">else</span> <span class="synStatement">if</span>( phase === PHASE_PRODUCTION_BUILD ) <span class="synIdentifier">{</span> <span class="synStatement">return</span> <span class="synIdentifier">{</span> env: <span class="synIdentifier">{</span> TEST_ENV : <span class="synConstant">&quot;production_build&quot;</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span> </pre> <p>設定できたかどうかを実行して<code>localhost:3000</code>で確認してみます。<br /> <code>npm run dev</code>を実行してみます。<br /> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/o/orca_gs/20211226/20211226125732.png" alt="f:id:orca_gs:20211226125732p:plain" width="840" height="229" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p><code>PHASE_DEVELOPMENT_SERVER</code>の時のENVが参照されていることが分かります。<br /> 次に<code>npm run start</code>を実行してみます。<br /> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/o/orca_gs/20211226/20211226125937.png" alt="f:id:orca_gs:20211226125937p:plain" width="691" height="176" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p><code>PHASE_PRODUCTION_BUILD</code>の時のENVが参照されていることが分かります。</p> <p>このように<code>next.config.js</code>を利用することで詳細な設定を行うこともできます。</p> <h3>SSR/SSG</h3> <p>そもそもSSRとは何なのかという話になりますが、<strong>SSR</strong>とはServer-side Renderingの略称です。<br /> 従来だとクライアントサイドで画面を描画していたものをサーバー側で行う技術のことです。<br /> メリットとして、初回アクセス時の描画が早い点やSEOに強い点などが挙げられます。</p> <p>また、<strong>SSG</strong>(Static Site Generation)という技術もあり、<a href="https://nextjs.org/docs/basic-features/pages#two-forms-of-pre-rendering">Next.jsの公式サイト</a>ではこちらが推奨されています。<br /> 理由としてはSSRはリクエスト毎にサーバー側でHTMLが生成されるのに対し、SSGはビルド時にHTMLを生成し、リクエストの際にそれを再利用するため、パフォーマンスがいいという点が挙げられます。</p> <h4>実装してみる</h4> <p>さて、実際にSSRとSSGの実装やパフォーマンスの違いを確認していきたいと思います。</p> <p>郵便番号検索APIを使って外部データを取得し、郵便番号、都道府県、市区町村を画面に表示するページをSSR、SSGの両方で実装していきます。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=http%3A%2F%2Fzipcloud.ibsnet.co.jp%2Fdoc%2Fapi" title="郵便番号検索API - zipcloud" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;"></iframe><cite class="hatena-citation"><a href="http://zipcloud.ibsnet.co.jp/doc/api">zipcloud.ibsnet.co.jp</a></cite></p> <p>実装の簡略化のため、使用するAPIのパラメータ(郵便番号)は固定で取得するようにします。 <code>/page</code>ディレクトリ以下に新たに<code>SSR.tsx</code>を作成し、下記のコードを追加します。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synStatement">function</span> SSR<span class="synStatement">(</span>api: <span class="synType">any</span><span class="synStatement">)</span> <span class="synIdentifier">{</span> <span class="synStatement">return</span> <span class="synStatement">(</span> <span class="synStatement">&lt;</span>div<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>div<span class="synStatement">&gt;</span>郵便番号: <span class="synIdentifier">{</span>api.data.zipcode<span class="synIdentifier">}</span><span class="synStatement">&lt;</span>/div<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>div<span class="synStatement">&gt;</span>都道府県: <span class="synIdentifier">{</span>api.data.address1<span class="synIdentifier">}</span><span class="synStatement">&lt;</span>/div<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>div<span class="synStatement">&gt;</span>市区町村: <span class="synIdentifier">{</span>api.data.address2<span class="synIdentifier">}</span><span class="synStatement">&lt;</span>/div<span class="synStatement">&gt;</span> <span class="synStatement">&lt;</span>/div<span class="synStatement">&gt;</span> <span class="synStatement">)</span> <span class="synIdentifier">}</span> <span class="synStatement">export</span> <span class="synStatement">async</span> <span class="synStatement">function</span> getServerSideProps<span class="synStatement">()</span> <span class="synIdentifier">{</span> <span class="synComment">// 郵便番号検索APIを叩く</span> <span class="synType">const</span> res <span class="synStatement">=</span> <span class="synStatement">await</span> <span class="synSpecial">fetch</span><span class="synStatement">(</span><span class="synConstant">`https://zipcloud.ibsnet.co.jp/api/search?zipcode=1000000`</span><span class="synStatement">)</span> <span class="synType">const</span> apiData <span class="synStatement">=</span> <span class="synStatement">await</span> res.json<span class="synStatement">()</span> <span class="synComment">// データパラメーター(results[])の受け取り</span> <span class="synType">const</span> data <span class="synStatement">=</span> apiData.results<span class="synIdentifier">[</span><span class="synConstant">0</span><span class="synIdentifier">]</span> <span class="synComment">// 要素1つの配列のためインデックス0で受け取り</span> <span class="synStatement">return</span> <span class="synIdentifier">{</span> props: <span class="synIdentifier">{</span> data <span class="synIdentifier">}</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span> <span class="synStatement">export</span> <span class="synStatement">default</span> SSR </pre> <p>ここでキモになるのが、<code>getServerSideProps()</code>です。<br /> この関数を置いてあげることで、Next.js側はサーバーサイドレンダリングを行ってくれます。 実際に動かしてみると単に取得したデータを表示するページができました。<br /> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/o/orca_gs/20211226/20211226165838.png" alt="f:id:orca_gs:20211226165838p:plain" width="271" height="149" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>同様にSSGで実装してみます。<code>/page</code>ディレクトリ以下に<code>SSG.tsx</code>を作成します。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// function部分はメソッド名のみSSGに変更、内容は同じ</span> ... <span class="synComment">// 以下を変更</span> <span class="synStatement">export</span> <span class="synStatement">async</span> <span class="synStatement">function</span> getStaticProps<span class="synStatement">()</span> <span class="synIdentifier">{</span> <span class="synType">const</span> res <span class="synStatement">=</span> <span class="synStatement">await</span> <span class="synSpecial">fetch</span><span class="synStatement">(</span><span class="synConstant">`https://zipcloud.ibsnet.co.jp/api/search?zipcode=1000000`</span><span class="synStatement">);</span> <span class="synType">const</span> apiData <span class="synStatement">=</span> <span class="synStatement">await</span> res.json<span class="synStatement">();</span> <span class="synType">const</span> data <span class="synStatement">=</span> apiData.results<span class="synIdentifier">[</span><span class="synConstant">0</span><span class="synIdentifier">]</span> <span class="synStatement">return</span> <span class="synIdentifier">{</span> props: <span class="synIdentifier">{</span>data<span class="synIdentifier">}</span> <span class="synIdentifier">}</span><span class="synStatement">;</span> <span class="synIdentifier">}</span> <span class="synStatement">export</span> <span class="synStatement">default</span> SSG </pre> <p>実行結果はSSRと変わらないので省略しますが、<code>getStaticProps()</code>関数を置くことで、Next.js側はSSGと認識して動作してくれます。</p> <h4>パフォーマンスの違い</h4> <p>先の文でも記述していますが、SSRとSSGの違いはリクエスト毎にHTMLを生成しているのかどうかです。<br /> Chromeのシークレットモードで検証した結果が以下のとおりです。</p> <p><strong>SSRでの結果</strong> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/o/orca_gs/20211226/20211226170825.png" alt="f:id:orca_gs:20211226170825p:plain" width="1200" height="488" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p><strong>SSGでの結果</strong> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/o/orca_gs/20211226/20211226170744.png" alt="f:id:orca_gs:20211226170744p:plain" width="1200" height="487" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>どちらも同じ表示なので見えづらいですが、SSRの表示読み込みが<code>1.91s</code>に対し、SSGが<code>186ms</code>と圧倒的な速さを見せております。<br /> 再リロードして検証してみてもSSRは<code>856ms</code>、SSGは<code>50ms</code>となっておりました。</p> <h3>API Routes</h3> <p>Next.jsはフロントエンド向けのフレームワークですが、APIの構築も可能となっております。<br /> とはいっても標準で同一オリジンからしかAPIを受け付けていません。CORSのミドルウェアをラップしてあげれば動くみたいです。</p> <p><code>/page/api</code>ディレクトリ以下にファイルを配置してあげるとpageの代わりにAPIのエンドポイントとして認識してくれます。<br /> <code>create next app</code>した際に既に<code>/page/api/Hello.tsx</code>が存在しており、<code>/api/hello</code>でJSONが返ってくるようになっています。</p> <p>サンプルを参考にして、メソッド毎にレスポンスを変える実装をしてみます。</p> <p><code>/page/api</code>ディレクトリ以下に<code>comment.tsx</code>を作成し、下記の内容を追加します。</p> <pre class="code lang-typescript" data-lang="typescript" data-unlink><span class="synComment">// comment.tsx</span> <span class="synStatement">import</span> <span class="synStatement">type</span> <span class="synIdentifier">{</span> NextApiRequest<span class="synStatement">,</span> NextApiResponse <span class="synIdentifier">}</span> <span class="synStatement">from</span> <span class="synConstant">'next'</span> <span class="synStatement">type</span> Data <span class="synStatement">=</span> <span class="synIdentifier">{</span> comment: <span class="synType">string</span> <span class="synIdentifier">}</span> <span class="synStatement">export</span> <span class="synStatement">default</span> <span class="synStatement">function</span> handler<span class="synStatement">(</span> req: NextApiRequest<span class="synStatement">,</span> res: NextApiResponse<span class="synStatement">&lt;</span>Data<span class="synStatement">&gt;</span> <span class="synStatement">)</span> <span class="synIdentifier">{</span> <span class="synStatement">if</span> <span class="synStatement">(</span>req.method <span class="synStatement">===</span> <span class="synConstant">'POST'</span><span class="synStatement">)</span> <span class="synIdentifier">{</span> res.<span class="synStatement">status(</span><span class="synConstant">200</span><span class="synStatement">)</span>.json<span class="synStatement">(</span>req.body<span class="synStatement">)</span> <span class="synIdentifier">}</span> <span class="synStatement">else</span> <span class="synIdentifier">{</span> res.<span class="synStatement">status(</span><span class="synConstant">200</span><span class="synStatement">)</span>.json<span class="synStatement">(</span><span class="synIdentifier">{</span>comment: <span class="synConstant">&quot;comment&quot;</span><span class="synIdentifier">}</span><span class="synStatement">)</span> <span class="synIdentifier">}</span> <span class="synIdentifier">}</span> </pre> <p>コードを見て分かるようにPOSTメソッドの際はリクエストの内容を単純にオウム返しするだけとなっており、それ以外は固定値を返すようにしています。<br /> Postmanを使ってAPIを叩いてみます。</p> <p><strong>POSTメソッドの結果</strong> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/o/orca_gs/20211226/20211226175730.png" alt="f:id:orca_gs:20211226175730p:plain" width="804" height="516" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>ちゃんとbodyの中身がJSONの形式で返ってきていることが分かります。</p> <p><strong>POST以外の結果</strong> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/o/orca_gs/20211226/20211226175920.png" alt="f:id:orca_gs:20211226175920p:plain" width="1108" height="409" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p><code>hello.tsx</code>の内容を参考にしているので当たり前ですが、固定のレスポンスが返ってきているのが分かります。</p> <p>利用シーンとしては、フロントエンド開発時のモックAPIとしての活用ができそうな気がしました。</p> <h2>まとめ</h2> <ul> <li>Next.jsはReactベースのフロントエンドフレームワークだよ。</li> <li>Zero Configで設定を意識せずに動かすことができるよ。</li> <li>SSR/SSGでHTMLをサーバー側で描画できるようになるよ。Next.jsはパフォーマンスの向上を理由としてSSGを推奨しているよ。</li> <li>Next.jsをAPIサーバーとしても利用することができるよ。ただし、標準では同一オリジンしか叩けないよ。</li> </ul> <p>久々にフロントエンド系のフレームワークに触れてみましたが、機能や考え方などが変わってきていることを身を持って実感しました。<br /> Next.jsとしての機能はほんの一部をかじった程度に過ぎないので、これからも継続して勉強を行い、ブログのネタにできたらと思います。</p> <h2>最後に</h2> <p>弊社では絶賛エンジニア募集中です。BtoCのサービス開発をしてみたい方や、植物に興味のある方は是非応募してください。 カジュアルに話だけでも聞きたいという方もお待ちしてます。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.wantedly.com%2Fcompanies%2Fgreensnap" title="GreenSnap株式会社の会社情報 - Wantedly" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;"></iframe><cite class="hatena-citation"><a href="https://www.wantedly.com/companies/greensnap">www.wantedly.com</a></cite></p> orca_gs Amazon QuickSightを使って都内のサウナイキタイの「イキタイ」を可視化する hatenablog://entry/13574176438045649037 2021-12-27T11:23:37+09:00 2021-12-30T00:13:29+09:00 こんにちは、GreenSnapでiOSエンジニアをやっている山野です。 この記事は、弊社で社内の非エンジニアにも使ってもらえるようなBIツールを探しているときに候補に上がったAWSのQuickSightを調査するにあたって、どうせ色々触ってみるなら、自分の好きなものを対象にしてみたいなと思い、最近のマイブームであるサウナをテーマにして、QuickSightで遊んでみたというネタ記事です。 可視化するデータは、「サウナイキタイ」というポータルサイトを利用します。 aws.amazon.com サウナイキタイとは サウナイキタイとは、サウナ好きなら一度は見たことはあるであろう、国内最大規模のサウナ… <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yamano-hidenori/20211227/20211227135439.png" alt="f:id:yamano-hidenori:20211227135439p:plain" width="1200" height="630" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span> こんにちは、GreenSnapでiOSエンジニアをやっている山野です。 この記事は、弊社で社内の非エンジニアにも使ってもらえるようなBIツールを探しているときに候補に上がった<a href="https://aws.amazon.com/jp/quicksight/">AWSのQuickSight</a>を調査するにあたって、どうせ色々触ってみるなら、自分の好きなものを対象にしてみたいなと思い、最近のマイブームであるサウナをテーマにして、QuickSightで遊んでみたというネタ記事です。 可視化するデータは、「サウナイキタイ」というポータルサイトを利用します。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Faws.amazon.com%2Fjp%2Fquicksight%2F" title="Amazon QuickSight(あらゆるデバイスからアクセス可能な高速BIサービス)| AWS" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;"></iframe><cite class="hatena-citation"><a href="https://aws.amazon.com/jp/quicksight/">aws.amazon.com</a></cite></p> <h2>サウナイキタイとは</h2> <p>サウナイキタイとは、サウナ好きなら一度は見たことはあるであろう、国内最大規模のサウナのポータルサイトです。サウナにとって重要な指標である、サウナの温度、水風呂の温度などの基本情報から、アメニティなどの細かい情報までまとまっており、サイト内の独自の指標「イキタイ」により、サウナの人気度合いがわかったり、「サ活」でみんなのサウナ日記を見ることもできる神サイトです。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fsauna-ikitai.com%2F" title="サウナイキタイ - 日本最大のサウナ検索サイト" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;"></iframe><cite class="hatena-citation"><a href="https://sauna-ikitai.com/">sauna-ikitai.com</a></cite></p> <h2>基本方針</h2> <p>今回、サウナ分析をするにあたり、以下の方針で進めていきます。</p> <ol> <li>Pythonによるスクレイピングにより、サウナ情報を取得</li> <li>AWS QuickSightを使って可視化</li> </ol> <h2>Step.1 Pythonによるスクレイピングにより、サウナ情報を取得</h2> <p>環境構築が面倒なので、Google Colaboratory を使い、Pythonによるスクレイピングをします。 (可視化だけ見たい方は読み飛ばしてください。)</p> <h3>ソースコード</h3> <pre class="code lang-python" data-lang="python" data-unlink><span class="synComment"># 必要なライブラリのインポート</span> <span class="synPreProc">import</span> requests <span class="synPreProc">from</span> bs4 <span class="synPreProc">import</span> BeautifulSoup <span class="synPreProc">import</span> os <span class="synPreProc">import</span> pandas <span class="synStatement">as</span> pd <span class="synComment"># import geocoder</span> <span class="synComment"># はじめに、ベースとなる1ページ目のURLを定義する</span> base_url = <span class="synConstant">&quot;https://sauna-ikitai.com/search?conditions%5B%5D=target_gender%23is_male_available&amp;ordering=ikitai_counts_desc&amp;prefecture%5B%5D=tokyo&amp;target_gender%5B%5D=male&amp;water_baths__temperature%5Bmin%5D=0&quot;</span> <span class="synComment"># データ格納用のデータフレーム</span> df = pd.DataFrame() </pre> <pre class="code lang-python" data-lang="python" data-unlink><span class="synComment"># スクレイピング対象の URL にリクエストを送り HTML を取得する</span> response = requests.get(base_url) <span class="synComment"># BeautifulSoupによるHTMLのパース処理</span> soup = BeautifulSoup(response.text, <span class="synConstant">&quot;lxml&quot;</span>) <span class="synComment"># ページ数を取得</span> result_number = <span class="synIdentifier">int</span>(soup.find_all(<span class="synConstant">'p'</span>, {<span class="synConstant">'class'</span>: <span class="synConstant">'p-result_number'</span>})[<span class="synConstant">0</span>].find_all(<span class="synConstant">'span'</span>)[<span class="synConstant">0</span>].string) row = <span class="synConstant">0</span> sauna_links = [] item_num = <span class="synConstant">20</span> page_num = <span class="synIdentifier">int</span>(result_number / item_num) mod = result_number % item_num <span class="synStatement">if</span> mod != <span class="synConstant">0</span>: page_num += <span class="synConstant">1</span> <span class="synStatement">for</span> p <span class="synStatement">in</span> <span class="synIdentifier">range</span>(page_num): <span class="synComment"># スクレイピング対象の URL にリクエストを送り HTML を取得する</span> response = requests.get(base_url + <span class="synConstant">&quot;&amp;page=&quot;</span> + <span class="synIdentifier">str</span>(p+<span class="synConstant">1</span>)) <span class="synComment"># BeautifulSoupによるHTMLのパース処理</span> soup = BeautifulSoup(response.text, <span class="synConstant">&quot;lxml&quot;</span>) <span class="synComment"># class が p-saunaList の div 要素を全て取得する</span> sauna_list_elms = soup.find_all(<span class="synConstant">'div'</span>, {<span class="synConstant">'class'</span>: <span class="synConstant">'p-saunaList'</span>})[<span class="synConstant">0</span>] <span class="synComment"># ページ内リンクを取得</span> sauna_links += [url.get(<span class="synConstant">'href'</span>) <span class="synStatement">for</span> url <span class="synStatement">in</span> sauna_list_elms.find_all(<span class="synConstant">'a'</span>)] <span class="synIdentifier">print</span>(<span class="synIdentifier">str</span>(p+<span class="synConstant">1</span>) + <span class="synConstant">&quot;ページ / &quot;</span> + <span class="synIdentifier">str</span>(page_num) + <span class="synConstant">&quot;ページ&quot;</span>) </pre> <pre class="code lang-python" data-lang="python" data-unlink><span class="synComment"># サウナ名から緯度経度を取得する2</span> <span class="synStatement">def</span> <span class="synIdentifier">get_lat_lon_from_address</span>(address): geo_link = <span class="synConstant">&quot;http://geocode.csis.u-tokyo.ac.jp/cgi-bin/simple_geocode.cgi?charset=UTF8&amp;addr=&quot;</span> + address address_response = requests.get(geo_link) <span class="synComment"># BeautifulSoupによるHTMLのパース処理</span> address_soup = BeautifulSoup(address_response.text, <span class="synConstant">&quot;lxml&quot;</span>) lat = address_soup.find_all(<span class="synConstant">'latitude'</span>)[<span class="synConstant">0</span>].string lng = address_soup.find_all(<span class="synConstant">'longitude'</span>)[<span class="synConstant">0</span>].string latlng = {<span class="synConstant">&quot;lat&quot;</span>: lat, <span class="synConstant">&quot;lng&quot;</span>: lng} <span class="synStatement">return</span> latlng </pre> <pre class="code lang-python" data-lang="python" data-unlink><span class="synPreProc">import</span> re <span class="synComment"># 住所を分割し、市区町村を取得</span> <span class="synStatement">def</span> <span class="synIdentifier">get_municipalities</span>(address): matches = re.match(<span class="synConstant">r'(...??[都道府県])((?:旭川|伊達|石狩|盛岡|奥州|田村|南相馬|那須塩原|東村山|武蔵村山|羽村|十日町|上越|富山|野々市|大町|蒲郡|四日市|姫路|大和郡山|廿日市|下松|岩国|田川|大村)市|.+?郡(?:玉村|大町|.+?)[町村]|.+?市.+?区|.+?[市区町村])(.+)'</span> , address) <span class="synStatement">return</span> matches[<span class="synConstant">2</span>] </pre> <pre class="code lang-python" data-lang="python" data-unlink><span class="synStatement">def</span> <span class="synIdentifier">isfloat</span>(s): <span class="synComment"># 浮動小数点数値を表しているかどうかを判定</span> <span class="synStatement">try</span>: <span class="synIdentifier">float</span>(s) <span class="synComment"># 文字列を実際にfloat関数で変換してみる</span> <span class="synStatement">except</span> <span class="synType">ValueError</span>: <span class="synStatement">return</span> <span class="synIdentifier">False</span> <span class="synStatement">else</span>: <span class="synStatement">return</span> <span class="synIdentifier">True</span> </pre> <pre class="code lang-python" data-lang="python" data-unlink><span class="synComment"># 男、女、共用のどのタイプにデータがあるかを見て、どれを取得するか判断</span> <span class="synStatement">def</span> <span class="synIdentifier">getSpecSoup</span>(soup): tmp = soup.find_all(<span class="synConstant">'div'</span>, {<span class="synConstant">'class'</span>: <span class="synConstant">'p-saunaSpec'</span>}) <span class="synStatement">for</span> item <span class="synStatement">in</span> tmp: elms = item.find(<span class="synConstant">'div'</span>, {<span class="synConstant">'class'</span>: <span class="synConstant">'p-saunaSpecNot'</span>}) <span class="synStatement">if</span> elms == <span class="synIdentifier">None</span>: <span class="synStatement">return</span> item </pre> <pre class="code lang-python" data-lang="python" data-unlink><span class="synComment"># 温度、収容人数を取得、なければ-を返す</span> <span class="synStatement">def</span> <span class="synIdentifier">getSpecNumber</span>(p): <span class="synStatement">return</span> p.text.split(<span class="synConstant">&quot;</span><span class="synSpecial">\n</span><span class="synConstant">&quot;</span>)[<span class="synConstant">1</span>] <span class="synStatement">if</span> <span class="synIdentifier">len</span>(p.text.split(<span class="synConstant">&quot;</span><span class="synSpecial">\n</span><span class="synConstant">&quot;</span>)) &gt; <span class="synConstant">1</span> <span class="synStatement">else</span> <span class="synConstant">&quot;-&quot;</span> </pre> <pre class="code lang-python" data-lang="python" data-unlink><span class="synComment"># 値を0,1変換する</span> <span class="synStatement">def</span> <span class="synIdentifier">replace</span>(<span class="synIdentifier">str</span>): <span class="synStatement">return</span> <span class="synIdentifier">str</span>.replace(<span class="synConstant">&quot;有り&quot;</span>, <span class="synConstant">&quot;1&quot;</span>).replace(<span class="synConstant">&quot;○&quot;</span>, <span class="synConstant">&quot;1&quot;</span>).replace(<span class="synConstant">&quot;無し&quot;</span>, <span class="synConstant">&quot;0&quot;</span>).replace(<span class="synConstant">&quot;-&quot;</span>, <span class="synConstant">&quot;0&quot;</span>) <span class="synComment"># specでimgがあれば変換</span> <span class="synStatement">def</span> <span class="synIdentifier">getSpecItems</span>(td): <span class="synStatement">return</span> replace(td.find(<span class="synConstant">&quot;img&quot;</span>).get(<span class="synConstant">&quot;alt&quot;</span>)) <span class="synStatement">if</span> td.find(<span class="synConstant">&quot;img&quot;</span>) != <span class="synIdentifier">None</span> <span class="synStatement">else</span> <span class="synConstant">&quot;0&quot;</span> </pre> <pre class="code lang-python" data-lang="python" data-unlink><span class="synComment"># sauna_linkにサウナ施設のURLを渡すとデータフレームにサウナ情報が追加される</span> <span class="synComment"># rowはデータフレームの行</span> <span class="synStatement">def</span> <span class="synIdentifier">setSaunaInfoToDataFrame</span>(sauna_link, row): <span class="synComment"># スクレイピング対象の URL にリクエストを送り HTML を取得する</span> sauna_response = requests.get(sauna_link) <span class="synComment"># BeautifulSoupによるHTMLのパース処理</span> sauna_soup = BeautifulSoup(sauna_response.text, <span class="synConstant">&quot;lxml&quot;</span>) <span class="synComment"># class が p-saunaDetailShop_info の div 要素を取得する</span> sauna_list_elms = sauna_soup.find_all(<span class="synConstant">'div'</span>, {<span class="synConstant">'class'</span>: <span class="synConstant">'p-saunaDetailShop_info'</span>})[<span class="synConstant">0</span>] <span class="synComment"># サウナ情報を取得する</span> sauna_infos_keys = [<span class="synIdentifier">str</span>(td.string) <span class="synStatement">for</span> td <span class="synStatement">in</span> sauna_list_elms.find_all(<span class="synConstant">'th'</span>, {<span class="synConstant">'class'</span>: <span class="synConstant">'c-table_th'</span>})] sauna_infos_values = [<span class="synIdentifier">str</span>(td.string).replace(<span class="synConstant">&quot;</span><span class="synSpecial">\n</span><span class="synConstant">&quot;</span>, <span class="synConstant">&quot;&quot;</span>).replace(<span class="synConstant">&quot; &quot;</span>, <span class="synConstant">&quot;&quot;</span>).replace(<span class="synConstant">&quot;</span><span class="synSpecial">\r</span><span class="synConstant">&quot;</span>, <span class="synConstant">&quot; &quot;</span>) <span class="synStatement">for</span> td <span class="synStatement">in</span> sauna_list_elms.find_all(<span class="synConstant">'td'</span>, {<span class="synConstant">'class'</span>: <span class="synConstant">'c-table_td'</span>})] <span class="synComment"># イキタイを取得してkey,valueに追加</span> ikitai_elms = sauna_soup.find_all(<span class="synConstant">'div'</span>, {<span class="synConstant">'class'</span>: <span class="synConstant">'p-action_number'</span>})[<span class="synConstant">0</span>] sauna_infos_keys.append(<span class="synConstant">&quot;イキタイ&quot;</span>) sauna_infos_values.append(<span class="synIdentifier">int</span>(ikitai_elms.string)) <span class="synComment"># 緯度経度を取得する</span> address = sauna_infos_values[<span class="synConstant">2</span>] latlon = get_lat_lon_from_address(address) sauna_infos_keys.append(<span class="synConstant">&quot;緯度&quot;</span>) sauna_infos_keys.append(<span class="synConstant">&quot;経度&quot;</span>) sauna_infos_values.append(latlon[<span class="synConstant">&quot;lat&quot;</span>]) sauna_infos_values.append(latlon[<span class="synConstant">&quot;lng&quot;</span>]) <span class="synComment"># 市区町村を取得</span> municipalities = get_municipalities(address) sauna_infos_keys.append(<span class="synConstant">&quot;市区町村&quot;</span>) sauna_infos_values.append(municipalities) <span class="synComment"># 男、女、共用のどのタイプにデータがあるかを見て、どれを取得するか判断</span> sauna_spec_soup = getSpecSoup(sauna_soup) sauna_spec_elms = sauna_spec_soup.find_all(<span class="synConstant">'div'</span>, {<span class="synConstant">'class'</span>: <span class="synConstant">'p-saunaSpec_main'</span>})[<span class="synConstant">0</span>] <span class="synComment"># サウナと水風呂の温度、収容人数など取得</span> tmp_people = [getSpecNumber(p) <span class="synStatement">for</span> p <span class="synStatement">in</span> sauna_spec_elms.find_all(<span class="synConstant">'p'</span>, {<span class="synConstant">'class'</span>: <span class="synConstant">'p-saunaSpecItem_people'</span>})[<span class="synConstant">0</span>:<span class="synConstant">2</span>]] tmp_temp = [getSpecNumber(p) <span class="synStatement">for</span> p <span class="synStatement">in</span> sauna_spec_elms.find_all(<span class="synConstant">'p'</span>, {<span class="synConstant">'class'</span>: <span class="synConstant">'p-saunaSpecItem_number'</span>})[<span class="synConstant">0</span>:<span class="synConstant">2</span>]] <span class="synComment"># tmp_peopleがまれにない場合があるので適当に追加</span> tmp_people += [<span class="synConstant">&quot;-&quot;</span>, <span class="synConstant">&quot;-&quot;</span>] <span class="synComment"># floatに変換できるかどうか判定し、できない場合は&quot;-&quot;を格納</span> sauna_people_vals = [<span class="synIdentifier">float</span>(item) <span class="synStatement">if</span> isfloat(item) <span class="synStatement">else</span> <span class="synConstant">&quot;-&quot;</span> <span class="synStatement">for</span> item <span class="synStatement">in</span> tmp_people] sauna_temperature_vals = [<span class="synIdentifier">float</span>(item) <span class="synStatement">if</span> isfloat(item) <span class="synStatement">else</span> <span class="synConstant">&quot;-&quot;</span> <span class="synStatement">for</span> item <span class="synStatement">in</span> tmp_temp] sauna_infos_keys.append(<span class="synConstant">&quot;サウナ収容人数&quot;</span>) sauna_infos_values.append(sauna_people_vals[<span class="synConstant">0</span>]) sauna_infos_keys.append(<span class="synConstant">&quot;水風呂収容人数&quot;</span>) sauna_infos_values.append(sauna_people_vals[<span class="synConstant">1</span>]) sauna_infos_keys.append(<span class="synConstant">&quot;サウナ温度&quot;</span>) sauna_infos_values.append(sauna_temperature_vals[<span class="synConstant">0</span>]) sauna_infos_keys.append(<span class="synConstant">&quot;水風呂温度&quot;</span>) sauna_infos_values.append(sauna_temperature_vals[<span class="synConstant">1</span>]) <span class="synComment"># その他情報を取得</span> spec_elms = sauna_spec_soup.find_all(<span class="synConstant">'table'</span>, {<span class="synConstant">'class'</span>: <span class="synConstant">'p-saunaSpecTable'</span>})[<span class="synConstant">0</span>] spec_keys = [div.text <span class="synStatement">for</span> div <span class="synStatement">in</span> spec_elms.find_all(<span class="synConstant">'div'</span>, {<span class="synConstant">'class'</span>: <span class="synConstant">'p-saunaSpecTable_name'</span>})] spec_values = [getSpecItems(td) <span class="synStatement">for</span> td <span class="synStatement">in</span> spec_elms.find_all(<span class="synConstant">'td'</span>, {<span class="synConstant">'class'</span>: <span class="synConstant">'p-saunaSpecTable_mark'</span>})] sauna_infos_keys += spec_keys sauna_infos_values += spec_values <span class="synComment"># さらに細かい情報を取得</span> other_spec_elms = sauna_soup.find_all(<span class="synConstant">'div'</span>, {<span class="synConstant">'class'</span>: <span class="synConstant">'p-saunaSpecDetail'</span>})[<span class="synConstant">0</span>] other_spec_keys = [span.text <span class="synStatement">for</span> span <span class="synStatement">in</span> other_spec_elms.find_all(<span class="synConstant">'span'</span>, {<span class="synConstant">'class'</span>: <span class="synConstant">'p-saunaSpecList_key'</span>})] other_spec_values = [replace(span.text) <span class="synStatement">for</span> span <span class="synStatement">in</span> other_spec_elms.find_all(<span class="synConstant">'span'</span>, {<span class="synConstant">'class'</span>: <span class="synConstant">'p-saunaSpecList_value'</span>})] sauna_infos_keys += other_spec_keys sauna_infos_values += other_spec_values <span class="synComment"># サウナ情報をデータフレームに入れる</span> <span class="synStatement">for</span> index, sauna_info <span class="synStatement">in</span> <span class="synIdentifier">enumerate</span>(sauna_infos_values): key = sauna_infos_keys[index] <span class="synStatement">if</span> sauna_info == <span class="synConstant">&quot;&quot;</span> <span class="synStatement">or</span> sauna_info == <span class="synConstant">&quot;None&quot;</span>: sauna_info = <span class="synConstant">&quot;-&quot;</span> df.loc[row, key] = sauna_info </pre> <pre class="code lang-python" data-lang="python" data-unlink>start_index = <span class="synConstant">0</span> <span class="synStatement">for</span> row, sauna_link <span class="synStatement">in</span> <span class="synIdentifier">enumerate</span>(sauna_links[start_index:]): <span class="synIdentifier">print</span>(sauna_link) setSaunaInfoToDataFrame(sauna_link, row + start_index) <span class="synIdentifier">print</span>(<span class="synIdentifier">str</span>(row + start_index + <span class="synConstant">1</span>) + <span class="synConstant">&quot; / &quot;</span> + <span class="synIdentifier">str</span>(result_number)) </pre> <pre class="code lang-python" data-lang="python" data-unlink><span class="synComment"># 空欄を0埋め</span> df.fillna(<span class="synConstant">0</span>, inplace=<span class="synIdentifier">True</span>) <span class="synComment"># csv出力</span> df.to_csv(<span class="synConstant">'sauna.csv'</span>, index=<span class="synIdentifier">False</span>) </pre> <p>上記コードを上から順に実行することで、最終的にsauna.csvというcsvファイルが生成されます。 ソースコードはGitHubにも上げているので参考にしてください。</p> <p><a href="https://gist.github.com/yamano-h/e7eacf008e59581a9ea4057ab41ce9d6#file-sauna-ipynb">sauna.ipynb &middot; GitHub</a></p> <h2>Step.2 AWS QuickSightによる可視化</h2> <p>いよいよ、QuickSightの出番です。 QuickSightは初見では少しわかりづらいですので、順を追って解説します。</p> <h3>1. データセットのインポート</h3> <p>Step.1で作成した<code>sauna.csv</code> というファイルをQuickSightのデータセットとしてアップロードします。 QuickSightのメニューから、データセットを選択し、右上の「新しいデータセット」をクリックします。 <figure class="figure-image figure-image-fotolife" title="新しいデータセット"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yamano-hidenori/20211224/20211224141135.png" alt="f:id:yamano-hidenori:20211224141135p:plain" width="1200" height="761" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>新しいデータセット</figcaption></figure></p> <p>すると、データセットとして選択できるものの一覧が表示されます。 今回はcsvを直接アップロードするので、「ファイルのアップロード」を選択します。 <figure class="figure-image figure-image-fotolife" title="データセットソース一覧"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yamano-hidenori/20211224/20211224141140.png" alt="f:id:yamano-hidenori:20211224141140p:plain" width="1200" height="761" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>データセットソース一覧</figcaption></figure></p> <p>Step.1で作成した<code>sauna.csv</code>をアップロードします。 <figure class="figure-image figure-image-fotolife" title="アップロード"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yamano-hidenori/20211224/20211224142419.png" alt="f:id:yamano-hidenori:20211224142419p:plain" width="1200" height="761" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>アップロード</figcaption></figure> アップロードが完了すると、↑のようにアップロード内容のプレビューがでます。 問題なければ、左下の「設定の編集」をクリックします。</p> <h3>2. データセットの編集</h3> <p><figure class="figure-image figure-image-fotolife" title="編集"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yamano-hidenori/20211224/20211224142339.png" alt="f:id:yamano-hidenori:20211224142339p:plain" width="1200" height="761" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>編集</figcaption></figure> するとデータセットの編集画面へ遷移します。 このまま何もしなくても問題ありませんが、今回は緯度と経度があるので、データの型を修正します。 対象の型をタップして、「緯度」または「軽度」と選択するとデータ型が切り替わります。 <figure class="figure-image figure-image-fotolife" title="編集完了"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yamano-hidenori/20211224/20211224142350.png" alt="f:id:yamano-hidenori:20211224142350p:plain" width="1200" height="761" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>編集完了</figcaption></figure> 完了したら、公開して視覚化をクリックします。 すると、分析画面へ遷移します。</p> <h3>3. データの可視化(基本編)</h3> <p>いよいよ可視化に移ります。 まずは、サウナイキタイにおける人気度をあらわす「イキタイ」数を地図上にマッピングしてみます。 まずは、左下のビジュアルタイプから、「地図上のポイント」を選択します。 <figure class="figure-image figure-image-fotolife" title="地図選択"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yamano-hidenori/20211224/20211224142328.png" alt="f:id:yamano-hidenori:20211224142328p:plain" width="1200" height="761" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>地図選択</figcaption></figure></p> <p>次に、左のフィールドリストから、緯度、経度を探し、画面上部のフィールドウェルの中の、Geospatial内へドラッグアンドドロップします。 さらに、Sizeにイキタイ(合計)、Colorに施設名をドラッグアンドドロップします。 するとこのように、地図上にイキタイ数に応じて円のサイズが違うものが地図上にマッピングされます。 <figure class="figure-image figure-image-fotolife" title="マッピング"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yamano-hidenori/20211224/20211224142406.png" alt="f:id:yamano-hidenori:20211224142406p:plain" width="1200" height="761" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>マッピング</figcaption></figure> ズームして見た感じ、池袋エリア、上野エリア、錦糸町エリアに人気のサウナがまとまってそうな気がします。 <figure class="figure-image figure-image-fotolife" title="マップズーム"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yamano-hidenori/20211227/20211227145532.png" alt="f:id:yamano-hidenori:20211227145532p:plain" width="1200" height="556" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>マップズーム</figcaption></figure></p> <p>他にも、いろんなビジュアルタイプがあるので、手当り次第触ってみるのがいいかと思います。たとえば、サウナと水風呂の温度を散布図を使ってマッピングすると、 <figure class="figure-image figure-image-fotolife" title="散布図"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yamano-hidenori/20211224/20211224164746.png" alt="f:id:yamano-hidenori:20211224164746p:plain" width="1200" height="761" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>散布図</figcaption></figure> こんな感じで表現できますし、 シンプルに表を使い、 <figure class="figure-image figure-image-fotolife" title="表"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yamano-hidenori/20211224/20211224164950.png" alt="f:id:yamano-hidenori:20211224164950p:plain" width="1200" height="761" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>表</figcaption></figure> イキタイ数順に並び替えたりもできます。</p> <h3>4. データの可視化(応用編)</h3> <p>また、可視化対象をフィルターする機能も便利なので、少し紹介させてください。 フィルターは少し複雑ですが、慣れれば色々と柔軟に行えて便利な機能です。 今回は、上記で作成した図を、施設名で絞り込んでみたいと思います。 手順は3つあります。</p> <h4>4.1. パラメータ作成</h4> <p>まずはパラメータを作成します。パラメータには、検索対象のフィールドを指定します。適当な名前をつけ、↓のように設定して作成しておきます。 <figure class="figure-image figure-image-fotolife" title="パラメータ作成"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yamano-hidenori/20211224/20211224165224.png" alt="f:id:yamano-hidenori:20211224165224p:plain" width="1200" height="761" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>パラメータ作成</figcaption></figure></p> <h4>4.2. コントロールの追加</h4> <p>パラメータが作成されると上記のような画面が出るので、コントロールの追加をおこないます。これにより、フィルタ時に利用するコンポーネントを追加できます。 <figure class="figure-image figure-image-fotolife" title="コントロールの追加"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yamano-hidenori/20211224/20211224165450.png" alt="f:id:yamano-hidenori:20211224165450p:plain" width="1200" height="761" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>コントロールの追加</figcaption></figure> 今回は施設名でフィルターしたいので、テキストフィールドを使用します。 <figure class="figure-image figure-image-fotolife" title="コントロール作成"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yamano-hidenori/20211224/20211224165531.png" alt="f:id:yamano-hidenori:20211224165531p:plain" width="1200" height="761" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>コントロール作成</figcaption></figure></p> <h4>4.3. フィルタの作成</h4> <p>最後にフィルタの作成を行います。今回は施設名で絞りたいので、フィルタするフィールドに、「施設名」を選択します。 <figure class="figure-image figure-image-fotolife" title="パラメータ項目選定"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yamano-hidenori/20211224/20211224180617.png" alt="f:id:yamano-hidenori:20211224180617p:plain" width="1200" height="761" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>パラメータ項目選定</figcaption></figure> カスタムフィルタを選択し、1で作成したパラメータと紐付けます。 <figure class="figure-image figure-image-fotolife" title="カスタムフィールド"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yamano-hidenori/20211224/20211224180926.png" alt="f:id:yamano-hidenori:20211224180926p:plain" width="1200" height="761" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>カスタムフィールド</figcaption></figure></p> <p>少し複雑でしたが、設定は以上です。実際にフィルターを使ってみます。先程作成したテキストフィールドから、試しにわたしのホームサウナである「駒の湯」と入力すると、ダッシュボード内の図が、すべて駒の湯だけに絞り込まれました。 <figure class="figure-image figure-image-fotolife" title="フィルター機能1"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yamano-hidenori/20211224/20211224181007.png" alt="f:id:yamano-hidenori:20211224181007p:plain" width="1200" height="761" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>フィルター機能1</figcaption></figure></p> <p><figure class="figure-image figure-image-fotolife" title="フィルター機能2"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/yamano-hidenori/20211224/20211224181205.png" alt="f:id:yamano-hidenori:20211224181205p:plain" width="1200" height="761" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>フィルター機能2</figcaption></figure></p> <h2>まとめ</h2> <p>今回は、サウナイキタイのデータをスクレイピングして収集し、QuickSightを使って可視化してみました。QuickSightを使ってみた所感としては、正直慣れるまでかなり使いづらかったです… ただ、個人的に地図上マッピングは感動しましたし、フィルター機能も使いこなせば色々なことができそうな気はしています。今回の調査を踏まえて、ダッシュボードを作って社内に公開することで、社内のメンバーなら誰でもGreenSnap内のデータ分析ができるようなダッシュボードを作っていけたらなと思います。この記事が、これからQuickSightを使おうか悩まれている方、そしてサウナが大好きな方の参考になりましたら幸いです。笑</p> <h2>最後に</h2> <p>弊社では絶賛エンジニア募集中です。BtoCのサービス開発をしてみたい方や、植物に興味のある方は是非応募してください。 カジュアルに話だけでも聞きたいという方もお待ちしてます。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.wantedly.com%2Fcompanies%2Fgreensnap" title="GreenSnap株式会社の会社情報 - Wantedly" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;"></iframe><cite class="hatena-citation"><a href="https://www.wantedly.com/companies/greensnap">www.wantedly.com</a></cite></p> yamano-hidenori Notionを導入してスクラムを始めました hatenablog://entry/13574176438040282941 2021-12-07T21:57:22+09:00 2021-12-30T00:10:25+09:00 こんにちは、GreenSnapのCTOの高畑です。弊社ではエンジニアチームのWikiとしてNotionを導入しました。ClickUpのSprintテンプレートと同じような使い方ができるテンプレートをNotionにも用意できたので紹介します。 公開リンクを置いておくので見てもらえれば早いのですが簡単に使い方と実際の弊社の1週間の開発の流れを説明します。 <h2>はじめに</h2> <p>こんにちは、GreenSnapのCTOの高畑です。弊社ではエンジニアチームのWikiとしてNotionを導入しました。始めはタスク管理はClickUpでドキュメントはNotionと分けていたのですがNotionの使い方を調べていくうちに<a href="https://www.notion.so/Jira-Alternative-Notion-template-93880ffefbbd4931900ffd11430859fd">Jira Alternative | Notion template</a> こちらのテンプレートを発見しました。 これを元に自分たちが使いやすいようにカスタマイズして、ClickUpのSprintテンプレートと同じような使い方ができるテンプレートをNotionにも用意できたので紹介します。 公開リンクを置いておくので見てもらえれば早いのですが簡単に使い方と実際の弊社の1週間の開発の流れを説明します。</p> <p>こちらが実際に使ってるテンプレートのサンプルです <a href="https://www.notion.so/Template-91b31297d08d4a6c89e4a709bae57cb6">&#x30B9;&#x30AF;&#x30E9;&#x30E0;Template</a></p> <h2>下準備</h2> <h3>とりあえずSprintsの作成</h3> <p><a href="https://www.notion.so/93e5bc905cee4b2eb08a3f26dbc74336">Sprints</a> に期間を入力していくつか未来の分まで用意します。 SprintのDateは1週間ごとか2週間ごとかはプロジェクトに合わせてください。</p> <h3>プロダクトバックログを作る</h3> <p>プロダクトバックログの作り方はAsanaを参考にしています。 <a href="https://asana.com/ja/resources/product-backlog">https://asana.com/ja/resources/product-backlog</a></p> <blockquote><p>プロダクトバックログには、一般的にフィーチャー、バグ修正、技術的負債、知識獲得などが含まれます。このようなプロダクトバックログの項目とは、製品に対してまだ割り当てられていない明確に分割された作業内容のことです</p></blockquote> <p><a href="https://www.notion.so/9f843d3a9f974b0381dfaff23c7ce7c8">Roadmap</a> のページからEpicを作成します。 Epicは大きめの機能単位で追加していきます数週間のものから1,2ヶ月かかるもの入れています。開発予定の機能のほかにICEBOXなどもEpicとして登録しておきその中にその機能を実現するための細かいチケットを登録していきます。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/masahide318/20211207/20211207014100.png" alt="f:id:masahide318:20211207014100p:plain" width="1200" height="341" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h3>Ticketsの作成</h3> <p>Roadmapを作成したら、Sprintバックログに紐付ける用の作業単位のチケットを作成していきます。理想を言えば作業単位が2時間程度に細かく切れればいいですが弊社ではiOS,Androidともに専任がひとりなのでざっくり4時間程度で完了する粒度にしています。 Sprintの動きです。</p> <h2>スプリントプランニング</h2> <h3>Sprintを開始する</h3> <p><a href="https://www.notion.so/93e5bc905cee4b2eb08a3f26dbc74336">Sprints</a> のページから対象の日付のStatusを「In Progress」にします。 終了したSprintは「Close」に、次のSprintは「Pending」にします。 Pendingにしておくと将来のSprintに紐付けたチケットをフィルターするときに少し便利です。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/masahide318/20211207/20211207013810.png" alt="f:id:masahide318:20211207013810p:plain" width="1200" height="309" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h3>Sprintバックログを作る</h3> <p>Roadmap、もしくはTicketsのページからSprintでやるタスクを紐付けます。 Taskに日付も入れておくと1週間でどのタスクをどのタイミングで完了させるか可視化できるのでデイリースクラムで進捗を把握しやすくなります。 以上でCurrentSprintを見るとSprintバックログが出来上がります。 CurrentSprint配下に、Assign単位でFilterしたページを用意しておくと、各自のやることがわかるので便利です。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/masahide318/20211207/20211207013954.png" alt="f:id:masahide318:20211207013954p:plain" width="1200" height="650" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2>デイリースクラム</h2> <p><a href="https://www.notion.so/CurrentSprint-9b925a4d32164e4aa368fd152a4a779a">CurrentSprint</a></p> <p>CurrentSprintのページを見て各自進捗を確認します。 ここは見慣れたカンバン表示なのでチケットの進行に応じてチケットを移動させればOKです。</p> <h2>バックログリファインメント</h2> <p>Roadmapを見直して未来の予定を確認します。またICEBOXに追加されたタスクや緊急度の高い追加タスクなどを次回のSprintに紐付けます。</p> <h2><strong>スプリントレトロスペクティブ</strong></h2> <p>Notionのカンバンでやっていましたが、現在はmiroやFigjamなど別のツールを検討中</p> <h2>実際数ヶ月運用してみての感想</h2> <h3>長所</h3> <ul> <li>TicketのPropertiesのFomula、Rollup、Relationを駆使して、カレンダーやFilterなどのViewを使えば他のチケット管理ツールと遜色ないレベルのものが作成できる。</li> <li>Figmaなどの埋め込みが地味に便利</li> </ul> <h3>短所</h3> <ul> <li>過去のSprintも多くなってくると見づらくなってくるので適度にアーカイブする必要がありそう。</li> <li>振り返りでホワイトボードがほしいがさすがにホワイトボード的な使い方は難しい。</li> <li>振り返りはmiroなどと併用したほうがよさそう。</li> </ul> <h2>現在の運用の流れをまとめると</h2> <ol> <li><a href="https://www.notion.so/93e5bc905cee4b2eb08a3f26dbc74336">Sprints</a> に期間を入力していくつか未来の分まで用意します</li> <li><a href="https://www.notion.so/9f843d3a9f974b0381dfaff23c7ce7c8">Roadmap</a> に開発ロードマップも作成します。ざっくり大きな機能や実現したいことを追加します</li> <li><a href="https://www.notion.so/72b0f47b6c28443caef47744dd0dcaee">Tickets</a> にRoadmapに追加した機能を見積もり可能なレベルで分解します。ついでにStoryPointの見積もりも入れておくとEpicの合計StoryPointが見れます</li> <li>分解TicketsをSprintの紐付けて、Sprint毎のタスクを作成します</li> <li>現在のSprintのStatusを「In Progress」にします</li> <li><a href="https://www.notion.so/CurrentSprint-9b925a4d32164e4aa368fd152a4a779a">CurrentSprint</a> を見ると、In ProgressのSprintに紐付けられたタスクがボードで見れます</li> <li>毎日朝会で<a href="https://www.notion.so/CurrentSprint-9b925a4d32164e4aa368fd152a4a779a">CurrentSprint</a> のチケットを確認します</li> <li>バックログリファインメントでRoadmapやICEBOXに追加されたタスクを確認して次のSprintに紐付けます</li> </ol> <p>以上となります。</p> <p>今の所この運用で回っています。カスタマイズ性が高いのでチームの運用に合わせて柔軟に組み替えられそうです。 Notionが多機能すぎて使いこなすが難しいですが、使い方が無限大過ぎて「こういうことできるかな?」と考えるのも楽しいです。 おすすめのテンプレートや使い方があればぜひ教えて下さい。</p> <h2>最後に</h2> <p>弊社では絶賛エンジニア募集中です。toCのサービス開発をしてみたい方や、植物に興味のある方は是非応募してください。 カジュアルに話だけでも聞きたいという方もお待ちしてます。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.wantedly.com%2Fcompanies%2Fgreensnap" title="GreenSnap株式会社の会社情報 - Wantedly" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;"></iframe><cite class="hatena-citation"><a href="https://www.wantedly.com/companies/greensnap">www.wantedly.com</a></cite></p> masahide318 GreenSnap入社エントリ hatenablog://entry/26006613798250160 2021-08-23T17:02:25+09:00 2021-08-23T17:02:25+09:00 初めまして、GreenSnapのエンジニアのジュリーと申します。 サーバサイドの領域を担当しており、API開発などを行っています。 入社して3ヶ月ほど経過したので、入社エントリなるものを執筆してみました。 この記事で「GreenSnapについて」、「GreenSnapで働いてどうなのか」などについてお伝えできればと思います。 この記事でわかること GreenSnapとは 入社の経緯 入社しての感想 最後に この記事でわかること GreenSnapとは 入社の経緯 入社しての感想 GreenSnapで達成したいこと 最後に GreenSnapとは 次代の「みどりのインフラ」をつくるをミッションと… <p>初めまして、GreenSnapのエンジニアのジュリーと申します。 サーバサイドの領域を担当しており、<a class="keyword" href="http://d.hatena.ne.jp/keyword/API">API</a>開発などを行っています。</p> <p>入社して3ヶ月ほど経過したので、入社エントリなるものを執筆してみました。 この記事で「GreenSnapについて」、「GreenSnapで働いてどうなのか」などについてお伝えできればと思います。</p> <ul class="table-of-contents"> <li><a href="#この記事でわかること">この記事でわかること</a></li> <li><a href="#GreenSnapとは">GreenSnapとは</a></li> <li><a href="#入社の経緯">入社の経緯</a></li> <li><a href="#入社しての感想">入社しての感想</a></li> <li><a href="#最後に">最後に</a></li> </ul> <h4 id="この記事でわかること">この記事でわかること</h4> <ul> <li><p>GreenSnapとは</p></li> <li><p>入社の経緯</p></li> <li><p>入社しての感想</p></li> <li><p>GreenSnapで達成したいこと</p></li> <li><p>最後に</p></li> </ul> <h4 id="GreenSnapとは">GreenSnapとは</h4> <p>次代の「みどりのインフラ」をつくるをミッションとして、<a class="keyword" href="http://d.hatena.ne.jp/keyword/SNS">SNS</a>アプリ、Webメディア、<a class="keyword" href="http://d.hatena.ne.jp/keyword/EC%A5%B5%A5%A4%A5%C8">ECサイト</a>を運営している会社です。 テク<a class="keyword" href="http://d.hatena.ne.jp/keyword/%A5%CE%A5%ED">ノロ</a><a class="keyword" href="http://d.hatena.ne.jp/keyword/%A5%B8%A1%BC">ジー</a>の力を使って、より自由に<a class="keyword" href="http://d.hatena.ne.jp/keyword/%A1%D6%A4%DF%A4%C9%A4%EA%A1%D7">「みどり」</a>を楽しめる世界を実現するために日々メンバー全員で頑張っております。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/g/greensnap-tech/20210810/20210810132353.jpg" alt="f:id:greensnap-tech:20210810132353j:plain" width="1200" height="630" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>メインサービスは植物の写真を共有する<a class="keyword" href="http://d.hatena.ne.jp/keyword/SNS">SNS</a>で、一言で言うと「<a class="keyword" href="http://d.hatena.ne.jp/keyword/Instagram">Instagram</a>の植物版」です。 広告などのプロモーションを使わないオーガニック成長で2021年には240万ダウンロード、1,200万枚投稿、1,100万MAUを達成しています。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/g/greensnap-tech/20210817/20210817135242.png" alt="f:id:greensnap-tech:20210817135242p:plain" width="1200" height="600" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h4 id="入社の経緯">入社の経緯</h4> <p>私の経歴を簡単に説明させて頂くと、</p> <ul> <li><p>1社目: <a class="keyword" href="http://d.hatena.ne.jp/keyword/Java">Java</a>を中心とした受託開発と客先常駐開発を経験</p></li> <li><p>2社目: <a class="keyword" href="http://d.hatena.ne.jp/keyword/%A5%D1%A5%C3%A5%B1%A1%BC%A5%B8%A5%BD%A5%D5%A5%C8">パッケージソフト</a>の開発、運用に従事</p></li> </ul> <p>まぁ、よくある珍しくもない経歴かと思います(汗)</p> <p>お客様が<a class="keyword" href="http://d.hatena.ne.jp/keyword/%CA%DD%CD%AD">保有</a>するサービスのみを開発していたので、年々自社サービスを開発してみたいという想いを持っていました。 そんな中、GreenSnapから声をかけて頂き入社に至りました。</p> <h4 id="入社しての感想">入社しての感想</h4> <ul> <li><p>会社として若いので、どんどん自分の意見が言える→開発のみならず組織づくりにも関わっていける</p></li> <li><p>サーバー側、アプリ側、インフラなどやりたいと手を挙げれば任せてもらえる</p></li> <li><p>開発陣の技術への感度が高く、「これ面白そう」→じゃあすぐ勉強会やりましょうとなる</p></li> </ul> <p>一言でまとめると発言しやすく、挑戦したいことを任せてもらえる文化があると思います。 「自分次第でどの領域にも成長していける」。そんな会社ではないでしょうか。</p> <p>自分はサーバサイドが担当ですが、いずれはアプリサイドでも活躍していきたいと考えています。</p> <h4 id="最後に">最後に</h4> <p>ここまで記事を読んで頂きありがとうございました。GreenSnapでは絶賛エンジニアを募集しています。 自社サービスやってみたい!組織作りにも携わってきたい!という想いをお持ちの方は是非アプライしていただければと思います。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.green-japan.com%2Fjob%2F122781" title="GreenSnapの求人情報 | 転職サイトGreen(グリーン)" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;"></iframe><cite class="hatena-citation"><a href="https://www.green-japan.com/job/122781">www.green-japan.com</a></cite></p> greensnap-tech shopifyに在庫切れ通知を実装して在庫管理の運用改善 hatenablog://entry/26006613726133058 2021-05-18T13:18:01+09:00 2021-12-30T00:12:14+09:00 はじめに GreenSnapの開発者ブログを始めることになりました。エンジニアの高畑です。 初回ということで何を書こうかと思いましたが最近の取り組みとしてshopifyの在庫切れ通知を生産者と繋げた事例を紹介します。 GreenSnapではshopifyを利用して植物に関するECをGreenSnapStoreとして展開しています。 greensnap.co.jp 商品は全国の提携している生産者や市場から直接卸しており、商品が売り切れると補充しないといけないのですが都度在庫を問い合わせるのは時間がかかるのが問題でした。 もともと運用上、各市場や生産者の方は弊社のSlackチャンネルに招待していた… <h2>はじめに</h2> <p>GreenSnapの開発者ブログを始めることになりました。エンジニアの高畑です。 初回ということで何を書こうかと思いましたが最近の取り組みとしてshopifyの在庫切れ通知を生産者と繋げた事例を紹介します。</p> <p>GreenSnapではshopifyを利用して植物に関するECをGreenSnapStoreとして展開しています。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgreensnap.co.jp%2F" title="GreenSnap STORE(グリーンスナップ ストア) | 観葉植物や多肉植物、花苗の通販" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;"></iframe><cite class="hatena-citation"><a href="https://greensnap.co.jp/">greensnap.co.jp</a></cite> 商品は全国の提携している生産者や市場から直接卸しており、商品が売り切れると補充しないといけないのですが都度在庫を問い合わせるのは時間がかかるのが問題でした。 もともと運用上、各市場や生産者の方は弊社のSlackチャンネルに招待していたのでそれを利用し商品が売り切れるとリアルタイムで何の商品が売り切れたのかを通知する仕組みを作りました。現在はある市場の方に協力をいただいて試運用しておりそれがうまくまわってきたので実際にどのようにshopifyで在庫切れ通知を実装したかを紹介します。</p> <h2>在庫切れアラートの実装</h2> <p>実装のステップとしては以下のとおりです。</p> <ol> <li>slackのWebhookを受けるボットを用意する</li> <li>ShopifyのWebhookを受けるサーバーを用意する</li> <li>Webhookを受けたあとの処理の実装をする</li> <li>Shopifyの管理画面から「商品更新」のWebhookを設定する</li> <li>実際に更新通知を受けてslackに通知するか確認する</li> </ol> <p>「3. Webhookを受けたあとの処理の実装をする」の実装に関しては本記事では実際に運用しているものよりは簡略化したものを紹介します。</p> <h3>slackのWebhookを受けるボットを用意する</h3> <p>slackの<a href="https://slack.com/intl/ja-jp/help/articles/115005265063-Slack-%E3%81%A7%E3%81%AE-Incoming-Webhook-%E3%81%AE%E5%88%A9%E7%94%A8">Incoming Webhookのチュートリアル</a>どおりにIncoming WebhookのURLを取得してください。</p> <h3>2. ShopifyのWebhookを受けるサーバーを用意する</h3> <p>サーバーは正直なんでも良いです。本記事ではfirebaseのcloud functionsで実装します。 AWSのAPIGatewayでも自前のサーバーにWebhookを受ける口を用意しても良いと思います。</p> <p>cloud functionsの導入方法は割愛します。公式のチュートリアルにしたがって導入してください <a href="https://firebase.google.com/docs/functions/get-started?hl=ja">https://firebase.google.com/docs/functions/get-started?hl=ja</a></p> <h3>3. Webhookを受けたあとの処理の実装をする</h3> <p>ここからが本題です。 Webhookの実装にあたって2つnpmでモジュールを追加しました</p> <pre class="code" data-lang="" data-unlink>npm install shopify-hmac-validation npm install request</pre> <p> <a href="https://github.com/leighs-hammer/shopify-hmac-validation">shopify-hmac-validation</a>を導入していますがこれはPOSTされたデータがshopifyからのものかを検証するロジックを簡略化するためにいれました。 公式にruby,php,pythonのサンプルソースコードはあるのでそちらの言語を使っている場合は参考にしてください。 <a href="https://shopify.dev/tutorials/manage-webhooks">https://shopify.dev/tutorials/manage-webhooks</a></p> <p>index.jsを以下のように実装します。</p> <pre class="code js" data-lang="js" data-unlink> const functions = require(&#39;firebase-functions&#39;); const http = require(&#39;request&#39;); const checkHmacValidity = require(&#39;shopify-hmac-validation&#39;).checkWebhookHmacValidity const slackWebhookUrl = &#34;各自置き換えてください&#34;//SlackのIncoming WebhookのURLに置き換えてください const shopifySecret = &#34;各自置き換えてください&#34; //後述します exports.webhookProduct = functions.https.onRequest((request, response) =&gt; { //POST出ない場合は何もしない if (request.method !== &#39;POST&#39;) { response.status(405).send(&#39;Method Not Allowed&#39;); return; } const checkHmac = checkHmacValidity(shopifySecret, request.rawBody.toString(), request.header(&#34;x-shopify-hmac-sha256&#34;)); //shopifyからのリクエスト出ない場合はなにもせずにreturn if (!checkHmac) { functions.logger.info(&#34;Hmac does not match&#34;, request.rawBody.toString()); response.send(&#34;Hmac does not match&#34;); return; } //公開済み商品でない場合は何もしない if(request.body.published_at === null) { response.send(&#34;OK&#34;); return; } request.body.variants.forEach(variant =&gt; { //商品の在庫が1以下の場合はslackにメッセージPOSTのデータ if (variant.inventory_quantity &lt;= 1) { http.post({ uri: slackWebhookUrl, headers: { &#34;Content-type&#34;: &#34;application/json&#34;, }, json: { &#34;text&#34;: &#34;「&#34; + request.body.title + &#34;」の在庫が切れそうです&#34; } }, (error, response, body) =&gt; { }); } }) response.send(&#34;OK&#34;); });</pre> <p>firebaseにデプロイします。</p> <pre class="code" data-lang="" data-unlink>firebase deploy --only functions</pre> <p>以上でfirebase側の実装は終了です。</p> <h3>4. Shopifyの管理画面から「商品更新」のWebhookを設定する</h3> <p>「商品更新」のshopifyのショップ管理画面から「設定」→「通知」→「Webhookを作成」と遷移し 「商品更新」のWebhookを作成し、Webhookを受けるURLに先程deployしたfirebaseのcloud functionのURLを設定します。 <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/masahide318/20210508/20210508233641.png" alt="f:id:masahide318:20210508233641p:plain" width="1200" height="583" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/masahide318/20210508/20210508233702.png" alt="f:id:masahide318:20210508233702p:plain" width="1200" height="336" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>先程index.jsに記述のあった</p> <pre class="code js" data-lang="js" data-unlink> const shopifySecret = &#34;各自置き換えてください&#34;</pre> <p>この部分はWebhookのページにsecretの情報があるのでそれに各自置き換えてください <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/masahide318/20210508/20210508233807.png" alt="f:id:masahide318:20210508233807p:plain" width="624" height="115" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h3>5. 実際に更新通知を受けてslackに通知するか確認する</h3> <p>ここまでくれば準備完了です。あとは試しにショップで商品を購入してみましょう 在庫が1以下になったときにアラートが来るように設定しているので、商品の在庫数を2個にして購入してください。 購入後slackのWebhookに通知がくれば完成です。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/masahide318/20210508/20210508233746.png" alt="f:id:masahide318:20210508233746p:plain" width="519" height="60" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2>弊社の実際の通知例</h2> <p>弊社の実際の通知例を紹介しますと</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/masahide318/20210508/20210508232445.png" alt="f:id:masahide318:20210508232445p:plain" width="872" height="227" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span> 在庫が0になったタイミングで通知をしており、商品のバリエーションの情報も追加で載せています。 これを見て市場の方がいくつ追加可能かレスをくださっています。 これにより在庫追加がスムーズになりました。 余談ですが、この仕組がうまくまわってきたので在庫を追加するのもSlackからできるように改善中です。</p> <h2>さいごに</h2> <p>GreenSnapでは「次代のみどりのインフラをつくる。」をミッションに絶賛メンバーを募集中です。 興味がある方は気軽に話だけでも聞きにきてもらえるとうれしいです。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwww.wantedly.com%2Fcompanies%2Fgreensnap" title="GreenSnap株式会社の会社情報 - Wantedly" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;"></iframe><cite class="hatena-citation"><a href="https://www.wantedly.com/companies/greensnap">www.wantedly.com</a></cite></p> masahide318