every Tech Blog

株式会社エブリーのTech Blogです。

OPTiMさんとGolangの共同勉強会を開催しました

OPTiMさんとGolangの共同勉強会を開催しました

目次

はじめに

こんにちは。 トモニテ開発部ソフトウェアエンジニア兼、CTO室Dev Enableグループの庄司(ktanonymous)です。
今回の記事では、先日OPTiMさんと共同で開催した勉強会についてご紹介したいと思います。

勉強会の概要

7月2日(火)に、弊社everyとOPTiMさんとの2社合同でのGoの勉強会 OPTiM × every Golang Developer Night を開催しました。

この勉強会は、OPTiMさんのご厚意により、OPTiMさんのオフィスで開催させていただきました。 受付では各社のノベルティも配られました。また、暑いなか参加上限に近い数の方にご参加いただき、勉強会も懇親会も盛況でした。

受付のノベルティ
受付のノベルティ
会場の様子
会場の様子
勉強会中の風景
勉強会中の風景
懇親会の様子
懇親会の様子

勉強会では、OPTiMさんとeveryからそれぞれメンバーが登壇し、Golangを利用したプロジェクトでの成功事例や課題克服にまつわるLTが行われました。
以下が当日のタイムスケジュールとなっています。

時間 内容
19:30 - 19:35 オープニング
19:35 - 19:40 OPTiM 会社紹介
19:40 - 19:45 every 会社紹介
19:45 - 20:00 LT枠1: Go x LLMで 新たなコード生成の可能性を探る (OPTiM 今枝)
20:00 - 20:15 LT枠2: Go言語で行うメール解析 (every きょー。)
20:15 - 20:30 LT枠3: プロダクトでどれくらいMELTしてますか? (OPTiM 坂井)
20:30 - 20:45 LT枠4: slices/maps pkgを活用してオレオレ実装を撲滅したい (every ayaka.yoshida)
20:45 - 21:00 質疑応答
21:00 - 21:10 クロージング
21:10 - 懇親会

LT枠1: Go x LLMで 新たなコード生成の可能性を探る (OPTiM 今枝)

(発表資料は公開準備中です。公開され次第追記します。)

このセッションでは、Go言語のコード生成について紹介が行われました。
コード生成は、DX向上や品質・セキュリティ向上、パフォーマンス最適化、保守性・拡張性の向上、相互運用性・プラットフォーム互換性の向上などの目的で行われます。 oapi-codegen1 のような api interface や templategen2 のようなテンプレート生成など、 Go言語自体がコード/テンプレートの自動生成をサポートしているため、手軽にコード生成を行うことができます。 そこに、LLMによるコード生成も加わることで、より効率的なコード生成が可能になるという提案に関するお話でした。 コード生成ツールとして、plandex3というGo言語で書かれたOSSも紹介されました。

コードを自動で生成できることで、開発効率が向上し、品質やセキュリティの向上にもつながるため、 LLMを織り交ぜたコード生成を上手く取り込むことで、より効率的な開発が目指せそうだと感じました。

Go x LLMで 新たなコード生成の可能性を探るの発表の様子

LT枠2: Go言語で行うメール解析 (every きょー。)

speakerdeck.com

このセッションでは、Go言語を使ったメール解析について紹介が行われました。
メールのプロトコルの話から始まり、ヘッダーのようなメールの構成などの説明があり、実際にGo言語でメール解析を行う方法についても解説がありました。 メールのメッセージは、RFC28224 で規定されたメールプロトコルで構成されており、 メールヘッダーの情報を利用することで、トラブルシューティングやスパム検出、セキュリティ分析など様々な用途に活用できるようです。
Goではnet/mailパッケージ5が標準で提供されており、 メッセージからヘッダーやボディの情報を取得することで、様々なメール解析を行うことができるとのことでした。

発表中には実際のユースケースの紹介もあり、メール解析の活用方法についてのイメージをより具体的に持つことができました。 ちなみに、Goによるメール解析の実際のユースケースとして、きょー。が弊社everyのテックブログも書いているので、興味のある方はぜひご覧ください。

tech.every.tv

Go言語で行うメール解析の発表の様子

LT枠3: プロダクトでどれくらいMELTしてますか? (OPTiM 坂井)

(発表資料は公開準備中です。公開され次第追記します。)

このセッションでは、MELT6という、モニタリング/オブザーバビリティに関する概念についての紹介が行われました。
MELTとは、システムレベルの懸念を理解するための「モニタリング」とアプリケーションレベルの懸念を理解するオブザーバビリティに関する考え方であり、 SaaSなどの発展で複雑化して管理者による制御が難しくなってきているシステムに対して、 Metrics/Events/Logs/Tracesを基本的な観測可能性のシグナルとして捉えることで、観測可能なシステムの開発ライフサイクルの実現を目指すための概念とのことでした。 OpenTelemetry7 などを用いて、Goでどのように計装し可視化するのか、デモも交えて説明していただけました。

モニタリング/オブザーバビリティの考え方は、システムの問題の特定や解決を行うための重要な概念であり、 Go言語を用いた計装方法や可視化方法を学ぶことで、システムの理解を深めることができると感じました。
また、実際にデモを行っていただいたことで、どのように可視化されるのかが明確になり、ツールの良さをより実感することができました。

プロダクトでどれくらいMELTしてますか?

LT枠4: slices/maps pkgを活用してオレオレ実装を撲滅したい (every ayaka.yoshida)

speakerdeck.com

このセッションでは、Go言語のslices/mapsパッケージの活用方法について紹介が行われました。
Go1.21でslicesパッケージ8およびmapsパッケージ9が追加され、sliceやmapの操作をより簡潔に行うことができるようになりました。 これにより、以前まではforループなどを駆使して自前で実装していた処理を、公式提供のslices/mapsパッケージのメソッドに置き換えることができるようになったということでした。
実際にプロジェクトで使われているコードを新しいパッケージのメソッドに置き換えた例が提示され、コードがより簡潔で可読性の高いものになることが参加者も実感できたのではないかと思います。

Goでは、最低限の機能のみを提供することによるシンプルな言語設計を方針とされていますが、その影響で自前の実装が長大になるケースもあったかと思います。 しかし、slices/mapsのように、公式によるパッケージ提供のおかげで徐々にプログラムの記述が簡潔になってきているのではないかと感じました。
1.22以降でも、ループ変数のスコープの変更やイテレータなど、様々な改善が行われているため、今後もGo言語の進展に注目していきたいと思いました。

slices/map pkgを活用してオレオレ実装を撲滅したいの発表の様子

まとめ

今回の記事では、7月2日(火)にOPTiMさんと共同で開催した勉強会について紹介しました。 LT枠では、Go言語を使ったコード生成やメール解析、MELTを意識したGoによるシステムのmonitoring/observation、slices/mapsパッケージの活用について発表がありました。 それぞれのセッションを通じて、Go言語の新たな活用方法や機能、システムの可視化方法などを学ぶことができ、非常に有意義な時間を過ごすことができました。 最後に、このような機会を提供していただいたOPTiMさん、登壇者の皆様、参加者の皆様、本当にありがとうございました。お疲れ様でした。

最後まで読んでいただき、ありがとうございました。

Golangでアプリ課金(iab/iap)を実装するときは awa/go-iap が便利って話

この記事は every Tech Blog Advent Calendar 2024(夏) 28日目の記事です。

はじめに

DelishKitchenとヘルシカでバックエンドエンジニアをしているyoshikenです。

今回は新規アプリ開発の際に、Android/iOSアプリの課金処理(subscription)について awa/go-iapを使用して大変便利だったので、その紹介をしたいと思います。

awa/go-iap: go-iap verifies the purchase receipt via AppStore, GooglePlayStore, AmazonAppStore and Huawei HMS.

Android/iOSの課金処理のフローについてはサイバーエージェントさんのテックブログで詳しく説明されているので、そちらを参照してください。(この記事を書いてくださった方がそのまま awa/go-iapの原型を作成されたかと

自動購読課金について【Android編】 | サイバーエージェント 公式エンジニアブログ 自動購読課金について【iOS編】 | サイバーエージェント 公式エンジニアブログ

awa/go-iapとは

awa/go-iapは、In-App Purchase (IAP) のサポートを提供するライブラリで、実際にawaでも使用されているので一定の信頼性があり、更新頻度も高く、以前自分がissueを立てた際は次の日には修正されていました。

offerDiscountType is missing in JWSTransactionDecodedPayload · Issue #266 · awa/go-iap

また、今回はAndroid/iOSについての説明をしますが、他にもAmazon AppStoreやHuawei HMSといった決済方法に対応しています。

Android編

サーバー側でやることは

  1. 署名の検証
  2. receipt問い合わせ
  3. acknowledgeの送信

となります

署名の検証

サイバーエージェントさんのテックブログにも書かれていますが、送られてきたreceiptデータの検証に署名を利用します。

awa/go-iapを使用しない場合の実装はサイバーエージェントさんにお任せするとして…

awa/go-iapのVerifySignature関数を使用することで、簡単に署名の検証ができます。

https://pkg.go.dev/github.com/awa/go-iap@v1.32.0/playstore#VerifySignature

以下に実装例を出します

import (
  "encoding/base64"
  "github.com/awa/go-iap/playstore"
  "yourapp/types"
)

// Verify receiptが改竄されていないかを検証
// receiptData: レシートデータ、Androidから送られてくるのはbase64化されている
// signature: レシートデータの署名
func Verify(receiptData, signature string) (bool, error) {
    // base64化されているので戻す
    b, err := base64.StdEncoding.DecodeString(receiptData)
    if err != nil {
        return false, err
    }

    // レシートデータ自体が正しいか署名を検証
    return playstore.VerifySignature(types.GooglePlayPublicKey, b, signature)
}

Androidから送られてくるreceiptデータがbase64化されているのでデコード処理が必要という意味で関数化しましたが、実際の処理は一行で終わるので無理に関数化しなくても良いかもしれません。

また、検証にはGooglePlayのPublicKeyが必要ですが、これはGooglePlayConsoleから取得できます。

subscription問い合わせ

receipt自体の検証が完了したら、購入情報を取得するために再度GooglePlayに問い合わせを行います。

こちらの問い合わせもVerifySubscriptionV2関数を使用することで簡単に実装できます。

https://pkg.go.dev/github.com/awa/go-iap@v1.32.0/playstore#Client.VerifySubscriptionV2

import (
  "context"
  "github.com/awa/go-iap/playstore"
  "google.golang.org/api/androidpublisher/v3"
  "github.com/avast/retry-go/v4"

  "yourapp/types"

)

type PurchaseData struct {
    AutoRenewing     bool   `json:"autoRenewing"`
    OrderID          string `json:"orderId"`
    PackageName      string `json:"packageName"`
    ProductID        string `json:"productId"`
    PurchaseTime     int64  `json:"purchaseTime"`
    PurchaseState    int64  `json:"purchaseState"`
    DeveloperPayload string `json:"developerPayload"`
    PurchaseToken    string `json:"purchaseToken"`
}

func Verifyreceipt(ctx context.Context, receiptData, signature string) (*androidpublisher.SubscriptionPurchaseV2, error) {
   // 先程のVerify関数で返り値にdecode済みのreceiptデータを追加しても良いかもしれません
    b, err := base64.StdEncoding.DecodeString(receiptData)
    if err != nil {
        return nil, err
    }

    var purchaseData PurchaseData
    err = json.Unmarshal(b, &purchaseData)
    if err != nil {
        return nil, err
    }

    request, err := playstore.New([]byte(types.GooglePlayServiceAccount))
    if err != nil {
        return nil, err
    }

    var res *androidpublisher.SubscriptionPurchaseV2
   // ちょくちょく5xxを返すことがあるのでリトライ処理を入れています
    err = retry.Do(
        func() error {
            res = &androidpublisher.SubscriptionPurchaseV2{}
            res, err = request.VerifySubscriptionV2(ctx, purchaseData.PackageName, purchaseData.PurchaseToken)
            if err != nil {
                return err
            }
            return nil
        },
        retry.RetryIf(func(err error) bool {
            // 任意のエラーハンドリング
            if strings.Contains(err.Error(), "Service Unavailable") {
                return true
            }
        }),
    )
    if err != nil {
        return nil, err
    }
    return res, nil
}

acknowledgeの送信

署名の検証やらdb保存やら諸々が完了してレスポンスを返却するまえにGooglePlayに対してacknowledgeを送信して購入を承認する必要があります。

逆をいうと、acknowledgeを送信しないと購入が完了しないので、エラー時はacknowledgeさえ送信しないことに注意を払えばなんとかなります。

acknowledgeですが、こちらも関数が用意されており、 AcknowledgeSubscription を使用します。

import (
  "context"
  "github.com/awa/go-iap/playstore"
  "google.golang.org/api/androidpublisher/v3"
  "yourapp/types"
)

type PurchaseData struct {
    AutoRenewing     bool   `json:"autoRenewing"`
    OrderID          string `json:"orderId"`
    PackageName      string `json:"packageName"`
    ProductID        string `json:"productId"`
    PurchaseTime     int64  `json:"purchaseTime"`
    PurchaseState    int64  `json:"purchaseState"`
    DeveloperPayload string `json:"developerPayload"`
    PurchaseToken    string `json:"purchaseToken"`
}

func (u *UseCaseImpl) AckSub(ctx context.Context, purchaseReceipt PurchaseData) error {
    cl, err := playstore.New([]byte(types.GooglePlayServiceAccount))
    if err != nil {
        return err
    }

    return cl.AcknowledgeSubscription(ctx, purchaseReceipt.PackageName, purchaseReceipt.ProductID, purchaseReceipt.PurchaseToken, &androidpublisher.SubscriptionPurchasesAcknowledgeRequest{})
}

acknowledgeには冪等性があるので何度再送しても大丈夫です。

iOS編

iOSもAndroidと似たようなものですが、serverから見るとAndroidより簡単です。

実際にApplestoreに問い合わせが必要な部分はreceiptの検証のみです。

以下が実装例です

import (
  "context"
  "github.com/awa/go-iap/appstore"
  "github.com/avast/retry-go/v4"
)

func VerifyReceipt(ctx context.Context, receiptData, password string) (*appstore.IAPResponse, error) {
    request := appstore.IAPRequest{
        ReceiptData: receiptData,
        Password:    password,
    }
    var res *appstore.IAPResponse
    err := retry.Do(
        func() error {
            res = &appstore.IAPResponse{}
            err := appstore.IAPClient.Verify(ctx, request, res)
            if err != nil {
                return err
            }
            err = appstore.HandleError(res.Status)
            if err != nil {
                return err
            }
            return nil
        },
        retry.RetryIf(func(err error) bool {
            // 任意のエラーハンドリング
              if strings.Contains(err.Error(), "Service Unavailable") {
                return true
            }
        }),
    )
    if err != nil {
        return res, err
    }
    return res, nil
}

GooglePlayと同じく時折5xxを返すのでリトライ処理を入れています。

iOSはクライアント購入時にack相当はしているのでサーバー側でのacknowledgeは不要です。

おわりに

以上のように、awa/go-iapを使用することで、Android/iOSの課金の購入処理を簡単に実装することができます

今回例に上げたのはsubscriptionのみですが、単発購入の場合も似たような実装になるかと思います。

ただ、subscriptionの場合は購入後の処理のほうが複雑で、定期的な購入情報を問い合わせるか、通知(App Store Server Notifications/RTDN)を受けるかしたあとにそれぞれのプロダクトに合わせた処理をしていきます。(今回は購入処理だけです

自分が実装するさいにawa/go-iapの参考になるドキュメントが少なかったため、今回の記事を書きました。誰かのお役に立てれば幸いです。

参考

awa/go-iap: go-iap verifies the purchase receipt via AppStore, GooglePlayStore, AmazonAppStore and Huawei HMS.

自動購読課金について【Android編】 | サイバーエージェント 公式エンジニアブログ

自動購読課金について【iOS編】 | サイバーエージェント 公式エンジニアブログ

リアーキテクチャを支えるテスト駆動開発:効果的なリファクタリングの方法

この記事は every Tech Blog Advent Calendar 2024(夏) 27日目の記事です。

目次

はじめに

DELISH KITCHENのiOSアプリ開発を担当している池田です。DELISH KITCHENでは皆様の料理体験がより良いものになるよう、日々新しい機能を追加しています。今回は「リアーキテクチャを支えるテスト駆動開発:効果的なリファクタリングの方法」について、実際の経験をもとにお伝えします。テスト駆動開発の重要性を改めて確認しながら、効果的なリファクタリングの方法を紹介します。

背景と問題点

DELISH KITCHENのiOSアプリは2016年のリリース以来、様々な機能を追加してきました。しかし、しっかりとした設計方針がないまま開発を続けてきたため、今後も継続的に機能を追加することが困難になっていました。そのため、一度まとまった時間を取り、リファクタリングを行うことにしました。

既存設計の問題

DELISH KITCHENのiOSアプリでは、SPMを用いたマルチモジュールで設計されています。現在は下図のようなモジュール構成になっています。

例えば、Networkingモジュールは通信に関する実装を含んでいますが、以下のような問題がありました:

  • レスポンスそのものをアプリ全体で使い回しているため、通信を意識する必要のないUIモジュールがNetworkingモジュールに依存している。
  • レスポンスがサーバの返却するJSONをそのままの形でパースしたものであり、アプリで使いやすい形ではない。

新しい設計方針

上記の問題を解決するために、クリーンアーキテクチャを元にした設計に移行することにしました。クリーンアーキテクチャとは、ドメインモデルを中心とした設計であり、各層が独立して動作することを目指します。下図のような構成を目標にリファクタリングを進めていきます。

リファクタリングの準備

テストを書く

リファクタリングを行う前に、まず既存の動作をテストすることが重要です。レスポンスの変換に対してテストを行うことで、変更後の動作を担保します。以下に、簡易的なデコードテストの実装例を示します:

// テストコード例
class DecodableTests: XCTestCase {
    func testGetRecipeResponseDecoding() {
        if let _: GetRecipeResponse = Self.decodeJSON(from: "GetRecipeResponse") {
            XCTAssert(true)
        }
    }
}

extension DecodableTests {
    /// 共通のJSONデコードテストメソッド
    static func decodeJSON<T: Decodable>(from fileName: String) -> T? {
        guard let fileURL = Bundle.module.url(forResource: fileName, withExtension: "json") else {
            XCTFail("Failed to find file \(fileName).json")
            return nil
        }

        do {
            let data = try Data(contentsOf: fileURL)
            let decodedObject = try JSONDecoder.decoder.decode(T.self, from: data)
            return decodedObject
        } catch {
            XCTFail("Decoding failed: \(error)")
            return nil
        }
    }
}

このテストではOpenAPIのJSONレスポンスをJSONファイルとしてプロジェクトのローカルに配置し、そのJSONのデコードが失敗しないことをチェックしています。

リファクタリング作業

実装する

設計方針に従って少しずつリファクタリングを行います。今回は、NetworkingモジュールからModelを切り出し、サーバから取得したレスポンスをドメインモデルに変換するように変更します。以下に、リファクタリングのステップとコード例を示します:

  • Modelモジュールを作り、アプリで利用しやすいドメインモデルを定義し直す。
  • Networkingモジュールではサーバから取得したレスポンスをドメインモデルへと変換する。

既存実装ではレスポンスとModelの型を一致させることで、ModelをCodableに準拠させ、JSONからの変換のコードの実装が不要でした。今回はModelをレスポンスに依存させるのではなく、レスポンスをModelに依存させるように関係を変更すること、レスポンスとModelの構造が異なることからJSONから変換する処理を実装する必要が出てきました。

// リファクタリング前のコード例
// Networkingモジュール
struct Recipe: Codable {
    let id: Int
    let title: String
    let category: String
    // 以下略
}
// リファクタリング後のコード例
// Modelモジュール
struct Recipe {
    let id: Int
    let title: String
    let category: Category
    // 以下略

    enum Category {
        case unknown
        case typeA
        case typeB
    }
}

// Networkingモジュール(将来的にはInfraモジュールへ変更予定)
extension Recipe: Decodable {
    enum CodingKeys: String, CodingKey {
        case id
        case title
        case category
        // 以下略
    }

    public init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)

        let id = try container.decode(Int.self, forKey: .id)
        let title = try container.decode(String.self, forKey: .title)
        let category = try container.decode(Category.self, forKey: .category)
        // 以下略

        self.init(
            id: id,
            title: title,
            category: category
            // 以下略
        )
    }
}

extension Recipe.Category: Decodable {
    public init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let value = try container.decode(String.self)

        switch value {
        case "typeA":
            self = .typeA
        case "typeB":
            self = .typeB
        default:
            self = .unknown
        }
    }
}

テストする

ビルドできるコードが実装できたら、テストを実行します。全てのテストが成功すればここで終了ですが、変更内容が大きいため、いくつかのテストは失敗することが予想されます。失敗したテストの該当する実装を修正しながら、全てのテストが成功するまで続けます。

まとめ

リファクタリングを実施するにあたり、テスト駆動開発の考え方を取り入れました。既存コードに対してテストを書いてからリファクタリングを行うことで、変更後のコードに問題がないことを確認でき、比較的大きな規模のリファクタリングでも安心して進めることができました。今後も継続的にリファクタリングを行い、リアーキテクチャを進め、開発しやすいコードを目指していきます。

終わりに

テスト駆動開発は、効果的なリファクタリングを実現するための強力な手法です。テスト駆動開発の重要性を改めて認識し、日々の開発に取り入れることで、より健全で拡張性の高いアプリケーションを構築することができます。この記事が、皆さんの開発においても役立つことを願っています。

Amazon QuickSightを使用してインタラクティブな可視化をしてみる

はじめに

はじめまして!開発本部のデータ&AIチームに4月に新卒入社した蜜澤です。

最近Amazon QuickSightを使用してダッシュボード作成に励んでいるので、QuickSightにおけるインタラクティブなグラフの作り方を紹介しようと思います!中でも、割合系の指標に対してフィルターを適応するのに苦戦したので、そのあたりの作り方を説明します。

この記事はevery Tech Blog Advent Calendar 2024(夏) 26日目の記事になります。

作成するグラフのイメージ

性別、年代、レシピ名を指定すると、CTRの折れ線グラフが表示される。

使用する模擬データ

実際のデータを使用してしまうとアレなので、今回は以下の模擬データを使用します。

それぞれのカラムの定義は以下の通りです。

  • date:日付(2024-04-01~2024-04-07)
  • recipe:レシピ名(ハンバーグ、からあげ、生姜焼き)
  • gender:性別(男性、女性)
  • age:年代(10代、20代、30代、40代、50代、60代以上)
  • click:レシピをクリックした回数(1~10の整数の乱数)
  • impression:レシピが表示された回数(500~1000の整数の乱数)

また、今回は「CTR=click / impression * 100」と定義します。
CTRカラムを作ることは可能ですが、フィルターをかける都合上、CTRはquicksight上で定義します。

使用するデータの注意点として、date 、gender、age、recipe全ての組み合わせごとにclick、impressionを集計したデータである必要があります。
このようなデータを使用することで「10代と20代の男性にハンバーグが表示された場合のCTR」としてフィルターをかけることができるようになります。

データセットを作成する

今回使用する模擬データをQuickSight上にアップロードします。
今回使用するデータはCSVファイルなのでファイルのアップロードを選択します。

ファイルをアップロードすると確認画面が出てくるので、想定通りのデータなら「次へ」を押します。

データの編集/プレビューを押して、データの型などを確認します。

問題がなければ、「保存して視覚化」を押します。

「保存して視覚化」を押すと、分析ページに飛びます。

グラフ作成

左上の「視覚化」を押すと、作成可能なビジュアルのアイコンが表示されるので、作成したいものを選択します。今回は「折れ線グラフ」を選択します

X軸とY軸をどのカラムにするかを選択します。
使用しているデータセットに含まれるカラムが表示されているので、その中から必要なものを選んで、「X軸」と「値」にドラッグ&ドロップします。

X軸に「date」、値に「impression」をドラッグ&ドロップしてみると、、
日付ごとのimpressionの折れ線グラフが表示されます。

今回作りたいグラフはCTRのグラフなので、これからCTRを定義します。
カラム名の上にある「計算フィールド」を押します。

このような画面が表示されるので、「名前」と「定義」を入力します。

名前が「CTR」、定義は「sum({click}) / sum({impression}) * 100」としました。{カラム名}でカラムを計算フィールド内で指定できます。
ここで、「{click} / {impression} * 100」と記入すると、性別や年代を複数フィルターするときに正しく計算できなくなってしますので注意が必要です!
入力したら、「保存」を押します。

CTRが追加されたので、値に「CTR」をドラッグ&ドロップすると、日付ごとのCTRのグラフになりました!
しかし、このままでは性別、年代、レシピ名を指定できないので、指定するために「パラメータ」と「フィルター」を作成します。

パラメータの作成

この後作成するフィルターで使用する値を転送できる名前付きの変数の役割を果たす「パラメータ」を作成します。
左上の「パラメータ」を押して、「追加」を押します。

このような画面が出てくるので、それぞれの項目を入力していきます。
まずは、性別のパラメータを作ります。名前は「gender」、データ型は「文字列」、値は「複数の値」、デフォルト値は「男性、女性」にします。名前とデフォルト値はお好みで変更していただいてOKです!
入力が終わったら「保存」を押します。

作成したパラメータが表示されます。

同じように年代とレシピ名のパラメータも作成します。
今回作成するダッシュボードは「各レシピ別の傾向を分析する」ユースケースを想定しています。
そのため、レシピ名は「複数の値」ではなく、「単一の値」を選択します。

フィルター作成

次に、作成したグラフに表示するデータを選別する役割を果たす「フィルター」を作成します。
先ほど作成したパラメータを使います。
左上の「フィルター」を押し、「追加」を押します。

まずは「gender」のフィルターを追加してみます。
「gender」を押すとフィルターが作成されます。

作成されたgenderフィルターの3点部分を押して、「編集」を押します。

編集画面が出てくるので、各項目設定します。

「フィルタータイプ」は「カスタムフィルター」を選択します。

「フィルター条件」は「次と等しい」を選択して、「パラメータを使用」のチェックボックスにチェックを入れます。

チェックを入れると、すべてのビジュアルとシートにフィルターを適用するか聞かれるので、用途に合わせて選択します。
今回はビジュアルを1つしか作らないので「いいえ」を選択します。

パラメータが選択できるようになりました。

性別のフィルターなので、先ほど作成した「genderパラメータ」を選択します。
最後に「適用」を押します。

同様のやり方で年代のフィルターである「age」とレシピ名のフィルターである「recipe」も作成します。

これでフィルターの編集は終了です。

コントロールの追加

次に、「コントロール」を作成します。 フィルターに使用しているパラメータの値はダッシュボードの編集者しか変更ができませんが、コントロールを追加することで閲覧者がパラメータを変更できるようになります。 左上の「パラメータ」を押して、コントロールを追加したいパラメータの3点部分を押します。

「コントロールを追加」を押します。

このような画面が表示されるので、各項目を入力します。

genderのコントロールなので名前は「性別」、スタイルは「ドロップダウン - 複数選択」、は「特定の値」、特定の値を定義は「男性」「女性」とします。
入力したら「保存」を押します。

グラフの上部にコントロールが表示されるようになりました。

ドロップダウン形式で「男性」、「女性」、「すべて選択」を選べるようになりました。
試しに、「男性」以外のチェックボックを外してみると、、

グラフの形が変わりました。
男性と女性を集計対象としたCTRのグラフから男性のみを対象としたCTRのグラフに変わリました。

同様にageのコントロールも作ります

「gender」と「age」とは少し違う作り方で「recipe」のコントロールも作ります。
「recipe」のコントロールは「レシピ名をテキストとして指定できるようにする」ことを目指します。
そのため、「テキストフィールド」のスタイルを選択します。
今回の例ではレシピ名が3種類しかないのでドロップダウン形式でも良いのですが、実際の運用を見越すとなると、多くの種類のレシピが対象になります。
そうなると、ドロップダウン形式では見通しが悪くなるため、直接レシピ名を指定する手段を採用しました。

「性別」と「年代」はドロップダウン形式、「レシピ名」のみテキストフィールド形式になっていることがわかります。

レシピ名に「ハンバーグ」と入力してみます。
グラフの形が変わりました。先ほどまでは全てのレシピ(ハンバーグ・からあげ・生姜焼き)を集計対象としたCTRのグラフでしたが、ハンバーグのみを対象としたグラフとして絞り込むことができました。

細かい調整

ここまでできたらほぼ完成です!
最後に細かいところをいじります。

デフォルトだと日付が見にくいので、表示形式を変更します。
「date」を押します。

このような画面が表示されるので、「形式」を押して、好きな表示形式を選択します。今回は「YYYY/MM/DD」にします。

dateの表示形式が変更されてスッキリしました!

グラフのタイトルも変更します。
左上の「プロパティ」を押します。

画面右側にプロパティの編集画面が表示されます。
「ディスプレイ編集」内の「タイトル編集」の筆のようなアイコンを押します。

このような画面が表示されるので、好きなタイトルを入力します。
ここで、パラメータを押すとタイトルがパラメータの内容で動的に変更できるようになるので、「recipe」を押してみます。

パラメータが入力されました!
パラメータの後ろに「のCTR」と入力し、「{入力したレシピ名}のCTR」というタイトルが表示されるようにしました。

レシピ名が「ハンバーグ」の時に、グラフのタイトルが「ハンバーグのCTR」になっています。

説明は省略しますが、X軸やY軸の名前も変更できます。
軸の名前をよしなに変更して、完成です!!!

使ってみる

男性20代と女性20代の生姜焼きのCTRを比較して、日付によって男性女性どちらのCTRが高くなるのかなどを見ることができます。
(今回は乱数を使ったデータなので示唆は得られませんが、、、)

この使い方はあくまでも一例であり、色々な使い方ができると思います。

最後に

今回はAmazon QuickSightを使用して、インタラクティブな可視化を行いました。

今回紹介したビジュアル以外にも多くのビジュアルがあり、様々な可視化ができるので、これからも色々な可視化に挑戦していきたいと思います。

また、今回はデータの整形に関しては触れませんでしたが、QuickSightを使用する際にデータの形式はかなり悩むところなので、今後はデータ整形に関することも紹介できたらなと思います!

この記事が何かの参考になれば幸いです。
最後まで読んでいただきありがとうござました!

Go 言語で multipart/form-data を使用して画像を受け取り外部に送信する

この記事は every Tech Blog Advent Calendar 2024(夏) 25 日目の記事です。

はじめに

こんにちは、24 新卒として 4 月から入社し、DELISH KITCHEN 開発部でソフトウェアエンジニアをしている新谷です。

現在取り組んでいる業務で、画像を受け取って外部に送信する処理を行う API を作成する機会がありました。そこで学んだ、Go 言語における multipart/form-data を使った画像の取り扱い方について紹介します。

背景

開発した機能としては、クライアントがアップロードした画像を API サーバーで受け取り、外部の API に POST するというものです。開発した部分は以下の図で表すと、API サーバーの部分になります。

この処理を実現するためには、画像データの受信かつ送信を行う必要があります。今回は、画像を取り扱う際によく使われるmultipart/form-data形式を使用して画像を受け取り、外部 API に送信するように実装したので、その方法について紹介します。

multipart/form-data とは

multipart/form-dataは、Web ページのフォームからファイルをアップロードする際に使用されるデータ形式です。multipart/form-data形式のリクエストは、以下のような特徴があります。

  • リクエストボディが複数の部品(part)から構成される
  • 各部品はヘッダーとボディから構成される
  • 各部品はboundaryで区切られる
  • ヘッダーには Content-Disposition や Content-Type などが含まれる
  • ボディにはファイルのデータが含まれる

以下は、multipart/form-data形式のリクエストの例です。

POST /post HTTP/1.1
Host: httpbin.org
User-Agent: Go-http-client/1.1
Content-Length: 376
Content-Type: multipart/form-data; boundary=600fcf99bf89273352a59587b26bc07642bd
bfc97ce30f8445ecc0c3873a
Accept-Encoding: gzip

--600fcf99bf89273352a59587b26bc07642bdbfc97ce30f8445ecc0c3873a
Content-Disposition: form-data; name="file"; filename="sample.txt"
Content-Type: application/octet-stream

this is dummy.

--600fcf99bf89273352a59587b26bc07642bdbfc97ce30f8445ecc0c3873a
Content-Disposition: form-data; name="key1"

value1
--600fcf99bf89273352a59587b26bc07642bdbfc97ce30f8445ecc0c3873a--

ここでは、boundaryで区切られた 2 つの部品が含まれています。1 つ目の部品はファイルのデータを含み、2 つ目の部品はフォームデータを含んでいます。

実装

API Server での画像の受け取り方

本 API Server は、Go の echo フレームワークを使用しています。echo のc.FormFileを使用して画像を受け取る実装を以下に示します。

   file, err := c.FormFile("image_file")
    if err != nil {
        return types.ErrInvalidParameters
    }

    src, err := file.Open()
    if err != nil {
        return types.ErrInvalidParameters
    }
    defer src.Close()

それぞれの処理について解説します。

c.FormFile("image_file")は、リクエストから"image_file"という名前のフィールドを取得します。先ほどのmultipart/form-data形式のリクエストの例では、name="file"としていた部分に対応します。

image_fileフィールドから取得したfileにはmultipart.FileHeader型のファイル情報が格納され、ファイル名やファイルサイズなどの情報を取得できます。

次に、file.Open()でファイルを開き、srcにファイルの中身を格納します。file.Open()は、ファイルを開いてmultipart.File型のファイルオブジェクトを返します。multipart.Fileは以下のインターフェースを実装しています。

type File interface {
    io.Reader
    io.ReaderAt
    io.Seeker
    io.Closer
}

io.Readerio.ReaderAtio.Seekerio.Closerのインターフェースを実装しているため、io.ReadAllなどを使用してファイルの中身を取得することもできます。

ファイルの中身を取得した後は、defer src.Close()でファイルをクローズします。

外部 API への画像の POST

外部 API に画像を POST する際は、multipart/form-data形式でリクエストを送信する必要があります。Go 言語では、標準ライブラリのmime/multipartパッケージを使用してmultipart.Writerを作成し、リクエストボディを構築します。

   body := &bytes.Buffer{}
    writer := multipart.NewWriter(body)

    part, err := writer.CreateFormFile("image_file", file.Filename)
    if err != nil {
        return types.ErrInternalServer
    }

    _, err = io.Copy(part, src)
    if err != nil {
        return types.ErrInternalServer
    }

    err = writer.Close()
    if err != nil {
        return types.ErrInternalServer
    }

    req, err := http.NewRequest("POST", "https://example.com", body)
    if err != nil {
        return types.ErrInternalServer
    }

    req.Header.Set("Content-Type", writer.FormDataContentType())
    client := &http.Client{}
    resp, err := client.Do(req)
    if err != nil {
        return types.ErrInternalServer
    }
    defer resp.Body.Close()

bodyにはリクエストボディを格納するバッファを作成します。

次に、multipart.NewWriter(body)でマルチパート形式のリクエストボディを構築するmultipart.Writerオブジェクトを作成します。

writer.CreateFormFile("image_file", file.Filename)は、フォームフィールド名を"image_file"とし、ファイル名を file.Filename とするファイルパートを作成します。 そのファイルパートに、io.Copy(part, src)でファイルの中身をコピーします。 writer.Close()をすることで、最後のboundaryを書き込みます。

その後は、http.NewRequestでリクエストを作成し、req.Header.Set("Content-Type", writer.FormDataContentType())でリクエストヘッダーにContent-Typeを設定します。writer.FormDataContentType()は、multipart.Writerのboundaryを含むContent-Typeを返します。

気をつけたこと

外部 API に画像を送信する際は、エラーが発生する可能性があるためリトライ処理を実装しました。

   for tries < MaxLimit {

        // リクエスト構築
        req, err = r.buildRequest(file, fileName, hurl)
        if err != nil {
            return nil, err
        }

        // リクエスト処理
        body, err = r.doRequest(ctx, req)
        if err != nil {
            file.Seek(0, 0)
            tries++
            continue
        }
    }

リトライ処理で気を付けるべき点は、一度リクエストを送信すると画像ファイルが読み込まれた状態になるため、リトライ時にファイルのポインタを先頭に戻す必要がある点です。 ファイルポインタを戻さないと、リトライ時にファイルの中身が空になってしまいます。 multipart.Fileio.Seekerインターフェースを実装しているため、ファイルポインタを先頭に戻すには、file.Seek(0, 0)を使用することができます。

まとめ

今回は、Go 言語でmultipart/form-data形式を使って画像を受け取り、外部 API に送信する方法を紹介しました。echo フレームワークとmime/multipartパッケージを利用することで、画像の受け取りや送信が簡単に実装できます。しかし、まずmultipart/form-data形式のリクエストの仕組みを理解することが重要だと感じました。また、リトライ処理を実装する際には、リクエストボディを元の状態に戻すことを忘れないようにしましょう。