GreenSnap TECH BLOG

GreenSnapのエンジニアチームの取り組みや使っている技術を紹介します

Claude Codeとペアプログラミングで効率的な開発体験できた

はじめに

久しぶりに記事を書きます。GreenSnapの高畑です。 AIエージェントを使ってプログラムを書くのが当たり前になってる昨今、自分もこれまでいろいろなAIツールと開発してきましたが、今の段階でClaude Codeが気に入りました。 というのも先日、GoでのAPI開発にClaude Codeを活用してみました。およそ半日の作業で30ファイル近くの追加・変更を行いました。以下の機能を実装することができました

  • パスワードリセットAPI
  • パスワード更新API
  • 月次クレジット残数リセット用のcronジョブ

内容としてはそこまで難しくないのですが、自分で手で書くよりも圧倒的に早く仕上がります。 いわゆるRepositoryクラスでCRUDを実装する部分やDAOのオブジェクトなど、ほぼテンプレート化されてるような実装は完全にお任せして量産できます。 ControllerやServiceもほぼ真似できる型があるとそれに沿って開発してくれるので 自分ひとりで開発する場合と比較して、2〜3倍のスピードで実装できたと実感しています。開発体験は非常にスムーズで、指示からコード生成までの応答速度も速く、非常に気に入りました。 他のAIエージェントのツールと比較してどこが気に入ったのか紹介します。

DevinとClaude Codeの比較

同様のAIコーディングアシスタントであるDevinも試しましたが、個人的にはClaude Codeの方が合っていると感じました。その理由としては

  • 料金体系がAPIの使用料のみで、短時間の利用でも気軽に始められる(今回は1日で約10ドル程度の利用)
  • ローカルファイルをリアルタイムで変更してくれるため、IDE上で修正を即座に確認できる
  • 必要に応じて起動ディレクトリを変更し、読み込み対象のソースコードを限定できる

これらの特徴によって、非常に使いやすく効率的な開発環境を実現できました。 特にIDE上でdiffを確認できるので、AIに指示を伝えるのが難しい部分や、わざわざAIに任せるほどでもない部分はささっと自分で編集して続きをまたAIに任せるという作業ループができました。Devin単体だと望み通りの実装に持っていくのが難しく、ストレスになりがちでしたが、Claude Codeではそういった問題が解消されて気持ちよくコーディングできました。

CursorやClineとClaude Codeの比較

自分はIDEにJetBrains製のものを普段から使っているので、VSCode系のUIには慣れなくて開発がしにくかっただけなのでCursorやClineが問題なく使える人にはClaude Codeだから新たにできることはないと思います。

JetBrainsのJunieとClaude Codeの比較

JunieはJetBrains公式のプラグインで提供されていますが、指示出しから結果まで遅すぎて合わなかったです。 仕上がるクオリティもClaude Codeのほうが質が高く感じました。

AIとのコラボレーションで求められるスキル

AIとのペアプログラミングでは、従来のペアプロとは異なり、1行ずつ確認しながら進めるのではなく、AIが一気にコードを生成します。そのため、以下のスキルが重要になります

  • 生成されたコードの品質と正確性を迅速に判断する能力(プルリクエストのレビュースキルに近い)
  • 自分の設計思想と異なる実装に対して、適切な修正指示を出す能力
  • 最終的な完成形をしっかりイメージできること

AIの提案をそのまま採用してしまうと、ディレクトリ構成などの小さな違いが積み重なり、最終的に管理しづらいコードベースになる可能性があります。そのため、自分がイメージする完成形に向けてAIを上手く誘導する必要があります。

より効率的なAI活用のためのヒント

  1. 基本的なテンプレートや必要なファイル構造をあらかじめ用意し、それをAIに修正してもらう
  2. Claude Codeを起動する際は、必要な情報が揃っている特定のディレクトリに限定する
  3. 広範囲にわたるコード生成を一度に依頼するのではなく、ステップバイステップで進める

まとめ

  • Claude Codeは使いやすく、VSCodeのUIを使えない人にとっては開発効率を大幅に向上させるツール
  • 最も効果を発揮するのは、自分が完成形をしっかりとイメージできているプロジェクトにおいて
  • 適切な範囲設定と誘導によって、高品質なコードを効率的に生成できる

しばらくは開発のお供にはClaude Codeを活用していきたいと思います。 2,3ヶ月後には別のツールに移ってそうですが…

以下AIに出した指示たち

一部編集してますが、ほぼ原文ママです 結構雑にお願いしたり、仕様書を投げつけたり試してましたがどれもそこそこ満足のいく仕上がりを出してくれたので満足です。 依頼の合間に自分でソースを追加したり修正しています。

repositoriesディレクトリにdomainディレクトリに対応するモデルのRepositoryクラスを作ってください。すでにdomainモデルに対応するrepositoryが作られている場合は作らないでください。
プログラムの書き方はすでにある既存の書き方に沿って作ってください
passwordResetのAPIを作ってください
main.goに「/password_reset」というルーティング追加して
controllerとServiceクラスを作ってください
controllerとserviceはすでに参考となるクラスがcontrollersとservicesのディレクトリにあるのでそれを参考にしてください
repositoryはpassword_reset_repositoryがあるのでそれを使ってください

処理の流れは
メールアドレスをRequestで受け取って、対象のメールアドレスが存在する場合は
password_resetsテーブルにデータを挿入
存在しない場合はなにもせずにmessage-"success"のJSONを返して
以下の仕様に沿ってindoorgreenのパスワード更新APIを作ってください

#パスワード更新API仕様書

##概要

リクエストを受けてusersテーブルのpasswordをUPDATEします

##エンドポイント

POST <https://example.com/password>

##リクエストボディ

JSONフォーマットで以下のパラメータを送信してください。

|パラメータ名|型|必須|説明|
|---|---|---|---|
|token|String|必須|パスワードリセットのトークン|
|password|String|必須|新しいパスワード|

###リクエスト例

{
"token":"user_authentication_token",
"password":"new_secure_password"
}

##認証

このAPIエンドポイントには認証は不要です

##レスポンス

###成功時のレスポンス(200OK)

{
"message":"success"
}

##バリデーションルール

-パスワードは必須です
-パスワードは6文字以上である必要があります

###エラー時のレスポンス

###バリデーションエラー(400BadRequest)

{
"message":"パスワードは6文字以上である必要があります"
}

##その他の仕様

-トークンが無効または存在しない場合は適切なエラーレスポンスを返す必要があります(例:404NotFound)
PasswordResetServiceのパスワードをDBへいれるときに、セキュリティ的にbcryptしてDBにいれるようにして
1日に1回起動するscheduler.goを作ってください
処理の内容はuser_creditsのnext_reset_dateの情報を見て
現在時刻より過ぎていればcreditを5にし、next_reset_dateを来月にして更新します
credit_reset_service.goのnext_reset_dateはDBから取得した日時からプラス1ヶ月にするように修正して
scheduler.goが利用しているサービスのなかでpanicを起こすとWebアプリケーションそのものが終了してしまいます
なんとかcatchしてエラーログを出すようにしたい

AmazonLinux2にメールサーバーを構築する(SPF,DKIM,DMARC,TLS対応)

はじめに

2024年2月より、Googleのメール送信者のガイドラインが変更になります。
Gmailアカウントに1日あたり5,000件を超えるメールを送信する送信者は、送信ドメインにSPFレコード・DKIM署名・DMARCメール認証の設定が必要になりました。
support.google.com

1日あたり5,000件を超えるという条件に入っていなくとも、迷惑メールとして受信される確率を減らすために対応しておいたほうが良いでしょう。
この記事では、クラウドメール配信のSaaSが使用できない方向けに、AWSのEC2インスタンス上でSPF、DKIM、DMARC、TLSに対応したメールサーバーを構築する方法を紹介します。

やることのフローとしては以下の項目になります。

  • メールサーバー用のEC2インスタンスの立ち上げ
  • 逆引きレコードの設定
  • postfixのインストール
  • SPFレコードの設定
  • DKIMの設定
  • DMARCの設定
  • Let's EncryptでTLS化
  • 設定がうまくいってるか確認
  • トラブルシューティング

EC2インスタンスを立ち上げる

まずはじめにメールサーバーとして稼働させるEC2インスタンスをAWSのコンソールから立ち上げてください。
本記事ではAmazonLinux2、インスタンスタイプを「c5.large」を前提に進めます。
メールサーバーの負荷に応じてインスタンスのサイズは変更してください。

逆引きレコードの設定

メールサーバーに逆引きレコードを設定するため、Elastic IPを割り当てます。

次に、Route53からメールのFromドメインとなるホストゾーンを作成します。本記事ではホストゾーンの記述を「xxxxx.xxx」にしているので環境に合わせて変更してください。
作成したホストゾーンにAレコードを追加します。

レコード名にサンプルとして「mail」値にはElastic IPのPublicIPアドレスを入力してください。

再びElastic IPの設定画面へ行き、先程EC2インスタンスに関連付けたElastic IPの
「逆引きDNSを更新」をします。
Route53の反映に時間がかかるかもしれませんが、成功すれば「逆引きレコードDNSレコード」の項目が入力されます。

postfixのインストール

これより以下に登場するコマンドはすべてrootユーザーで実行することを想定しています。
以下のコマンドを実行し、Postfixをインストールします。Amazon Linux 2では通常、Postfixがプリインストールされていますので必要ないかもしれません。

yum update
yum install postfix
systemctl start postfix
systemctl enable postfix

/etc/postfix/main.cfの編集

Postfixの初期設定を行います。最低限以下の部分を設定してください。

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など)

SPFレコードの追加

Route53のホストゾーンにTXTレコードを以下のように追加します。SPFの対応はこれだけで完了です。

DKIMの設定

opendikmを利用して、秘密鍵と公開鍵を作成します。

amazon-linux-extras install epel -y
yum install opendkim opendkim-tools -y
systemctl start opendikm
systemctl enable opendkim
鍵を作成するディレクトリを作成
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

/etc/opendkim/keys/xxxxx.xxx/ ディレクトリにdefault.privateとdefault.txtが作成されていれば成功です。

/etc/opendkim.confの編集

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   ←コメントイン

/etc/opendkim/KeyTableファイルの編集

# 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

/etc/opendkim/SigningTableファイルの編集

SigningTableには電子署名を行う送信元アドレスを指定します。

*@xxxxx.xxx mail._domainkey.xxxxx.xxx ←追加

/etc/opendkim/TrustedHostsファイルを必要に応じて編集

メールサーバー自身がメールを送信する場合は編集不要です。
他サーバーからSMTPでメールを送信する場合は必要です。

# 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

opendkim再起動

systemctl reload opendkim

/etc/postfix/main.cfの編集

/etc/postfix/main.cfに以下を追加

smtpd_milters = inet:127.0.0.1:8891
non_smtpd_milters = inet:localhost:8891
milter_default_action = accept

postfixの再起動

systemctl reload postfix

Route53にDKIMのレコード追加

DKIMのkeyを確認
cat /etc/opendkim/keys/xxxxx.xxx/default.txt

以下のようなテキストがあるのでカッコの中身をRoute53に追加

mail._domainkey  IN  TXT ( "v=DKIM1; k=rsa; "
      "p=*********************************" )  ; ----- DKIM key default for xxxxx.xxx

DMARCの設定

Route53にレコードをTXTレコードを追加するだけです。

DMARCレコードは、以下の要素で構成されます:

  • バージョン(v): DMARCのバージョンを指定します。通常は "DMARC1" と記載されます。
  • ポリシー(p): ドメインのメールがDMARC検証に合格しなかった場合の取扱いを指定します。「none」、「quarantine」、「reject」のいずれかを設定できます。
  • SPFとDKIMの両方に合格する必要性(adkim、aspf): これらのタグは、SPFとDKIMがどの程度厳密にチェックされるべきかを指定します。通常は "r"(リラックス)または "s"(ストリクト)で指定されます。
  • サブドメインポリシー(sp): メインドメインとは異なるポリシーをサブドメインに適用する場合に使用します。
  • パーセンテージ(pct): ポリシーを適用するメールの割合を指定します。
  • レポートの送信先(rua、ruf): DMARC集計レポートと失敗レポートの受信先をメールアドレスで指定します。

Let's EncryptでTLS化

メールをTLS化します。
必要に応じて、セキュリティグループから80 と 443 ポートを許可してください。 もしかしたら環境によっては工夫が必要なところかもしれませんが何とかして発行します。

yum install certbot
certbot certonly --standalone -d xxxxx.xxx

成功すれば2つのファイルが作成されます。 - /etc/letsencrypt/live/xxxxx.xxx/fullchain.pem - /etc/letsencrypt/live/xxxxx.xxx/privkey.pem

/etc/postfix/main.cfの編集

以下を追加する

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

postfixの再起動

systemctl reload postfix

設定がうまくいっているか確認

ここまで駆け足で設定して来ましたが最後に実際にメールをメールサーバー経由で送信して設定がうまくいっているか確認します。 実際にメールサーバー経由で送信されたメールを試しにGmailアカウントへ送信し、受信したメールのソースを確認します。
SPF、DKIM、DMARCがそれぞれPASSになっていれば成功です。 また暗号化のアイコンが 灰色(TLS - 標準的な暗号化)TLS(標準的な暗号化)になっていること確認します。 送受信時のメールの暗号化 - Gmail ヘルプ

トラブルシューティング

メールが送信されない

  • /var/log/maillog を確認してエラーが出ていないか確認してください。エラー内容に沿って修正してください
  • ps aux | grep postfix でpostfixのプロセスがあるか確認してください。postfixを起動してください
  • 他のサーバーのアプリケーションからプログラム上でSMTPでメールサーバーと連携している場合、mynetworksにそのアプリケーションのサーバーのプライベートIPアドレスの記述があるか確認してください

SPFがPASSにならない

  • Route53にSPFレコードが追加されているか確認してください

DKIMがPASSにならない

  • /var/log/maillog で opendkim not authenticated のようなログが出ているか確認してください。でている場合は何かしら設定がおかしいはずです
  • Route53 に DKIMのレコードが追加されているか確認してください
  • /etc/postfix/main.cf にsmtpd_miltersやnon_smtpd_miltersの設定があるか確認してください
  • 他のサーバーのアプリケーションからプログラム上でSMTPでメールサーバーと連携している場合、TrustedHostsにアプリケーションのサーバーのPrivateIPアドレスの記述があるか確認してください
  • SigningTableに記述しているドメイン名とメールの送信元ドメインが正しいか確認してください

DMARCがPASSにならない

  • Route53にDMARCのレコードが追加されているか確認してください

さいごに

自前でメールサーバーを用意するのは大変なので、可能ならはじめからクラウドメール配信のSaaSを利用したり、AWSのSESを利用することをおすすめします

参考資料

www.rem-system.com

qiita.com

Shopifyの在庫切れ通知に返信して在庫を追加する

初めまして、GreenSnapのエンジニアの澤田です。

以前、当ブログにてご紹介したshopifyの在庫切れ通知によって、リアルタイムでどの商品が売り切れたか把握することが可能になりました。

ですが商品が売り切れる度に、担当者がshopifyにログインし在庫変更の操作をする必要がありました。 こちらが運用上の負担となっていたため、在庫切れ通知機能を拡張しslack上からshopifyへ在庫を変更できる機能を実装しました。

今回はそちらについてご紹介したいと思います。 在庫切れ通知の実装手順は下記の記事を参照ください。

greensnap-tech.hatenablog.com

仕様

在庫切れ通知に対して数値をリプライ。 スレッド返信された数値分の在庫が追加される。

手順

  1. shopifyのWebhookを受けるサーバーを用意する
  2. 在庫切れ通知に対して権限を追加
  3. Webhookを受けたあとの処理の実装をする

1. shopifyのWebhookを受けるサーバーを用意する

本記事ではfirebaseのcloud functionsで実装します。 cloud functionsの導入方法は公式のチュートリアルに従って導入してください https://firebase.google.com/docs/functions/get-started?hl=ja

2. 在庫切れ通知に対して権限を追加する

スレッドの情報を取得するためには「thread_ts」を特定する必要があります。 取得した「thread_ts」をキーとしてslackのapi「https://slack.com/api/conversations.replies」にリクエストを行い結果を取得します。 また、上記APIにリクエストするためには事前にchannnels:historyの権限を付与する必要があります。

thread_ts

thread_tsはスレッドごとに一意の値です。tsは「タイムスタンプ」のことで、スレッドの親メッセージのタイムスタンプの値のようです。 ともかく同じスレッドのメッセージであれば同じthread_tsの値を持っています。 また、このthread_tsはそもそもスレッドに紐づいた情報にしかない属性なので、この有無をメッセージがスレッドに属するかの判定に利用することもできます。

channnels:history

conversation.repliesをSlack Appで利用する場合、事前にAppのスコープとしてチャンネルの履歴の読み取りを指定しておく必要があります。 Slack Appの設定から、[OAuth & Permissions]->[Scopes]->[Add an OAuth Scope]でスコープとしてchannnels:historyを追加してください。

api.slack.com

3. Webhookを受けたあとの処理の実装をする

const functions = require('firebase-functions');
const fetch = require('node-fetch');
//shopifyのlocationId
const locationId = "各自置き換えてください"

exports.addStock = functions.https.onRequest(async (request, response) => {
  const changeStockBySlack = () => async (req, res) => {
    const body = req.body;
    //requestにthread_tsがある場合、在庫切れ通知へのスレッド返信とみなす
    if (body.event.thread_ts !== undefined) {
    const ts = body.event.thread_ts;
    //slackのチャンネルを取得
    const channel = body.event.channel;
    //チャンネルで入力された会話の内容を取得
    const replies = await getConversationsReplies(ts, channel);
    //チャンネルで入力された在庫Id, 発注数量を取得
    const stockId = replies.stockId;
    const quantity = replies.quantity;
    //shopifyの在庫変更APIにリクエスト
    await postInventoryLevel(locationId, stockId, quantity);

    res.send("OK");
    }
  };

  //チャンネルで入力された会話の内容を取得する
  const getConversationsReplies = async (channel, ts) => {
    const response = await fetch(`https://slack.com/api/conversations.replies?channel=${channel}&ts=${ts}`, {
      method: "GET",
      headers: {
        "Authorization": `Bearer ${token}`,
        "Content-Type": "application/json; charset=utf-8"
      }
    });

    if (response.status !== 200) {
      throw new Error(response.statusText);
    } else {
      //レスポンスのjsonをオブジェクトに変換
      const replies = await response.json().messages.map((msg) => {
        return new ConversationsReply(msg);
      });
      return replies;
    }
  };

  //shopifyの在庫変更APIにリクエストを行う
  const postInventoryLevel = async (locationId, inventoryItemId, availableAdjustment) => {
    const data = {
      location_id: locationId,
      inventory_item_id: inventoryItemId,
      available_adjustment: availableAdjustment
    };

    const response = await fetch(`admin/api/${API_VERSION}/inventory_levels/adjust.json`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "X-Shopify-Access-Token": accessToken
      },
      body: JSON.stringify(data)
    });

    if (response.status !== 200) {
      throw new Error(response.statusText);
    } else {
      //レスポンスのjsonをオブジェクトに変換
      const inventoryLevel = new InventoryLevelResponse(response.json().inventory_level);
      return inventoryLevel;
    }
  };
});

最後に

他サービスのAPIを触ってみると色々発見や学びがあります。学んだことをGreenSnapの発展に活かしていきたいと思います。

弊社では絶賛エンジニア募集中です。サーバーサイド、iOS、Androidエンジニアはもちろんのこと これから作成するデータ基盤とそれを活用できるデータサイエンティストの方も募集しています。

www.wantedly.com

Next.js(TypeScript) / Axios /SpringBootでmultipart/form-dataを送信する

はじめに

今年、約4年振りにGreenSnapに舞い戻ってきた伊藤です。 以前はサーバーサイド(Scala)と、iOS(Swift)の開発をしていましたが、戻ってきてからはサーバーサイドエンジニアとして、ScalaとKotlinをメインに開発しております。

今回のテーマ

業務で開発しているサービスでお問い合わせ画面を開発する機会があり、 その際に添付ファイルをアップロードする必要があった為、表題の通りNext.jsとSpringBootを使ってファイルアップロードを実装してみたいと思います。 割と簡単かと思いますが、随所にハマりポイントがあったので、誰かのお役に立てばと思います。

今回使用する技術スタックは次の通りです。

  • Next.js(TypeScript) 12.3.0

  • Axios 0.27.2

  • Sprint Boot 2.6.3

今回作るもの

よくある画面だと思いますが、名前とメールアドレス、それに加えて画像ファイルを添付できるお問い合わせ画面です。 添付する画像ファイルは複数枚対応できるものとします。

画面イメージは次の感じです。

ソースコード

実際のソースコードです。

Next.js側

ページ(/pages/FileUploadTest.tsx)

まずはNext.jsの問い合わせページのコードです。 formを設置して、適当に各input要素を置いてます。

import {ChangeEvent, FormEvent, useState} from "react";
import axios from "axios";

export default function FileUploadTest() {

  const [name, setName] = useState<string>('')
  const [mailAddress, setMailAddress] = useState<string>('')
  const [images, setImages] = useState(new Map<string, File>())

  return (
    <form method={'post'} onSubmit={(e) => register(e)}>
      <p>
        名前: 
        <input type={'text'} value={name} required={true}
                     onChange={(e) => setName(e.target.value)}></input>
        メールアドレス: 
        <input type={'email'} value={mailAddress} required={true}
               onChange={(e) => setMailAddress(e.target.value)}></input>
      </p>
      <hr/>
      <input id="file01" type="file" accept="image/jpeg, image/png" onChange={(e) => handleUpload(e)}/>
      <input id="file02" type="file" accept="image/jpeg, image/png" onChange={(e) => handleUpload(e)}/>
      <hr/>
      <button type={'submit'}>アップロード</button>
    </form>
  )

  async function handleUpload(e: ChangeEvent<HTMLInputElement>) {
    if (!e.target.files) return
    const file = e.target.files[0]

    setImages(before => new Map(before.set(e.target.id, file)))
  }

  async function register(event: FormEvent<HTMLFormElement>) {
    event.preventDefault()

    const formData = new FormData() // axiosへ渡すデータとしてFormDataを使用
    formData.append('name', name)
    formData.append('mailAddress', mailAddress)
    images.forEach((image, key) => formData.append('files', image)) // filesというキーでFileを複数セット

    await axios.post(
      `/api/fileUploadTest/upload`, // Next.jsのAPI Routesを呼ぶ
      formData,
      { headers: {'Content-Type': 'multipart/form-data'}} // contentTypeに 'multipart/form-data' を指定
    ).then(response => {
      alert('アップロードしました')
    }).catch(error => {
      alert('エラーが発生しました')
    })
  }

}

ポイントとしては、

  1. FormDataを使用する
  2. axiosでNext.jsのAPI Routesを呼ぶ(コードは後述)
  3. axios.post時にcontentTypeに multipart/form-data を指定する

でしょうか。

API Routes(/pages/api/fileUploadTest/upload.ts)

次にページから呼ばれるNext.jsのAPI Routesのコードです。

ページからリクエストを受け取り、サーバ側(SpringBoot)のエンドポイントにリクエストを投げます。

import type { NextApiRequest, NextApiResponse } from 'next'
import axios from "axios";

export const config = {
  api: { bodyParser: false } // bodyParserを無効にする
}

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (req.method !== 'POST') {
    return res.status(405).end()
  }

  axios.post(
    `http://localhost:8080/api/fileUploadTest/upload`, // SpringBoot側のエンドポイント
    req,
    // { headers: {'Content-Type': 'multipart/form-data'}} // ← NG
    { headers: {'Content-Type': req.headers["content-type"]}}
  )
    .then(response => {
      if (response.status === 200) {
        res.status(200).json({ status: 'OK' })
      } else {
        res.status(500).json({ status: 'NG' })
      }
    })

}

ポイントとしては、

  1. bodyParseを無効にする
  2. axios.post時にcontentTypeに req.headers["content-type"] を指定する

です。

bodyParserを無効にしないと、ページから渡ってきたmultipart/form-data を上手く解釈できません。 また、サーバ側へのcontentTypeにはreq.headers["content-type"]を指定する必要があります。

この時、contentTypeを指定しなかったり、multipart/form-data を再指定したりすると、SpringBoot側へ正しいリクエストとして届かずエラーになります。 自分はここに気づくのに結構時間を取られました。

SpringBoot側

最後にSpringBootで書いてあるサーバサイドのControllerです。

FileUploadTestController.kt
import org.springframework.http.HttpEntity
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile

@RestController
@RequestMapping("/api/fileUploadTest")
class FileUploadTestController {

    @PostMapping("/upload")
    fun upload(
//        @RequestParam("name") name: String, RequestParamでは文字化けする
        @RequestPart("name") name: String,
        @RequestParam("mailAddress") mailAddress: String,
        @RequestPart("files") files: List<MultipartFile>?
    ): HttpEntity<*> {
        println(name)
        println(mailAddress)
        println(files?.get(0)?.originalFilename)
        println(files?.get(1)?.originalFilename)
        return ResponseEntity.EMPTY
    }
}

ポイントとしては、

  1. name, mailAddressなどのテキストデータは@RequestPart または@RequestParamで受け取る
  2. nameなどマルチバイト文字を@ RequestParamで受け取ると(なぜか)文字化けする
  3. ファイルは@RequestPartで受け取りMultipartFileとして処理する

です。

まとめ

以上簡単ですが、Next.jsとAxiosとSpringBootでmultipart/form-dataを送信する方法でした。 調べれば、それなりに情報は出てくるのですが、(自分調べでは)断片的なものばかりで、色々な情報を組み合わせて実装するのに大変だったので、今回記事にしてみました。

最後に

弊社では絶賛エンジニア募集中です。BtoCのサービス開発をしてみたい方や、植物に興味のある方は是非応募してください。 カジュアルに話だけでも聞きたいという方もお待ちしてます。 www.wantedly.com

社内slackでスタンプを押したらブロックチェーンに記録するWeb3サービスをchatGPTに丸投げして作ってみた話

はじめに

この記事は、AIの自然言語生成モデルであるchatGPTによって作成されました。そのため、内容に誤りがある可能性があります。また一部人間によって訂正されたり、修正された場合があることをご了承ください。

web3とは

web3は、ブロックチェーンを利用するためのアプリケーションプログラミングインターフェース(API)を提供するものです。つまり、ブロックチェーンにアクセスするためのインターフェースを提供し、ブロックチェーンを利用するアプリケーションを作成するためのものです。

【補足】 ここでchatGPTが説明されているのは、javascriptのweb3ライブラリだと思われます。一般的なWeb3について質問し直したところ、以下のような回答が返ってきました。

Web3とは、インターネット上で動くアプリケーションのプラットフォームのことです。Web3は、Web 2.0よりも新しい技術を使用しており、Web 2.0が中心としているのはサーバーであるのに対し、Web3はブロックチェーンを中心としています。

ブロックチェーンとは、データを複数のコンピューター(ノード)に分散して保存する技術です。そのため、Web3アプリケーションは、データが一つのサーバーに保存されるのではなく、複数のコンピューターに分散して保存されるため、安全で信頼性が高く、偽の情報の挿入やデータの改ざんが困難です。

Web3は、仮想通貨やスマートコントラクトなど、さまざまな技術を採用しており、新しいビジネスモデルやサービスを創出するための潜在的なプラットフォームとして注目されています。

chatGPTとは

chatGPTは、OpenAIが開発した自然言語生成モデルです。入力された文章をもとに、自然な文章を生成することができます。このモデルは、自然言語処理タスクやチャットボットなどに利用されています。

実装手順

1. hardhatでローカルにブロックチェーン環境をたてる

chatGPTから、web3のおすすめのフレームワークであるhardhatをおすすめされました。hardhatは、Ethereumのブロックチェーンを利用するためのフレームワークです。

まずは、hardhatをローカル環境にインストールします。以下のコマンドを実行してください。

$ npm install hardhat

次に、ローカル環境でブロックチェーンを立ち上げます。以下のコマンドを実行してください。

$ npx hardhat node

立ち上げた際に、オーナーアドレスが表示されるので、このアドレスを保存しておきましょう。このアドレスを使用して、スマートコントラクトをデプロイしたり、トランザクションを送信することができます。

2. ブロックチェーン上にデータを記録するためのスマートコントラクトを作成する

スマートコントラクトを作成することで、ブロックチェーン上にデータを記録することができます。スマートコントラクトは、Ethereumブロックチェーン上で実行されるプログラムです。

今回は、社内のslackでスタンプを押したときに、そのスタンプを記録するスマートコントラクトを作成します。そのために、ThankYouStamp.solという名前のスマートコントラクトファイルを作成します。

以下は、ThankYouStamp.solのソースコードです。

pragma solidity ^0.8.9;

contract ThankYouStamp {
    // スタンプを送信するためのデータ構造
    struct Thanks {
        bytes32 sender;  // 送信者のslackユーザーIDをKeccak256でハッシュ化したもの
        bytes32 receiver;  // 受信者のslackユーザーIDをKeccak256でハッシュ化したもの
        uint256 timestamp;  // スタンプの送信日時をUNIX時間で保存
    }

    // 送信されたスタンプを保存するためのマップ
    mapping(bytes32 => Thanks) public thanks;

    // 送信者と受信者のslackユーザーIDをハッシュ化したものをキーに、送信されたスタンプ数を保存するためのマップ
    mapping(bytes32 => uint256) public thanksCount;

    // スタンプを送信する
    function sendThanks(string memory senderId, string memory receiverId, uint256 timestamp) public {
        // 送信者と受信者のslackユーザーIDをハッシュ化
        bytes32 sender = keccak256(abi.encodePacked(senderId));
        bytes32 receiver = keccak256(abi.encodePacked(receiverId));

        // 自分自身にスタンプを送信することはできない
        require(sender != receiver, "Cannot send a stamp to yourself");

        // スタンプを送信する
        thanks[sender] = Thanks(sender, receiver, timestamp);
        thanksCount[receiver]++;
    }

    // 特定のslackユーザーが受け取ったスタンプの累積数を取得する
    function getMyThanks(string memory userId) public view returns (uint256) {
        // slackユーザーIDをハッシュ化
        bytes32 key = keccak256(abi.encodePacked(userId));
        return thanksCount[key];
    }
}

このスマートコントラクトは、thanksCountという変数を定義しています。この変数は、各ユーザーが取得したスタンプの数を記録するものです。また、sendThanksという関数が定義されています。この関数を呼び出すことで、thanksCountを1増やすことができます。

3. スマートコントラクトをデプロイする

作成したスマートコントラクトをデプロイすることで、ブロックチェーン上に登録することができます。デプロイするためには、オーナーアドレスが必要になります。

デプロイスクリプトは、deploy.tsとして以下のように書いておきます。

import { ethers } from "hardhat";
import { ThankYouStamp } from "../typechain-types/ThankYouStamp";

async function main() {
  const ThankYouStamp = await ethers.getContractFactory("ThankYouStamp");
  // スマートコントラクトのインスタンスを生成
  const thankYouStamp = await ThankYouStamp.deploy();

  // デプロイされたスマートコントラクトのアドレスを表示
  console.log("ThankYouStamp deployed to: ", thankYouStamp.address);
}

// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.
main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

以下のコマンドを実行して、スマートコントラクトをデプロイします。

$ npx hardhat run scripts/deploy.ts --network localhost

デプロイが完了すると、スマートコントラクトのアドレスが表示されます。このアドレスを保存しておきましょう。

4. slack bolt経由でスマートコントラクトを呼び出す

次に、slack boltを使用して、スマートコントラクトを呼び出すようにします。slack boltは、slackアプリで利用するためのフレームワークです。

slacl boltの詳細については、公式サイト を参照ください。

次に、slack boltを使用して、slackでスタンプを押したときに、スマートコントラクトを呼び出す処理を記述します。

app.jsファイルを作成し、以下のように書いておきます。

const {App, ExpressReceiver} = require('@slack/bolt');
const request = require('request');
const Web3 = require('web3');

// Web3を初期化する
// URL for the Ethereum network
const networkUrl = "http://127.0.0.1:8545";

// ThankYouStampのABIをロードする
const thankYouStampABI = require('./ThankYouStamp.json').abi;

// ThankYouStampのアドレスをロードする
const thankYouStampAddress = require('./ThankYouStampAddress.json').address;

// Connect to the Ethereum network
const web3 = new Web3(networkUrl);

// ThankYouStampのコントラクトを初期化する
const thankYouStamp = new web3.eth.Contract(thankYouStampABI, thankYouStampAddress);

const TOKEN = "SLACK_API_TOKEN"
const SIGNING_SECRET = "YOUR_SIGNING_SECRET"

// ボットトークンとソケットモードハンドラーを使ってアプリを初期化します
const app = new App({
    token: TOKEN,
    signingSecret: SIGNING_SECRET
});

app.event('reaction_added', async ({event, say, client}) => {
    if (event.reaction === 'thankyou') {
        // スタンプを送信したuserIdを受け取る
        const senderId = event.user;
        // リアクションしたメッセージを送信したuserIdを受け取る
        const recipientId = event.item_user;
    
        // 自分自身に感謝スタンプを送ることはできないので、senderIdとrecipientIdが異なる場合のみ処理を行う
        if (senderId !== recipientId) {
          // スタンプの送受信をブロックチェーン上に記録する
          await thankYouStamp.methods.sendThanks(senderId, recipientId, Date.now()).send({ from: "OWNER_ADDRESS" });
    
          // ボットが日本語で返答する
          say(`<@${senderId}>さんから感謝スタンプが届きました。あなたの感謝スタンプは合計${await thankYouStamp.methods.getMyThanks(recipientId).call()}個になりました。`);
        }
    }
});

(async () => {
    // アプリを起動します
    await app.start(process.env.PORT || 3000);
    console.log('⚡️ Bolt app is running!');
})();

上記のコードでは、thankyouというスタンプが押されたときに、スマートコントラクトを呼び出す処理が記述されています。

上記コード内の、ThankYouStamp.jsonについては、手順3で生成されたものになります。また、ThankYouStampAddress.jsonについては、手順3でデプロイした際に生成されるコントラクトアドレスを記載しております。

また、OWNER_ADDRESSに関しては、手順1で保管しておいたオーナーアドレスになります。

5. slack Boltを起動して試す

最後に、slack boltを起動して、動作を確認します。以下のコマンドを実行してください。

node app.js

slack boltが起動したら、thankyouスタンプを押してみましょう。すると、スマートコントラクトが呼び出され、以下のようなメッセージが返ってくれば成功です。

slack thankyou

まとめ

今回は、社内slackでスタンプを押したらブロックチェーンに記録するweb3サービスをchatGPTに丸投げして作ってみた話をご紹介しました。スマートコントラクトを作成し、slack boltを使用してスマートコントラクトを呼び出すことで、簡単にブロックチェーンを利用するアプリケーションを作成することができました。

web3を使用することで、ブロックチェーンを利用するアプリケーションを作成することができます。また、chatGPTを使用することで、自然言語を生成することができるので、お手軽に文章を作成することができます。

今回は、スタンプを押したときに、スマートコントラクトを呼び出す処理を実装しましたが、web3を使用することで、様々なタイプのアプリケーションを作成することができます。是非、web3とchatGPTを試してみてください。

最後に

GreenSnapではエンジニアを募集中です。サーバーサイド、iOS、Androidエンジニアはもちろんのこと これから作成するデータ基盤とそれを活用できるデータサイエンティストの方も募集しています。
一人ひとりが幅広くいろんな技術領域を担当しているので、特定の技術だけを担当するより多くの技術に触れたい方や挑戦したい方は是非お声がけください。

www.wantedly.com

裏話

この記事に出てくるソースコードをchatGPTに書いてもらったときの質問内容がこちらです。

以下の要件を満たす社内の感謝スタンプシステムを作るために、下記の条件を満たすスマートコントラクト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が@メンション付きで入り、△は感謝スタンプを受け取ったユーザーの累積のスタンプ数が入ります。

最初はかなり雑な質問をしていたので、手取り足取り教えてくれましたが、そこから実際にコードを書いて、動かなかったところが修正されるように質問内容を調整することで、このような質問にたどり着きました。質問の仕方次第で全く異なる回答が返ってきたりするので、ググり力ならぬ、chatGPTへの質問力みたいなものが、今後求められる時代が来るのかもしれません。

Lambda@Edgeで画像リサイズをGUIでイチから設定する

はじめに

aws.amazon.com こちらの記事を参考に画像をリサイズするLambda@Edgeを作ろうとしたが環境構築がCloudFormationを使って0の状態から作成されており、すでに本番環境で動いてるS3やcloudfrontに対して適用するのにGUIからやりたかったのでこれを実際にGUIで設定しようとしたらどうなるのか?というのをやってみました。
一部、元の記事の設定ではうまく動かない部分やハマった部分についても記述します。もしかしたらうまく設定できない方がいれば何かの参考になるかもしれません。

CloudFormationのテンプレートを読み解く

yamlファイルは元の記事を見てもらうとして、やってることは以下のようなことになります。

  1. Lambda@Edge用のIAMRoleを作る
  2. S3のバケットを用意し、1で作ったIAMRoleに権限付与する
  3. viewer-requestとorigin-responseのlambda関数を作成する。
  4. CloudFrontを用意する。2で作ったS3をオリジンにし、3で作ったlambda関数をlambdaEdge関数として適用する

ただ今回はGUIでやっていくので多少順番を入れ替えて以下のような作業工程で完成を目指します。

  1. Lambda@Edge用のIAMRoleを作る
  2. S3のバケットを用意し、1で作ったIAMRoleに権限付与する
  3. 2で作ったS3をオリジンとするCloudFrontを用意する
  4. viewer-requestとorigin-responseのlambda関数を作成する
  5. lambdaEdge関数にデプロイする

これらを順番にGUIで設定していけば画像をリサイズするLamda@Edgeが完成し
cloudfrontドメイン/path?d=100x100
のようなURLで画像ファイルにアクセスすると100x100のリサイズされた画像が表示されるようになります。 順番に設定してみましょう。

1. Lambda@Edge用のIAMRoleを作る

  EdgeLambdaRole:
    Type: "AWS::IAM::Role"
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
            Effect: "Allow"
            Principal:
              Service:
                - "lambda.amazonaws.com"
                - "edgelambda.amazonaws.com"
            Action:
              - "sts:AssumeRole"
      Path: "/service-role/"
      ManagedPolicyArns:
        - "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"

CloudFormationでいうところこの部分を作成します

IAMRoleの管理画面から、カスタム信頼ポリシーを選択し
信頼関係に

  • edgelambda.amazonaws.com
  • lambda.amazonaws.com

の二つを追加したIAMRoleを作成します

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": [
                    "edgelambda.amazonaws.com",
                    "lambda.amazonaws.com"
                ]
            },
            "Action": "sts:AssumeRole"
        }
    ]
}

IAMRoleは以上です。このIAMRoleをS3やlambda関数に使っていきます。

2. S3のバケットを用意し、手順1で作ったIAMRoleに権限付与する

  ImageBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref ImageBucket
      PolicyDocument:
        Statement:
            - Action:
                - s3:GetObject
              Effect: Allow
              Principal: "*"
              Resource: !Sub arn:aws:s3:::${ImageBucket}/*
            - Action:
                - s3:PutObject
              Effect: Allow
              Principal:
                AWS: !GetAtt EdgeLambdaRole.Arn
              Resource: !Sub arn:aws:s3:::${ImageBucket}/*
            - Action:
                - s3:GetObject
              Effect: Allow
              Principal:
                AWS: !GetAtt EdgeLambdaRole.Arn
              Resource: !Sub arn:aws:s3:::${ImageBucket}/*

CloudFormationでいうところこの部分を作成します。

S3バケットを作成し PutObjectを1で作ったIAMRoleに権限付与し、 GetObjectとListBucketは全体公開します。
ListBucketの権限はCloudFormation側にはないのですが、これを追加しておかないと後々うまく動かない部分があるので付けておいたほうが良いです。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::XXXXXX:role/EdgeLambdaRole" // 手順1で作成したIAMRole
            },
            "Action": "s3:PutObject",
            "Resource": "arn:aws:s3:::lambdaedge-test/*"
        },
        {
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::lambdaedge-test/*"
        },
        {
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:ListBucket",
            "Resource": "arn:aws:s3:::lambdaedge-test"
        }
    ]
}

3. 2で作ったS3をオリジンとするCloudFrontを用意する

  MyDistribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Origins:
        - DomainName: !Sub ${ImageBucket}.s3.amazonaws.com
          Id: myS3Origin
          S3OriginConfig: {}
        Enabled: 'true'
        Comment: distribution for content delivery
        DefaultRootObject: index.html
        DefaultCacheBehavior:
          TargetOriginId: myS3Origin
          LambdaFunctionAssociations:
            - EventType: 'viewer-request'
              LambdaFunctionARN: !Ref ViewerRequestFunctionVersion
            - EventType: 'origin-response'
              LambdaFunctionARN: !Ref OriginResponseFunctionVersion
          ForwardedValues:
            QueryString: 'true'
            QueryStringCacheKeys:
              - d
            Cookies:
              Forward: 'none'
          ViewerProtocolPolicy: allow-all
          MinTTL: '100'
          SmoothStreaming: 'false'
          Compress: 'true'
        PriceClass: PriceClass_All
        ViewerCertificate:
          CloudFrontDefaultCertificate: 'true'

CloudFormationでいうところこの部分を作成します。

先にキャッシュポリシーを作成します。
キャッシュキーの設定のクエリ文字列に「d」を設定すれば他はデフォルトでOKです。

CloudFrontを作成します。
オリジンを2で作成したS3を選択します。
キャッシュポリシーを先ほど作成したキャッシュポリシーを選択します。

以上でCloudFrontの設定は完了です。
ためしにS3に画像をアップロードしてCloudFrontのドメインで表示されればOKです。

4. viewer-requestとorigin-responseのlambda関数を作成し、lambdaEdge関数にデプロイする

※lambda関数はus-east-1のリージョンに作成します

 ViewerRequestFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: s3://<code-bucket>/viewer-request-function.zip
      Handler: index.handler
      Runtime: nodejs6.10
      MemorySize: 128
      Timeout: 1
      Role: !GetAtt EdgeLambdaRole.Arn

  ViewerRequestFunctionVersion:
    Type: "AWS::Lambda::Version"
    Properties:
      FunctionName: !Ref ViewerRequestFunction
      Description: "A version of ViewerRequestFunction"

  OriginResponseFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: s3://<code-bucket>/origin-response-function.zip
      Handler: index.handler
      Runtime: nodejs6.10
      MemorySize: 512
      Timeout: 5
      Role: !GetAtt EdgeLambdaRole.Arn

  OriginResponseFunctionVersion:
    Type: "AWS::Lambda::Version"
    Properties:
      FunctionName: !Ref OriginResponseFunction
      Description: "A version of OriginResponseFunction"

CloudFormationでいうところこの部分を作成します。
どちらもzipにしたソースコードをS3にアップロードしてそれをlambda関数にしていますが
Viewer-Request関数に関しては直接ソースコードを記述しても問題ありません。 Origin-Response関数もS3にアップしてますが直接zipをアップロードしてもデプロイ可能なので好みの方法でデプロイしてください。
注意点としては、S3のバケットを両方とも正しく設定することと、decodeURIしてrequest.uriの値を変数にいれておかないと、S3にアップされたファイル名にマルチバイト文字が含まれているとエラーになります。

    // read the required path. Ex: uri /images/100x100/webp/image.jpg
    let path = decodeURI(request.uri); //decodeURIしておく

5. Lambda@Edgeへデプロイする

Viewer-Request関数とOrigin-Response関数が作成できたらCloudFrontのlambdaEdgeにデプロイします。

Viewer-Request関数のLambda@Edgeへのデプロイ

Lambdaの右上のActionボタンから「Lambda@Edgeへのデプロイ」を選択します。

Distributionに作成したCloudFrontを選択し、CloudFront eventにViewer requestを選択しデプロイします。

Origin-Response関数のLambda@Edgeへのデプロイ 同様の手順でOrigin-Response関数もLambda@Edgeへのデプロイします。

デプロイがうまく行っていれば、CloudFront側のビヘイビアの設定をみるとLambda@Edgeが紐づいているのがわかります。

以上ですべての設定が完了です。 S3に画像をアップロードして cloudfrontドメイン/path?d=100x100
cloudfrontドメイン/path?d=200x200
クエリパラメーターをつけたURLにアクセスし、リサイズされた画像が表示されれば完成です。

その他補足

GUIで設定していく上でいくつかハマった点やおまけの情報をいくつか紹介します。

Lambda@Edgeへのデプロイでエラーになる場合

Lambda@Edgeへのデプロイで以下のようなエラーが表示された場合
IAMRoleが正しいか確認しましょう

Lambda@Edgeの関数をテストしたいとき

Viewer-Request関数の場合

cloudfront-access-request-in-response のテンプレートを選択しuriとquerystringの部分を修正する

{
//省略
          "clientIp": "2001:cdba::3257:9652",
          "uri": "/images/test-image.jpg", //s3のファイルのパス
          "querystring": "d=200x200", //リサイズの指定
          "method": "GET"
//省略
}

これでテストを実行し、成功するかどうかでチェックできます。

Origin-Response関数の場合

cloudfront-access-request-in-response のテンプレートを選択しuri,querystring,responseのstatusを変更します S3のimages/test-image.jpg のファイルがあると想定して

{
  "Records": [
// 省略
          "clientIp": "2001:cdba::3257:9652",
          "uri": "/images/200x200s/webp/test-image.jpg", //viewer-requestのfunctionが返してくるpath
          "querystring": "d=200x200", //リサイズの指定
          "method": "GET"
        },
        "response": { 
          "status": "404",  // 404にする
          "statusDescription": "Not Found",
//省略
  ]
}

この設定でテスト実行した結果 S3のパスimages/test-image.jpg の画像がリサイズされたものが /images/200x200s/webp/test-image.jpg に作成されていれば成功しています。

CloudFrontのLambda@Edgeの関数のURLにアクセスしてエラーになる場合

AccessDeniedが表示される場合
S3のバケットポリシーにs3:ListBucketの権限が付与されているか確認しましょう。
存在しない画像URLにアクセスした時に404が帰る状態になっていないとLambda@Edgeの関数がうまく動きません。

アスペクト比を保ったままリサイズしたい

現在の実装だと正方形に切り取ることしかできないのでアスペクト比を保ったままリサイズしようとするとOrigin-Response関数に少し手を加えないといけません。 具体的にはクエリパラメーターのmodeというキーの値を元にS3から取得した元画像をどのように切り取るかを決定する処理を追加します。
以下のように修正します。

exports.handler = (event, context, callback) => {
  let response = event.Records[0].cf.response;

  //check if image is not present
  if (response.status == 404) {

    let request = event.Records[0].cf.request;
    let params = querystring.parse(request.querystring);

    // if there is no dimension attribute, just pass the response
    if (!params.d) {
      callback(null, response);
      return;
    }

    // read the dimension parameter value = width x height and split it by 'x'
    let dimensionMatch = params.d.split("x");

    // read the required path. Ex: uri /images/100x100/webp/image.jpg
    let path = decodeURI(request.uri);

    // read the S3 key from the path variable.
    // Ex: path variable /images/100x100/webp/image.jpg
    let key = path.substring(1);

    // parse the prefix, width, height and image name
    // Ex: key=images/200x200/webp/image.jpg
    let prefix, originalKey, match, width, height, mode, requiredFormat, imageName;
    let startIndex;

    try {
      match = key.match(/(.*)\/(\d+)x(\d+)([sivh]?)\/(.*)\/(.*)/);
      prefix = match[1];
      width = parseInt(match[2], 10);
      height = parseInt(match[3], 10);
      mode = match[4] ? match[4] : "s";

      // correction for jpg required for 'Sharp'
      requiredFormat = match[5] == "jpg" ? "jpeg" : match[5];
      imageName = match[6];
      originalKey = prefix + "/" + imageName;
    }
    catch (err) {
      // no prefix exist for image..
      match = key.match(/(\d+)x(\d+)([sivh]?)\/(.*)\/(.*)/);
      width = parseInt(match[1], 10);
      height = parseInt(match[2], 10);
      mode = match[3] ? match[3] : "s";

      // correction for jpg required for 'Sharp'
      requiredFormat = match[4] == "jpg" ? "jpeg" : match[4];
      imageName = match[5];
      originalKey = imageName;
    }

    // get the source image file
    S3.getObject({ Bucket: BUCKET, Key: originalKey }).promise()
      // perform the resize operation
      .then(data => {
        let sharpConfig = {};
        /*
         * mode
         *  - s(square)     : 中央を正方形に切り取り(デフォルト)
         *  - i(inside)     : アスペクト比を変えずに指定の正方形サイズに収まる様に切り取り
         *  - v(vertical)   : アスペクト比を変えずに、縦にサイズを合わせる
         *  - h(horizontal) : アスペクト比を変えずに、横にサイズを合わせる
         */
        switch(mode){
          case 'i':
            sharpConfig.fit = 'inside';
            sharpConfig.width = width;
            sharpConfig.height = height;
            break;
          case 'v':
            sharpConfig.height = height;
            break;
          case 'h':
            sharpConfig.width = width;
            break;
          default:
            sharpConfig.width = width;
            sharpConfig.height = height;
        }
        return Sharp(data.Body)
            .resize(sharpConfig)
            .toFormat(requiredFormat)
            .toBuffer();
      })
      .then(buffer => {
        // save the resized object to S3 bucket with appropriate object key.
        S3.putObject({
            Body: buffer,
            Bucket: BUCKET,
            ContentType: 'image/' + requiredFormat,
            CacheControl: 'max-age=31536000',
            Key: key,
            StorageClass: 'STANDARD'
        }).promise()
        // even if there is exception in saving the object we send back the generated
        // image back to viewer below
        .catch(() => { console.log("Exception while writing resized image to bucket")});

        // generate a binary response with resized image
        response.status = 200;
        response.body = buffer.toString('base64');
        response.bodyEncoding = 'base64';
        response.headers['content-type'] = [{ key: 'Content-Type', value: 'image/' + requiredFormat }];
        callback(null, response);
      })
    .catch( err => {
      console.log("Exception while reading source image :%j",err);
    });
  } // end of if block checking response statusCode
  else {
    // allow the response to pass through
    callback(null, response);
  }
};

修正後デプロイした後動作確認します。

元画像

d=300x300でアクセス

d=300x300&mode=i でアクセス

以上のようにリサイズされていれば成功です。

最後に

長い文章になってしまい、やはりLambda@Edgeを手動で設定するのは大変ということがわかりました。
大人しくCloudFormationを勉強したいと思います。

GreenSnapではエンジニアを募集中です。サーバーサイド、iOS、Androidエンジニアはもちろんのこと これから作成するデータ基盤とそれを活用できるデータサイエンティストの方も募集しています。
一人ひとりが幅広くいろんな技術領域を担当しているので、特定の技術だけを担当するより多くの技術に触れたい方や挑戦したい方は是非お声がけください。

www.wantedly.com

Bitriseキャッシュを使ってデプロイ 時間を1時間→20分にした話

こんにちは、GreenSnapでiOSエンジニアをやっている山野です。 GreenSnapでは、iOSのビルドとデプロイのCI環境としてBitriseを利用しています。 これまで、ビルド時間に、ひどいときは1時間以上かかっており、生産性がかなり低かったのですが、 今回、ビルド時間が長い原因を調査し、改善することができたので、その内容をまとめてみました。

そもそもBitriseとは

www.bitrise.io Bitriseは、モバイルアプリのCI/CDを構築するためのSaaSです。 デフォルトで、様々なタスクが容易されており、それらをGUI上で操作することで、簡単にCI環境を構築することができます。

なぜデプロイに時間がかかっていたのか

デプロイに時間がかかる理由は環境により様々あると思います。 弊社の場合、fastlaneを使って、pod, carthageライブラリのインストールとビルドからデプロイまでを一貫して行っていましたが、このfastlaneに非常に時間がかかっていました。

+------------------------------------------------------------------------------+
|                               bitrise summary                                |
+---+---------------------------------------------------------------+----------+
|   | title                                                         | time (s) |
+---+---------------------------------------------------------------+----------+
                                         ︙
+---+---------------------------------------------------------------+----------+
| ✓ | fastlane                                                      | 54.2 min |
+---+---------------------------------------------------------------+----------+
                                         ︙
+---+---------------------------------------------------------------+----------+
| Total runtime: 54.9 min                                                      |
+------------------------------------------------------------------------------+

また、fastlaneの内部を見てみたところ、

+------+-----------------------------------------+-------------+
|                       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           |
+------+-----------------------------------------+-------------+

podとcarthageライブラリのインストールにそれなりに時間を要してしまっていることがわかりました。 そこで、これらのライブラリをキャッシュを使うことで、高速化できないか調べてみました。

Bitriseのキャッシュの導入

devcenter.bitrise.io Bitriseでは、公式でキャッシュ機能をサポートしています。 仕組みはシンプルで、2つのステップを追加するだけで導入可能です。

事前準備

弊社ではfastlaneを用いてライブラリをインストールしていたので、あらかじめこれらをfastlaneから除き、 それぞれシェルを使ってインストールできるようにステップを編集しておきました。

キャッシュの利用方法

devcenter.bitrise.io キャッシュの利用には、以下の2ステップを既存のワークフローに追加します。

  • Bitrise.ioキャッシュ:プルステップ

  • Bitrise.ioキャッシュ:プッシュ

Bitrise.ioキャッシュ:プルステップ

キャッシュをダウンロードします。 レポジトリをclone後、ライブラリをインストールする前にこのステップを追加します。これだけです。

f:id:yamano-hidenori:20220401101457p:plain
Bitrise.ioキャッシュ:プル

Bitrise.ioキャッシュ:プッシュ

キャッシュをアップロードします。 すべてのステップの最後にこのステップを追加します。

f:id:yamano-hidenori:20220401101757p:plain
Bitrise.ioキャッシュ:プッシュ
どのディレクトリをキャッシュするかを設定しておく必要があるので、Cache pathsの欄に、

$BITRISE_CACHE_DIR
Pods -> Podfile.lock
Carthage -> Cartfile.resolved
vendor/bundle -> Gemfile.lock

のように記載しました。 矢印の意味ですが、矢印の右側のファイルに変更があった場合は、左のキャッシュ対象を更新するという意味になります。

キャッシュの管理

キャッシュはブランチ単位で管理されます。キャッシュをダウンロードしてくる際に、デプロイ対象のブランチにキャッシュが無い場合は、デフォルトのブランチから探してくれます。 また、キャッシュはデフォルトでは7日後に期限切れになります。7日間キャッシュした対象のブランチで何もしなかった場合は、期限切れのタイミングでキャッシュも削除されます。

Bitriseのスケジュールビルド

Bitriseではスケジュールビルドを簡単に設定できます。 キャッシュ用のワークフローを作成し、1週間に1度定期実行するように設定すれば、7日後にキャッシュが消えることを回避できます。 設定は非常に簡単で、通常のビルド実行から、スケジュールビルドにチェックを入れ、曜日と時間を設定するたけです。

f:id:yamano-hidenori:20220401105630p:plain
スケジュールビルド

どれくらい改善されたか

キャッシュを使うことで、これまで数十分かかっていたライブラリのインストールですが、

+---+---------------------------------------------------------------+----------+
| ✓ | Bundle Install                                                | 16.19 sec|
+---+---------------------------------------------------------------+----------+
| ✓ | Pod Install                                                   | 1.4 min  |
+---+---------------------------------------------------------------+----------+
| ✓ | Carthage Install                                              | 25.05 sec|
+---+---------------------------------------------------------------+----------+

全部合わせて2分ほどで完了するようになりました。これにより、全体のデプロイ時間も大幅に削減され、導入前は1時間ほどかかっていたものが、20分ほどで完了するようになりました。

まとめと今後について

今回は、Bitriseのキャッシュ機能を使うことで、iOSのデプロイ時間を大幅に削減することができた話をさせていただきました。 Bitriseはモバイルアプリに特化しているだけのことはあり、非常に簡単な操作でCI環境を導入できるのでおすすめです。 今後は、Bitriseを使って、自動テスト環境なども構築できればいいなと考えています。

最後に

弊社では絶賛エンジニア募集中です。BtoCのサービス開発をしてみたい方や、植物に興味のある方は是非応募してください。 カジュアルに話だけでも聞きたいという方もお待ちしてます。 www.wantedly.com