エブリーエンジニアブログ エブリーエンジニアブログ

ミニアプリを作ることになったので、Swift Package Managerを採用してみた

はじめに

こんにちは。MAMADAYS開発部でiOSエンジニアをやってる國吉です。

この度、MAMADAYSから姉妹アプリ第一弾となる”陣痛カウンター”をリリースしました。

MAMADAYSアプリはスーパーアプリになっていて機能数も多く長く利用して頂くユーザさんも多いアプリです。一方で、陣痛時の利用という利用期間が短い用途のものは小さいアプリに切り出して機能特化することでシンプルで使いやすい戦略を取っています。

そしてタイトルにも書いてある通り、陣痛カウンターはSwift Package Managerを採用しています。採用理由は下記で、お試しで導入するには最適だと考え、導入に至りました。

- アプリ自体の規模が小さい
- 前からSwift Package Managerを使ってみたかった
- 使用するライブラリ数が少ない

今回はそんなSwift Package Managerについてお話ししていきます。

陣痛カウンター

本題に入る前に少し陣痛カウンターアプリについて宣伝をさせてください。

陣痛カウンターは機能・デザイン共にシンプルですごく使いやすい!また、オフライン状態でも動作します!

陣痛がきたかなと感じたら”きたかも”ボタンをタップし、時間計測を始めます。陣痛が治まったら”おさまったかも”ボタンをタップして計測を終了します。

それらデータを履歴として表示して、陣痛間隔が一定の間隔より下回ったらお知らせする。といったアプリです!

実際の使い勝手などは実際にインストールして使ってみてください!是非周りに出産を控える妊婦さんがいたら紹介して頂けると嬉しいです。  

apps.apple.com

Swift Package Manager

さて、本題のSwift Package Manager(以下”SPM”と略)のお話をしていきます。 SPMはApple公式から提供されているパッケージマネージャーになっており、Xcode9以降から使うことができます。

SPMに対応しているか調査

まず、アプリに入れようとしているライブラリがSPMに対応しているのか調べる必要があります。

ライブラリ側がSPMに対応していなかった場合は、SPMは使えません。

調べ方はすごく簡単で、ライブラリのディレクトリ内に「Package.swift」というファイルがあるかどうかを見るだけです。

例:nuke
https://github.com/kean/Nuke
1. ライブラリのディレクトリが確認できるページを開きます。
2. ディレクトリ内を確認するとPackage.swiftがあるのが確認できます。
3. これでnukeはSPM対応されていることがわかります。

次にアプリ側でライブラリ使えるようにしていきます。 

[File] -> [Add Packages ...] 

Search or Enter Package URL のサーチエリアでライブラリのリポジトリURLを入力し検索をかけます。

すると、このようにnukeがサジェストされます。

Dependency Ruleで導入したいバージョン指定を行い、[Add Package]をクリックすると、ダウンロードが開始されます。

複数パッケージが存在する場合は、どのパッケージを導入するか選択する画面がでるので、任意のパッケージを選択しダウンロードしましょう。

ダウンロードが完了すればライブラリが使えるようになります。CocoaPodsと同様にimportして使ってください。 

実態はどこに

ここまででSPMを用いてライブラリを追加してきましたが、設定ファイルや実態はどこにいるんだ?チーム開発をしているからメンバーにはどうやって共有されるんだ?

という疑問が生じました。調べた結果下記の場所に設定ファイルや実態がありました。

依存関係を解決した結果や各ライブラリのバージョンが”Package.resolved”というファイルに書き出されるので、これを他メンバーに共有されることでライブラリのバージョンを揃えることができます。 

# ライブラリ群
> /Library/Developer/Xcode/DerivedData/{projectID}/SourcePackages/checkouts/

# Package.resolved(設定ファイル)
> /{project}.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

CocoaPodsとの比較

Xcodeのみでライブラリの追加/削除が簡単にでき、バージョン確認等々も完結できます。 "pod init"や"pod install"とかしなくていいので、結果的に操作する箇所が減って楽になりました。

SPMはApple公式が出しているパッケージマネージャーなので、安心感はあります。Xcode13以降ようやく動作も安定してきたようです。 

SPM導入で苦労したこと

陣痛カウンターはFirebaseのCrashlyticsを使用しています。

fastlaneでFirebaseにdSYMを上げているんですが、実行しても「upload-symbolsが存在しません」とエラーが吐かれました。

CocoaPodsでライブラリ管理をしているとPodsフォルダ配下にupload-symbolsというスクリプトファイルが存在しますが、 SPMはライブラリの実態がプロジェクトのディレクトリ配下にはいないので、参照できないということでした。

回避策として、Firebase公式のページからupload-symbolsをダウンロードし、fastlaneではそのスクリプトファイルを動かすように対応しました。

導入してみた感想

結論、この規模感のアプリにはSPMは適していると思いました。 まず、CocoaPodsとの比較セクションでも触れましたがライブラリの追加/削除がXcode内で簡単にできるのが一番大きいメリットです。

Pod関連のファイルも無くなるので、ディレクトリの見通しも良くなります。

Crashlyticsとか独自の対応を行いましたが、一度対応しておけば使い回すことができるので、最初だけ我慢しましょう。

ただ、陣痛カウンターのような小規模なアプリに適していると感じているだけで、中規模以上のアプリには不適切かもしれません。 あと、既にCocoaPodsで実装を進めてしまっている場合も、CocoaPodsのままでいいと思います。現状、移行するコストを払うほどのメリットは無いと感じています。

また古いライブラリはSPMに対応していないケースも多々あるので、導入前に追加予定のライブラリがSPMに対応しているかの調査はすごく大事です。その中で1つでもSPM対応されていないライブラリがあるのであれば、手間が増えるだけなのでCocoaPodsにした方がいいです。

最後に

ここまで読んでくださりありがとうございました。

SPMについて触りぐらいは伝わったでしょうか?この記事がSPM導入の手助けになることがあれば嬉しいと思います!

IAP, IABレシートとユーザー状態の管理について

DELISH KITCHENの定期購読

こんにちは、DELISH KITCHEN開発部でバックエンド開発を担当している南です。 主にDELISH KITCHENのプレミアムユーザー向けの機能の開発を行っております。

DELISH KITCHENでは、人気順検索、プレミアムレシピ(ダイエット、ヘルスケア、美容・健康、作りおき)、 プレミアム献立など、さまざまな機能を提供するプレミアムサービスという定期購読(サブスクリプション)商品を販売しております。 プレミアムサービスは、おもにiOSやAndroidのプラットフォーム上で管理、販売されておりDELISH KITCHENアプリ内から購入できます。

ここではiOSの課金をIAP(In-App-Purchase), Androidの課金をIAB(In-App-Billing)と呼んで区別したいと思います。

IAPとIABとDELISH KITCHEN

iOSやAndroidのプラットフォームに対してAPIを実行することでそれぞれ課金状態を表したレシートを取得できます。 IAPレシートもIABレシートも課金状態を表現するものという点では共通しているのですが、 表現の仕方がことなるためDELISH KITCHENのサーバー側で違いを吸収する必要があります。

IAPとIABの課金状態について

IAPもIABもレシートが表現する課金状態はほぼ同じですが、IABにのみ一時停止という状態があります。

課金状態名と、それがDELISH KITCHENにおいて、どのような状態かを説明した表です。

AndroidのIABレシート

IABレシートの構造はシンプルで、現時点の購読状態のみ返します。 購読が更新されれば、expiryTimeMillis の日時が増加し、支払いに関して変化がおきたら paymentState の値が変化します。

{
    "kind": "xxxxxxx",
    "startTimeMillis": 1111111111111,
    "expiryTimeMillis": 2222222222222,
    "autoRenewing": true,
    "priceCurrencyCode": "JPY",
    "priceAmountMicros": 480000000,
    "countryCode": "JP",
    "developerPayload": "",
    "paymentState": 1,
    "cancelReason": 0,
    "userCancellationTimeMillis": 0,
    "orderId": "GPA.0000-0000-0000-0000",
    "linkedPurchaseToken": "",
    "purchaseType": 0
}

IABレシートとユーザー状態

レシートの値からユーザーの課金状態を把握する必要があります。 IABは返す情報がシンプルで、情報と状態を結びつける資料も整備されているので判別することが簡単です。 一方で履歴のような過去の情報が一切ないため、状態の変遷をAPIから知る方法がありません。

Google Play の課金システム > 定期購入を販売する

(*) 一時停止状態とはユーザーがPlayストアの定期購読一覧から指定した期間だけ購読を中断して、期間がすぎたら再び自動再開する仕組みです。

AppleStoreのIAPレシート

一方でIAPレシートは、IABレシートと比べると構造が複雑で情報も多めです。

latest_receipt_infoには定期購読商品の購入履歴が含まれています。 履歴の1つ1つには、「どんな商品を購入したか?」、「何時購入したか?」、「何時期限切れになるか?」といった変化しない情報が含まれています。 (例外として返金キャンセルが発生すると履歴の値が変化します)

また状況に応じて刻々と値が変わるpending_renewal_infoという項目があります。 pending_renewal_infoからは「次回の更新で購入する予定の情報」、「定期購読を継続するか否か」、「期限切れになった理由」、といった状況に応じて変化する情報が含まれています。

{
    ...
    "latest_receipt_info": [
        {
            "quantity": "1",
            "product_id": "delishkitchen",
            "transaction_id": "111111111111111",
            "original_transaction_id": "111111111111111",
            "purchase_date_ms": "1629307052000",
            "purchase_date": "2022-02-01 07:00:00 Etc/GMT",
            "purchase_date_pst": "2021-02-01 00:00:00 America/Los_Angeles",
            "original_purchase_date_ms": "1643698800000",
            "expires_date_ms": "1646118000000",
            "expires_date": "2021-03-01 07:00:00 Etc/GMT",
            "expires_date_pst": "2021-03-01 00:00:00 America/Los_Angeles",
            "cancellation_date_ms": "0",
            "cancellation_date": 0,
            "cancellation_date_pst": 0,
            "web_order_line_item_id": "333333333333333",
            "is_trial_period": "true",
            "is_in_intro_offer_period": "false",
            "promotional_offer_id": ""
        },
        {
            "quantity": "1",
            "product_id": "delishkitchen",
            "transaction_id": "222222222222222",
            "original_transaction_id": "111111111111111",
            "purchase_date_ms": "1646118000000",
            "purchase_date": "2022-03-01 07:00:00 Etc/GMT",
            "purchase_date_pst": "2021-03-01 00:00:00 America/Los_Angeles",
            "original_purchase_date_ms": "1629307053000",
            "expires_date_ms": "1648796400000",
            "expires_date": "2022-04-01 07:00:00 Etc/GMT",
            "expires_date_pst": "2021-04-01 00:00:00 America/Los_Angeles",
            "cancellation_date_ms": "0",
            "cancellation_date": 0,
            "cancellation_date_pst": 0,
            "web_order_line_item_id": "444444444444444",
            "is_trial_period": "false",
            "is_in_intro_offer_period": "false",
            "promotional_offer_id": ""
        }
    ],
    "pending_renewal_info": [
        {
            "expiration_intent": "",
            "auto_renew_product_id": "delishkitchen",
            "original_transaction_id": "111111111111111",
            "is_in_billing_retry_period": "",
            "product_id": "delishkitchen",
            "auto_renew_status": "1"
        }
    ]
}

IAPレシートとユーザー状態

IAPレシートは情報が多めですが、レシートからユーザーの状態を把握する際は以下の情報を用いています。

  • latest_receipt_infoの最新のレシート
    • is_trial_period: 無料トライアルか否か
    • expires_date_ms: 有効期限の日時
    • cancellation_date_ms: 返金した日時
  • pending_renewal_info
    1. auto_renew_status: 購読を継続するか否かを表す。
    2. expiration_intent: レシートが期限切れになった理由を表す。期限内は常に空
    3. is_in_billing_retry_period: 支払いリトライ中か否かを表す。ExpirationIntent=2以外のときは空
    4. grace_period_expiration_date: 猶予期間の期限
    5. auto_renew_product_id: 次回の更新に購入するプロダクトID

課金状態と注意点

猶予期間保留中(支払いリトライ状態)一時停止の扱いは気をつけないといけません。

猶予期間

期限が切れたユーザーを引き続き課金状態として扱うため、IABではexpiryTimeMillisの日時が自動的に伸びます。 一方IAPでは、latest_receipt_infoのexpires_date_msは伸びません。代わりに、grace_period_expiration_date_msに猶予期間の日時が入ります。

保留中(支払いリトライ状態)

猶予期間後も支払いに失敗しつづけている状態です。 期限切れになり無料ユーザー状態となりますが、一定期間(デフォルトでiOSは60日間、Androidは30日間)支払いをリトライし続けます。 リトライによって支払いが成功すると購読状態に戻りますが、一定期間以上失敗し続けると、プラットフォームが自動的に解約状態にしてリトライをやめます。 また支払いリトライ期間中に解約することもできます。

一時停止

IAB特有のユーザー状態です。こちらも一度期限が切れるため、一見解約したように見えます。 しかし指定した期間をすぎると何事も無かったかのように購読を再開するため、一時停止状態中だと判別できていないと解約したユーザーが再び戻ってきたかのように見えてしまいます。 また定期購読一時停止中に解約することもできます。

定期購読と状態管理

Choosing a Receipt Validation Technique

こちらで述べられている通り定期購読状態を適切に扱うにはサーバーで購読状態を管理し、同期する必要があります。 ですが、これだけでは下記のようなユーザーの行動の変遷を追うことはできません。 ユーザー状態をと経緯を正確に判断するためにも、レシートの履歴をサービスのサーバー側で保存することも大切です。

  • ユーザーが猶予期間中になっていたか?
  • 保留中から戻ったのか?、それともキャンセルしたのか?
  • 一時停止から戻ったのか?、それともキャンセルしたのか?

まとめ

定期購読の難しいところでも述べられておりますが、 一見単純そうにみえる定期購読ですが、正しくやろうとすると実は面倒なことが多いです。 またIAP、IABの仕様追加にも追従していく必要がありサーバー側の保守コストがかかります。

ですがDELISH KITCHENのプレミアム機能を多くの方に提供し続けるためにも定期購読の管理・アップデートを続けていきたいと思っております。

参考資料

  1. Google Play の課金システム > 定期購入を販売する
  2. App Store Receipt Data Types
  3. Choosing a Receipt Validation Technique
  4. Question About Ios Receipt Fields Addition on July 19 2017
  5. App Store の In-App Purchase の Grace Period対応
  6. アップルはApp Storeのサブスク期限切れに「猶予期間」を導入
  7. Engineering Subscriptions(WWDC 2018)
  8. Auto Renewing Subscriptions for iOS Apps

Next.js + useForm/zod で楽をする管理サイト作り

f:id:siukaido:20220325141415p:plain

こんにちは。TIMELINE開発部の齊藤です。好きなエディタはEmacsです。社内の一部エンジニアからは珍獣扱いされてますが、Emacsは最強のエディタなので20年近く愛用しています1

さて、皆様は日頃のサービス運用に、社内向けの管理サイトなどを作っているかと思われますが、弊社でもご多分に漏れず管理サイトを用意して、日々の運用を行なっております。

この管理サイトの出来不出来によっては、運用コストも大きく変わったりするので、案外重要なものだったりするのですが、作るのは正直めんどくさいです。

ユーザさんにお見せするサイトと異なり、MAUは一桁ぐらいですし、いいものを作っても誰かに誉められることも少ないので、正直めんどくさいです(大事なことなのでry

なので、めんどうなことは少しでも楽をしつつ、それでいて運用事故/コストを少しでも減らせられるようにがんばっていたりします。

序文

私が所属しているTIMELINE開発部では、「au payマーケット」アプリで提供している「ライブTV」の運用/開発などを主に担当しています。

コロナ禍でライブコマースがEC市場で再燃。複数のプラットフォームで配信できる『TIMELINE』ならではの強みとは

元々、弊社ではCHECKというライブコマースサービスを運用していました。

ライブTVはその時の資産を利用しており、管理サイトなども数年前のリリース時に私が作ったのがそのまま利用されていたりします。

当時は何も考えなしに作ってたのですが、TIMELINEに合流して改めて見ると、あちらこちらでめんどくせーってところが散見されたため、そこらへんを修正していったあれやこれやを紹介させていただきます。

ビルドの自前管理がめんどくさい

webpackを用いてビルドしているのですが、元々はそのコンフィグファイルを自前管理していました。

当時は「 grunt に比べてなんて楽なんだろう...」と感動して使っていたのですが、久々に昔書いたコンフィグファイルを眺めていると、「こんなん管理するとかムリー!!」っていう感情に溢れてしまうほどめんどくさいものでした。

そこで、MAMADAYSで採用していた Next.js へ全面的に載せ替えることにしました。

Next.js の利点として SSR/SSGによるものがよく挙げられますが、管理サイトのフレームワークとして採用する利点はそこではなく、「ビルド管理をNext.js に任せられる。また、それに伴う各種恩恵(後述)に与れる」ところです。

もちろん載せ替える手間はあります。しかし、今後発生するであろうめんどくささと比べたら微々たるものですし、元々が React で書いていたので最低限の修正だけで載せ替えることができました。

これにより next build するだけでビルドしてくれます。コンフィグを自前管理しなくてもいい2だなんて、神!

さらには、開発時のホットリロードも独自で書いてたのですが、それすらも next dev するだけでやってくれます。至れり尽くせりすぎる!

開発効率は格段に上がり、細かい修正とかへのストレスもだいぶ軽減されました。

ルーティングがめんどくさい

元々は react-router-dom を使って、下記のような方法でルーティングを行っていました。

import { HashRouter, Switch, Route } from 'react-router-dom';
import Index from './containers/Index';
import Hoge from './containers/Hoge';

const routes = [
{
    path: '/',
    exact: true,
    component: Index,
  },
  {
    path: '/hoges,
    exact: true,
    component: Hoge,
  },
];

export default class App extends Component {
  render() {
    return (
      <HashRouter>
        <Switch>
          {routes.map(({ path, exact, component }) => (
            <Route key={path} path={path} exact={exact} component={component} />
          ))}
          <Route render={(props) => <NoMatch {...props} /> } />
        </Switch>
      </HashRouter>
    );
  }
}

教科書どおりな react-router-dom の使い方ですし、NestedRouteing などの考えはものすごく良いのですが、管理サイトでそこまでやるメリットが思いつきませんでした。

むしろ、これだとページを追加するたびに routes を修正しないといけないし、path とファイル名が一致しないケースがあったりと、めんどくささが満載です。

ですが、Next.js に載せ替えたため、pages 以下にファイルを置くだけで、ルーティングをよしなにしてくれます。

だいぶめんどくささが軽減されましたし、path とファイル名が一致してるだけで、ものすごく気分が楽になります。

コンポーネント名を覚えるのがめんどくさい

JavaScript は型がなく厳密な書き方をせずとも動くので楽っちゃ楽なのですが、型がないがゆえに補完をうまくしてくれません。

そのため、ファイル名や位置とかをある程度、脳内メモリに格納した上で開発しないといけないわけですが、私も本厄を迎える歳となってしまい細かい記憶力に心配がでてくるようになってきました。

TypeScript であれば、最強のエディタであるEmacsがよしなに補完してくれる3し、import とかも気にせずに済むんですが...。

という悩みも、Next.js に載せ替えたことにより、tsconfig.json を置くだけでTypeScript化は完了です。

無事に加齢による衰えをシステムでフォローしてくれようになり、高齢化対策も万全です!

デザインがめんどくさい

cssが苦手です。書きたくないです。レスポンシブデザインとかになると、めんどくささに溢れてます。

なので、管理サイトは基本的に Bootstrap を使ってデザインしてます。

ただそれでもクラス名を覚えるのがめんどくさいので、 ReactBootstrap を使ってます。

もちろんReactBootstrapのコンポーネント名とかもEmacsが補完してくれます4

また、どうしてもcssを書かなきゃいけない個所であっても、Next.js が Sass に対応してくれるので、生でcssを書くよりも格段に楽ができます。

バリデーションがめんどくさい

元々は下記のように各項目ごとにバリデーションを書いていました。

const [form, setForm] = useState({ name: '', state: 0 });

const isValidName = () => {
  if (form.name.length < 5) {
    return false;
  }
  if (form.name.length > 10) {
    return false;
  }
  return true;
};

const isValidState = () => {
  if ([0, 1].includes(form.state)) {
    return false;
  }
  return true;
};

const onSubmit = (e) => {
  e.preventDefault();

  if (!isValidName() || !isValidState()) {
    // エラーハンドリング
    return;
  }
  // 正常処理
};

まー、めんどくさい。しかも、漏れも生じまくる。入稿してくれる方の職人芸もありつつの、事故回避でした。

そこで zod を用いることにしました。 zodに関しては uttkさんのこの記事 が秀逸です。めちゃくちゃ参考にしました。

上記の例をzodを使って書き直すと

const schema = z.object({
  name: z.string().min(5).max(10),
  state: z.union([z.literal(0), z.literal(1)]),
});

const [form, setForm] = useState({ name: '', state: 0 });

const onSubmit = (e) => {
  e.preventDefault();

  try {
    f = schema.parse(form);
  } catch (e) {
    // エラーハンドリング
    return;
  }
  // 正常処理
};

意識するのはzodの定義のみで、非常にわかりやすくなりました。

しかも、zodからTypeScriptの型も吐き出せます

type IForm = z.infer<typeof schema>;

// IForm = type {
//   name: string,
//   state: 0 | 1,
// }

なので、一石二鳥!使わない手はないです。

フォームがめんどくさい

運用の大部分を占めるのが入稿作業だと思います。

これも元々はお見せするのも恥ずかしいレベルのオレオレフォームを作っていたのですが、useForm を使うようにしました。

zodと組み合わせるとすげー便利ですし、すっきりさせることができました。

const methods = useForm<IForm>({
  resolver: zodResolver(schema),
  defaultValues: {
    name: '',
    state: 0,
  },
});

const onSubmit: SubmitHanlder<IForm> = (data) => {
  // 正常処理
};

跋文

こんな感じで手を抜けるところは抜いて、それでいて運用コストを少しでも下げられるような改善を日々行っています。

また、手を抜くことにより、理解する箇所を極限まで減らしていくことにもつながるので、普段フロントエンドを書いていないエンジニアでも、運用サイトの更新ができるという面もあったりします。


  1. Emacsは エディタではなくOSである という方もおられますが、ここではエディタとして扱わせていただきます

  2. もちろん細かい設定をいじりたいときは修正する必要がありますが、そうだとしても格段に管理が楽になりました

  3. だいたいのエディタでやってくれます…

  4. だいたいのエディタでやってくれます…

swagとecho-swaggerを使ったSwagger UIでの開発談

はじめに

こんにちはMAMADAYSバックエンドチームのrymiyamotoです。最近エルデンリングを遊び倒しています。

MAMADAYSではアプリとWebで利用しているAPI(golang)の仕様をドキュメント化するためにSwaggerを利用しています。

導入をしてから3年以上経過したため、APIの開発運用を進める中で出てきた課題点への施策を綴っていこうと思います。

そもそもSwaggerとは?

SwaggerはOpenAPIというRESTful APIの仕様を記述するためのフォーマットを使用したツールで、仕様が文章化されることで開発者や関係者での認識が取りやすくなります。

動作環境

MAMADAYSではSwaggerの利用にあたって以下のツールを使っています。

ツール名 用途 バージョン
swag ドキュメントの自動生成 v1.8.0
echo-swagger Swagger UIの表示で利用(ドキュメントの可視化) v1.3.0

Swaggerをそのまま使う分にはyamlを表記するだけですが、MAMADAYSではドキュメントを自動生成するための swag を使っています。 swag では定義したstructの型に合わせてドキュメントを生成するのでyamlを直接手で変更する必要がなく楽です。

また生成されたドキュメントのままだと視覚的に分かりにくいため、Swagger UIを表示できるように echo-swagger を利用しています。

以下MAMADAYSので表記に合わせた簡易的な例です。 (goのバージョンは1.17.8です)

package main

import (
    "net/http"

    _ "github.com/rymiyamoto/swagger-test/docs"

    "github.com/labstack/echo/v4"
    echoSwagger "github.com/swaggo/echo-swagger"
)

type (
    Response struct {
        Int64  int64  `json:"int64"`
        String string `json:"string"`
        World  *Item  `json:"world"`
    }

    Item struct {
        Text string `json:"text"`
    }
)

// @title         example
// @version       1.0
// @license.name  rymiyamoto
// @BasePath      /
func main() {
    e := echo.New()

    e.GET("/swagger/*", echoSwagger.WrapHandler)
    e.GET("/", hello)

    e.Logger.Fatal(e.Start(":1323"))
}

// hello godoc
// @Summary  Hello World !
// @ID       HelloWorldIndex
// @Tags     HelloWorld
// @Produce  json
// @Success  200  {object}  Response
// @Router   / [get]
func hello(c echo.Context) error {
    return c.JSON(http.StatusOK, &Response{
        Int64:  1,
        String: "example",
        World: &Item{
            Text: "hello world !",
        },
    })
}

※go.modとgo.sumは省略しています

$ go install github.com/swaggo/swag/cmd/swag@v1.8.0
$ swag init
$ go mod tidy
$ go run main.go

NULL許容の値を表現する

sql.NullStringsql.NullInt64 などのNULL値を含むデータをそのまま使うことができないため swaggertype:"XXX" で対象のキーに表現したい型を定義するかと思います。

しかしこのままだとNULL許容であるかどうかがわかりません。

方法としては2種類あるので紹介します。

descriptionを追加する

対象のキーにコメントとして書くことでdescriptionが追加でき、ここでNULL許容であるかどうかを表現してます。

Hello struct {
    NullInt64  sql.NullInt64  `json:"null_int64" swaggertype:"integer"` // nullable
    NullString sql.NullString `json:"null_string" swaggertype:"string"` // nullable
}

extensionsで任意の追加情報を付与する(echo-swagger v1.3.0では非対応)

extensions:"x-XXX"で任意の追加情報を付与することが可能です。

NULL許容を表現するにあたっては extensions:"x-nullable" で指定することにします。

Add extension info to struct field

Hello struct {
    NullInt64  sql.NullInt64  `json:"null_int64" swaggertype:"integer" extensions:"x-nullable"`
    NullString sql.NullString `json:"null_string" swaggertype:"string" extensions:"x-nullable"`
}

ただし echo-swagger(v1.3.0) 上では表示できないため、出力されたyamlをSuwagger Editor上で確認する人向けです。

(表示がうまくいかないのは、依存packageである swaggo/files の内部で保持しているファイルが古そうです)

同一リソース名を扱う

同一リポジトリ内でAppやWeb・Dashboard等でAPIを作成している場合、リソース名が重複します。

このとき swag 側で全体のパスを含めた構造体名に変更してくれますが、その表記が長く冗長になってしまいます。

単純に1つしかSwaggerを利用しなければ気にすることはありませんが、表記が長くならないようにそれぞれprefixを足して見通しを良くしています。

// DashboardHoge 内部向けDashboard用
DashboardHoge struct {
    Text    string    `json:"text"`
    StartAT time.Time `json:"start_at"`
    EndAT   time.Time `json:"end_at"`
}

// Hoge App用
Hoge struct {
    Text string `json:"text"`
}

structでリクエストのbodyを表現する

bodyを扱う場合に各APIのコメントに以下のように記載すればよいです。

// post godoc
// @Summary  Hello World !
// @ID       HelloWorldPost
// @Tags     HelloWorld
// @Produce  json
// @Param    title        body      string  true   "タイトル"
// @Param    description  body      string  false  "説明"
// @Success  200          {object}  Response
// @Router   / [post]
func post(c echo.Context) error {
    // ...
}

しかしこの状態だと

  • パラメーターが増えると定義が面倒
  • リクエストの定義とコントローラーの定義を別階層で管理していると抜け漏れが発生しやすくなる
  • 同一のリクエストを使いまわしていると修正が冗長になってしまう

となってしまいます。

その対策として、bodyには定義しているformのstructを渡すようにしています。

こうすることで、form部分の修正のみで対応するAPIのbodyも一括で変更できるため管理が簡単になります。

// post godoc
// @Summary  Hello World !
// @ID       HelloWorldPost
// @Tags     HelloWorld
// @Produce  json
// @Param    body  body      Form     true  "request body"
// @Success  200   {object}  Response
// @Router   / [post]
func post(c echo.Context) error {
    // ...
}

Form struct {
    Title       string `json:"title"`       // require
    Description string `json:"description"` // option
}

終わりに

swag を使い続けて3年も経過すると色々と気になるところが出てくるので、利用ルールの制定多くなってきました。

理想を言えばその部分も定義できればより汎用性が出そうです。

しかし適期的にアップデート内容を確認してきましたが、少しずつOpen API 3.0の記法も使えるようになってきているのでしばらくは使っていこうと思います。

皆様良きSwaggerライフを!

ECS Fargate を検証するために ECS Exec を使用した話

f:id:hueless:20220302185515p:plain

tl;dr

  • Fargate ではホストが隠蔽されていて、EC2 のように SSH でコンソールに入って検証することができない
  • ECS Exec は十分に SSH の代用となる
  • ECS Exec の導入に必要なことはこのセクションを参照

DELISH KITCHEN on ECS

弊社では DELISH KITCHEN というサービスを運用しており、主なアプリケーションサーバは ECS の上に構築しています。

ECS には EC2 によるものと、Fargate によるものの2つの環境が存在しますが、現時点ではほぼ全てのアプリケーションサーバが EC2 の環境です。

ECS は、十分な機能を備えながらシンプルで柔軟に運用できる優れたコンテナオーケストレーションサービスです。

しかしながら、EC2 環境においては常にホストとなる EC2 インスタンスをメンテナンスする必要があります。

それほど頻繁に発生する作業ではありませんが、定期的に AMI を更新しなければなりませんし、ECS 以外にホストで動作させているものがあれば、新しい AMI でも同様に動作するかの検証が必要になります。

また、EC2 の定期メンテナンスを行なっても、基本的にサービスへの影響がないことも、どのタイミングでメンテナンスを行うべきかの判断を難しくさせます。

同じだけ人員と工数をかけるのであれば、サービスにとってプラスになるタスクを優先したいというのは当然の考えです。

必然的にこのようなタスクは後回しになりますが、何らかの理由で(多くの場合はセキュリティに起因します)本当に作業が必要になった時には、それまでに積み重なっていた問題を一度に解決しなくてはいけないような状況に陥ることすらあります。

DELISH KITCHEN on Fargate

Fargate とは、ECS 上でコンテナのホストとなる EC2 の管理を不要にし、ECS タスクの管理に集中することができるサービスです。

DELISH KITCHEN で Fargate を採用できれば、前述したようなインスタンスの管理に必要なタスクが削減でき、よりサービスの開発に注力できるようになります。

これは 2022年 エブリーの開発組織の抱負 にある 事業にこだわる開発組織 にもマッチしており、導入に向けて検証を進めることになりました。

検証を進めていく中で、一番ネックになったのはホストに SSH ができないことです。

従来の EC2 環境ではホストに SSH 後、docker exec 経由でコンテナの内部の調査ができたのですが、Fargate ではホストも docker も隠蔽されており、通常はアクセスすることができません。

最初は原始的にアプリケーション自体に printf デバッグを仕込んだりしていたのですが、あまりに効率が悪いため、ECS Exec を導入しました。

ECS Exec の評価

ECS Exec では以下のようなコマンドで shell を実行できます

aws ecs execute-command --cluster "$CLUSTER" --task "$TASK_ID" --container "$CONTAINER_NAME" --interactive --command sh

shell だけ使うならほとんど SSH と使い勝手は変わりません。強いて言うならタイムアウトが 20 分とやや短いのと、その設定がグローバルでしか設定できないところが使い勝手が悪いでしょうか。

scp でファイルの送受信ができないことも困るタイミングがありますが、s3 を経由するのが一般的な解決法のようです。

port forwarding は ECS Exec では提供されませんが、大元の SSM では提供されています。 targetに ecs:{CLUSTER}_{TASK_ID}_{CONTAINER_NAME} を指定すれば Fargate で起動しているタスクへのセッションが開始できます。

例えばコンテナ内の nginx に port forwarding するなら

aws ssm start-session \
    --target "ecs:${CLUSTER}_${TASK_ID}_${CONTAINER_NAME}" \
    --document-name AWS-StartPortForwardingSession \
    --parameters '{"portNumber":["80"], "localPortNumber":["$LOCAL_PORT"]}'

のようにします。

Fargate への道

このように、多少の制限はあるものの、Fargate でも従来の使い勝手に近い形でアプリケーションの調査ができることが確認できました。

現在は引き続き Fargate の評価と移行準備を行なっており、次回があればその話をできれば良いと考えています。

また、ECS Exec を有効化するのにいくつか戸惑った点があったため、最後にドキュメントと共に補足としてまとめておきました。参考になれば幸いです。

ECS Exec の有効化

ECS Exec を利用するためには、事前にいくつかの準備が必要になります。 公式のドキュメント の通りにすれば良いのですが、セッションマネージャープラグインのインストールを見落としやすいため、注意が必要です。

こちらのドキュメントではプラグインのインストールに関するリンク先が英語のみですが、こちらにほぼ同様のものの日本語版があります。

以下にドキュメントの各セクションについて補足の説明を記載します。

ECS Exec を使用するための考慮事項

このセクションでは特に以下の点に注意すれば良いでしょう

  • ECS Exec は linux でしか動作しない
  • 既存のタスクは変更できない(設定後に新しく配置されたタスクから有効になる)
  • コマンドは root からのものになる
  • デフォルトのセッションタイムアウトは 20 分

ECS Exec を使用するための前提条件

aws cli は余程古いバージョンを使っていない限り問題にならないでしょう。

前述の通り、プラグインのインストールが必要になります。aws cli のセットアップ後、ドキュメントに従って各自の環境に合わせたプラグインをインストールしましょう。

Fargate のバージョンも、最近作られたサービスなら問題にならないと思いますが、2021/03/19 より以前に作られたものの場合、注意が必要です。

ECS Exec の有効化と使用

最低限 IAM の設定とサービス(ないしタスク)の設定をすれば ECS Exec を使用できるようになります。 対象がサービスの場合、設定だけでなく、タスクの再配置が必要になります。ウェブコンソールからの場合、"サービスの更新"から"新しいデプロイの強制"を有効にすると設定を変えずにタスクを再配置することができます。

現時点ではウェブコンソールからはサービスやタスクについて、ECS Execを有効にするための機能は提供されていません。ドキュメントに従い、--enable-execute-command 引数を使って aws cli から設定する必要があります。

aws ecs execute-command で SessionManagerPlugin is not found というエラーが出る場合、セッションマネージャープラグインが認識されていません。初回の設定後、シェルやマシンの再起動などが必要な場合があります。