fetchでは正確なエラーハンドリングができない? 〜 JavaScriptのFetch APIでより安全にデータを取得する方法 〜

トラストバンク開発部・フロントエンドチームの君田(きみた)です。

先日以下のような記事を目にして、とても気になったので記事にしようと思いました。

www.builder.io

自分もフロントエンドの開発時によく使用したり、見かけたりするFetch APIです。 このFetch APIのエラーハンドリングについて言及されていた記事で、書かれていた内容を実際に試してみたので書いていきます。

Fetch APIステータスコード200以外の場合でもエラーをスローしない

任意のステータスコードを返却してくれるサービスを使用して記載していきます。

httpbin.org

例えば以下のようなコードがあったとします。

const fetchSample = async () => {
  try {
    // ステータスコード404を返すリクエスト
    const response = await fetch('https://httpbin.org/status/404');
  } catch (err) {
    console.log(err, 'error')
  }
}

この場合、存在しないURLへデータを取得しに行っていますが、fetchSampleを実行してもcatch句に書かれているconsole.logが実行されません。 実際に実行するとFetch APIステータスコード200以外の場合でもエラーをスローしないことがわかります。 これを以下のように書き換えることで、エラーをスローしcatch句に書かれているconsole.logが実行されるようになります。 これは取得したデータのjsonメソッドのエラーによりcatch句の処理が実行されています。

const fetchSample = async () => {
  try {
    // ステータスコード404を返すリクエスト
    const response = await fetch('https://httpbin.org/status/404');
    const data = await response.json();
  } catch (err) {
    console.log(err, 'error')
  }
}

ステータスコードによって処理を分岐させ、適切なハンドリングができるようにしよう!

レスポンスのステータスコードごとにエラーハンドリングすることで、どのようなことが原因でエラーになっているのかをユーザーに明確に示すことができます。 以下は受け取ったレスポンスのステータスコードを判別し、適切な処理を行うサンプルコードです。

const fetchSample = async () => {
  try {
    const res = await fetch("https://httpbin.org/status/404");
    if (!res.ok) {
      switch (res.status) {
        case 400:
          throw new Error('400 error');
        case 401:
          throw new Error('401 error');
        case 404:
          throw new Error('404 error');
        case 500:
          throw new Error('500 error');
        default:
          throw new Error('something error');
      }
    }
  } catch (err) {
    console.log(err, "error");
  }
};

これでもステータスコードを判別し、各エラーごとに適切な処理を実行することができます。 ただ、可読性を上げるためにもエラーハンドリングはcatch句に記載したいところです。

そこで、Errorクラスを継承したクラスを作成し、catch句で判別できるようにします。

class ResponseError extends Error {
  constructor(message, res) {
    super(message);
    this.response = res;
  }
}

const fetchSample = async () => {
  try {
    const res = await fetch("https://httpbin.org/status/404");
    if (!res.ok) {
      throw new ResponseError("fetch did not work properly", res);
    }
  } catch (err) {
    switch (err.response.status) {
      case 400:
        // 400エラーだった時の処理
        break;
      case 401:
        // エラーだった時の処理
        break;
      case 404:
        // エラーだった時の処理
        break;
      case 500:
        // エラーだった時の処理
        break;
      default:
        // エラーだった時の処理
        break;
    }
  }
};

これでステータスコードごとのエラーハンドリングが可能になりました。 ただ、Fetch APIの登場回数は結構多く、あらゆる箇所で使用されると思います。 ですので、毎回この実装をするのは少し面倒です。 そこで、一連の処理をラップして独自の関数を作成したらかなり使い勝手の良いものになると思います。 例えば

class ResponseError extends Error {
  constructor(message, res) {
    super(message);
    this.response = res;
  }
}

export async function myFetch(...options) {
  const res = await fetch(...options)
  if (!res.ok) {
    throw new ResponseError('Bad fetch response', res)
  }
  return res
}

Fetch APIを利用するときは上記の例でいうmyFetch関数を呼び出し、レスポンスが成功した場合(ステータスが200-299)はレスポンスデータを返し、 そうでない場合はエラーと共に、レスポンスデータを返すようにします。 これを今までのtry...catch句で使用すると、

const dataFetch = async () => {
  try {
    const res = await myFetch('https://httpbin.org/status/404')
    const user = await res.json()
  } catch (error) {
    // Errorクラスを拡張したクラスを使用しているので
    // errorオブジェクトにレスポンスデータが格納されており、
    // その中のステータスコードにより適切なハンドリングをすることができるようになる
  }
}

と実装することができかなりスッキリします。

axiosを使えばさっきのラッパー関数使わなくてもステータスコードごとにエラーハンドリング可能.....

github.com

行っていることはFetch APIとほぼ同じですが、

実際に以下のコードを試して、コンソールを確認してみてください。

const fetchSample = async () => {
  try {
    const { data } = await axios.get('https://httpbin.org/status/404')
  } catch (err) {
    console.log("err", err.response);
  }
};

fetchSample();

そうすると以下のようなjsonデータが返却されていることがわかります。

{
  data: "",
  status: 404,
  statusText: "",
  headers: AxiosHeaders,
  config: Object,
  request: XMLHttpRequest,
}

元々エラーが発生した時にステータスコードのみならず、そのほかの情報も確認することができます。

さいごに

JavaScriptのデータ取得におけるエラーハンドリングについて記載しました。 Fetch APIaxiosどちらを使用しても同じ処理が実現できますが、使用する場面やプロダクトによって適切な選択をし、 それぞれで正しくエラーを処理できるようにしたいと思いました。

弊社トラストバンクでは様々な職種で絶賛採用中です! 気になった方、是非お気軽にご連絡ください!

www.wantedly.com

iOSのinputとtextareaクリック時にズームされてしまうのを防ぐ

トラストバンク開発部・フロントエンドチームの君田(きみた)です。

本日はiOSにて入力フィールドをクリックした時に自動でズームされてしまう挙動を防ぐ方法を書いていこうと思います。

iOSでは入力フィールドの文字サイズ(font-size)が16px以下だとズームされる

iOSの挙動では入力フィールドの文字サイズによって、少し画面がズームされます。 見出しにもあるとおり、具体的には16px以下の文字サイズを設定しているとクリック時にズームされます。

実際の挙動はこんな感じです。 (現状のふるさとチョイスでは以下の挙動は起こりません。疑似的にズームされるようにしています。) ズームの挙動

この挙動をそのまま保持したい場合はなんの問題もないですが、「ズームされたくないけど、文字サイズは小さくしたい!」 というような要望があった場合、ズームされるのを防がなければなりません。

ズームされるのを防ぐ解決策

metaタグのviewport設定を以下のように記述することで入力フィールドの文字サイズを16px以下にした場合でもズームされることを防ぐことができます。

   <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>

maximum-scale=1.0を設定することによりズームされるのを防ぐことができます。 ピンチインによるズームは使用可能な状態のままのですので、ご安心ください。

そのほかのviewport設定に関しては以下で紹介されています。

developer.mozilla.org

また、各モバイルデバイスのviewport一覧は以下で紹介されています。

experienceleague.adobe.com

最後に

弊社トラストバンクでは様々な職種で絶賛採用中です! 気になった方、是非お気軽にご連絡ください!

www.wantedly.com

トラストバンクのSREとしてこの1年やってきたこと

この記事は、トラストバンク Advent Calendar 2022の23日目です。

こんにちは!CTO室でSREやっている@Tocyukiです!

夜はまさに大クリスマス時代、という感じで気づいたら年の瀬で明日はクリスマスイヴで明後日はクリスマスでグランドライン目指してWe are !!という感じですね!

というわけでアドベントカレンダー書いていきたいと思います!

ふるさとチョイスのではなくトラストバンクのSREとして

昨年のアドベントカレンダーではこんな記事を書かせていただきました。

qiita.com

ありがたいことにたくさんの人に読んで頂けて、一時はIT関連のはてブランキングにも登場するなど「我、ふるさとチョイスのSRE也」ということを世に知らしめることができたとかできないとかそんな記事になったとかならないとかしました(してない

そんな(?)様々な改善活動が評価されたのかどうかは知る由もありませんが、今年の4月に組織編成が行われ、元々ふるさとチョイス事業本部開発部付のSREチームだったのが全社組織であるCTO室が爆誕し、それに伴いCTO室付のSREチームとして事業部を横断する形に活動の場を広げることになりました。

さらに個人としてもフルリモート環境下におけるコーポレート全体のコミュニケーション改善をミッションとしたインターナルコミュニケーションチームも兼務することになり、Gatherの導入やNotionの全社導入などSRE領域とはまた違ったチャレンジもしてきました。

今回アドベントカレンダーを書くにあたりこの一年を振り返ってみたのですが、活動の幅が広がったことも有り、個人としてもチームとしても思った以上に色々とやってきてて完全に記憶から消去されているなぁと思ったのでこの1年でどんなことをやってきたのかという部分を記憶とチケットを掘り起こしながら以下のテーマを中心に振り返っていきたいと思います!

  • 採用活動
  • 改善活動
  • 新規サービスリリース

採用活動

自分がトラストバンク一人目のSREとして入社してから2年が経過しました。現在SREチームは3名いて来月から4名体制になる予定で去年に比べるとかなり順調な感じなのですが、改めて振り返ると以下のような感じでした。

名前 入社時期 採用要因
1人目SREニキ Tocyuki 2020/12 1人目のSREとして裁量もってできる場所を求めてたどり着いた。
2人目SREニキ budatora 2021/07 プロダクトとフェーズに共感してくれた。正直採用できたのは幸運以外のなにものでもない。
3人目SREニキ iwatea 2022/06 リファラルでマイメンとして来てくれた。正直採用できたのは幸運以外のなにものでもない。
4人目SREニキ Mr.Y 2023/01 唯一採用戦略がハマり採用できた。(はず)シャスシャス!!

上記の通り、今年の中頃まで採用活動に関してはかなり苦戦していて振り返ってみると採用活動の成果と言えるのは4人目のみでした。

今年の6月までは2名体制でひーこら言いながら通常の業務と平行して採用活動を頑張っていたのですが、募集要項やスカウト文面の見直しに終始してしまい中々思うような成果に結びつかず、スカウトを打っても返信が返ってくることは稀でカジュアル面談に辿り着くことさえ困難な状況が続いていました。

これは流石にやばいなぁと思い、今年度の個人目標としてもSRE採用にコミットすることにして以下のことにチャレンジをしました。

  • 伝家の宝刀リファラ
  • テックブログ開設
  • コミュニティ活動
  • 登壇

伝家の宝刀リファラ

前述の通り、2人目のSREである@butadoraが入社してくれて以降、採用活動は非常に厳しい状況だったにも関わらず、SRE全社化による他事業部の改善や他サービスのAWS移行などもやることになり、新規サービスリリースも計画されていき、SREの採用が急務となる中、前職の同僚であり、マイメンでもあり、転職後もちょこちょこ連絡を取り合っていた@iwateaリファラルで入社してくれるということになりました。

今回のアドベントカレンダーで入社エントリを書いてくれたのでこちらも是非見てみてください。 tech.trustbank.co.jp

上記入社エントリにも書いてありますが、前職で出会いとても仲良くなって話はよくしていたのですが結局直接一緒に仕事をする機会は訪れることなく自分が先に転職してしまったのでどこかで仕事も一緒にできれば良いなとずっと思っていました。

また@iwateaPHPでのバックエンド開発をメインにやってきていることやTerraformやAnsible、AWSも問題なく扱えるので弊社技術スタックとの親和性も非常に高く、SREとして普通に採用するのは無理ゲーなレベルの人材だったので、様々なタイミングや条件も合う形でリファラルが実現が出来たのは最高にラッキー、もはやディスティニーだったなと思います。

持つべきものは友ですね。

テックブログ開設

採用活動ではテックブログの有無も影響を及ぼしてきますよね。

弊社には元々テックブログはなかったのですが、そんな中、昨年末に弊社初となるアドベントカレンダーが実施されました。

qiita.com

そしてアドベントカレンダーを行ったことでアウトプットの機運も高まったので、すかさずテックブログの開設を行いました。

tech.trustbank.co.jp

アウトプット文化を醸成するためには恒常的にアウトプットできる場所が必要ということと採用面へのポジティブな効果を期待して開設したのですが、テックブログ開設後はカジュアル面談に来てくれる方や面接に来てくれる候補者の方々が高い確率でテックブログを見てくれていてトラストバンクのエンジニア組織にはどういう人がいてどんなことがアウトプットされているのかという情報を提供する役割を担ってもらえるものになってきているのかなと思います。

当初は自発的に書いてくれる人は少なかったのですが、ここ数ヶ月は自ら書いてくれるという人も出始めてとても良い流れになってきているなと感じているのでアウトプット文化の醸成とともに、候補者の方への情報提供をより高い解像度で行うことができるものになっていければなと思っております。

今回のアドベントカレンダーでもテックブログに書いてくれる人が増えてきており、それもまた良き流れかなと思うので、今後はきちんとした運用体制を敷いてより活発に様々なアウトプットができる組織になっていければ良いなと考えております。

コミュニティ活動

採用活動を行う上である一定の認知や露出はやはり大事だし、個人のキャリアとしても社外のエンジニアとの繋がりは大事だよねということで、今まであまりしてこなかったコミュニティ活動にもチャレンジしようと決心しました。

するとグッドタイミングで昨年の12月にNRUG SRE支部が立ち上がり、そこの運営メンバーとして参加してイベントの運営および登壇を行ってきました。

NRUG SRE支部運営に参加した経緯や初回イベントの感想や関連リンクがあるので興味のある方は以下の記事を見てみてください。 tech.trustbank.co.jp

この初回のイベントは運営3名ががっつり登壇するという内容だったのですが、イベント終了後から今まで無風状態に近かったスカウトに返信が来るようになったり、カジュアル面談を実施できることも増え、さらにはイベントを見てくれた方が興味を持って話しを聞きに来てくれたりして「これが、登壇の、効果か・・・!」と感動したことをつい昨日の事のように覚えています。

個人としてもこのコミュニティ活動を通じて様々な方とつながることもできてエンジニアの友人が少ない手前にとって、勇気を出して運営メンバーに立候補してユーザーグループの運営に携わることができたことは本当に良い決断、経験だったなと思っています。

「感謝するぜ、お前と出会えたこれまでの全てに」というビヨンド・ネテロの気持ちが今ならとてもわかります。 ハンターハンターを再開してくれた富樫大先生には感謝しかありません。

登壇

コミュニティ活動だけではなく、個人のキャリアへの影響はもちろん、トラストバンクのエンジニア組織のプレゼンス向上および採用活動に繋げるべく今年はイベントやカンファレンスへの登壇にもチャレンジしてみました!

コミュニティ活動も本当にやってみて良かったなと思いましたが、このイベントやカンファレンスへの登壇も資料の準備などはとても大変でしたが本当にやってみて良かったなと思いました。

来年以降も外部へ発表できるように様々な改善活動を進めていき、色々な場所で発表をしていければなと思います!

JAWSDAYS2022への登壇

まずJAWSDAYS2022への登壇にチャレンジしました。

jawsdays2022.jaws-ug.jp

JAWSDAYSはJAWS-UG最大のイベントで毎年イベントへの参加はしていましたが、いつか登壇してみたいなぁと思っていたイベントの一つでもありました。

jaws-ug.jp

今回のJAWSDAYS登壇による採用的な狙いとしては「SRE採用におけるペルソナへの情報提供」でした。

弊社は現在ふるさとチョイスをはじめとしたサービスインフラのAWS移行を進めています。 移行後もEC2ベースのVM基盤となっていますが、TerraformやAnsible、Packerを活用してフルIaCでこの移行プロジェクトを進めているということもありSRE採用のペルソナとして「EC2環境の改善が進まなかったり、IaCの導入がうまくいかなかったりしてもやもやしている」みたいな部分も要素として盛り込んでいました。

そのため現職での改善活動やIaC導入などのチャレンジが組織的な課題等でうまく進められないことに対して自身のキャリアに課題を感じて転職を検討している人に対して、トラストバンクでは現在このようなフェーズで、移行に関してはこのように進めているというのを届けて弊社へ興味を持ってもらえるようにするために具体的な事例として発表させてもらいました。

JAWSDAYSという特性上、AWSを利用しているそのようなペルソナのSREへ情報を届けるにはうってつけのイベントだったのでそういった内容で登壇にチャレンジしてみたのですが、その後のカジュアル面談等でこの発表の内容について話を聞きたいということも何度か有り、採用に関しての狙いとしては十分以上に達成できたのかなと思いました。

参加の経緯や発表内容などもレポートとして記事にまとめていますので是非見てみてください。

tech.trustbank.co.jp

Cloud Native Days Tokyo 2022

またJAWSDAYSから間を開けずにCloud Native Days Tokyo 2022への登壇にもチャレンジをしてみました。

event.cloudnativedays.jp

このカンファレンスも毎年参加はしていたものの、いつか登壇したいと思っていたカンファレンスです。

event.cloudnativedays.jp

これまでの外部発信での内容では主に既存環境の移行や改善活動に伴う内容が主で、新しいチャレンジや新サービス、システムのアーキテクチャについてあまり発信出来ていなかったのでCfP提出を行い、無事採択され発表することができました。

既存サービスのAWS移行に伴い、AWSの本格利用が進み、新しいサービス、システムは基本的にすべてAWSを利用し、コンテナ基盤としてはECSを利用しています。

新サービス、システムへのAWS利用は今年から本格的にスタートし、多くの新規サービス、システムの新規構築に加え、複数の既存サービスのAWS移行も進めながら普段の運用業務も行っていたので、中々大変だったなぁと振り返りながら思い出してきましたw

今年新規構築したサービスについては後半に軽く触れていきたいと思います。

そんなこんなで新規のサービス、システムなどはPHP8系にLaravel9を基本としてコンテナ基盤としてはECSを採用するなどCloudNativeな開発もされるようになってきたので、そういうこともやれるようになってきましたYO、ということを伝えるべく今回のCloud Native Days Tokyo 2022での発表にチャレンジしましたが、こちらもとてもポジティブな反応を頂けてチャレンジして良かったなと思います。

参加レポート書き忘れていたので発表資料を置いておきます。

speakerdeck.com

改善活動

今年も色々な改善活動をしてきました。

AWS移行

ふるさとチョイスだけではなく、SREチームの全社化に伴い、地域通貨事業であるchiicaのAWS移行も行うことになりました。

chiica.jp

ふるさとチョイスでのIaC資産を活用し、スピーディーに開発環境の移行を行うことができ、ローカル開発環境も整備し、ステージング環境も新設しました。

LPのCloudflarePages移行

弊社には数多くのLPが存在するのですが、静的サイトにも関わらず、サーバーを用意してホスティングするという運用がスタンダードだった部分を自分が入社してからはCloudflarePagesをスタンダードにするように推進してきました。

www.cloudflare.com

すでに独立したLPや新規のLPについてはCloudflarePagesの活用を進めていたのですが、ふるさとチョイスに組み込まれているLPがあり、開発が活発なプロダクトにイベントのLPが組み込まれていることで外部の制作会社を利用しづらかったり、リリースもふるさとチョイス本体同様の開発フローで進める必要があるためスピーディーに変更できなかったりと様々な問題があったサイトもついにCloudflarePagesへ移行することができました。

award.furusato-tax.jp

New Relicの本格導入

自分が入社した時点では一部のサーバーにAPMが導入されているぐらいでまったく活用は出来ていなかった状況だったのですが、監視運用の内製化、DevOpsの実装に伴い、New Relicをo11y基盤として活用すべく様々な取り組みを行いました。

詳細については明日公開予定のNew Relicアドベントカレンダー24日目の記事を御覧ください。

qiita.com

新規サービスリリース

以下、今年新規に構築を行い(or している)、リリースが公表されているサービスたちです。 実際にはもっと数多くのシステムを今年に入ってから構築、リリースしています。 ものによっては外部の開発リソースを利用したりしていますが、インフラやCI/CDの実装周り、全体のアーキテクチャ設計は基本的にSREが行っています。

OEM API

www.trustbank.co.jp

www.trustbank.co.jp

www.trustbank.co.jp

このOEM APIの実装まわりについてもアドベントカレンダーで記事が上がっているので是非見てみてください! tech.trustbank.co.jp

読むふるさとチョイス

yomu.furusato-tax.jp

www.trustbank.co.jp

こちらについては以前テックブログに記事を書きました。

tech.trustbank.co.jp

めいぶつチョイス

www.trustbank.co.jp

おわりに

まだまだ色々とやったことや振り返りたいことはあるのですが、まだNew Relicアドベントカレンダーの記事も書けていないので一旦ここまでにしたいと思いますw

改めて振り返ってみると今年は個人としても本当に飛躍できた年と なりました。 きっかけとしてはやはりコミュニティ活動や外部登壇などのアウトプットなのかなと思いますが、前提としてしっかりと日々エンジニアリングに向き合い、インプットを継続し、その上でアウトプットするというバランスとサイクルが大事なんだなと感じました。

コミュニティ活動や色々なイベントでの様々な事例を見ると自分のエンジニアとしての実力、経験はまだまだだなと痛感する日々ですが、まわりと比べすぎて自分を下げることはせず、しっかりインプット、アウトプットを継続して来年以降も個人としてもチームとしても様々なことにチャレンジし、エンジニアリングを楽しみ、事業に貢献をしていくことができる自分、チーム、開発組織にしていきたいなと思っていますのでよろしくお願いします!

そんなトラストバンクではSREをはじめとした様々な職種で絶賛採用中なので気になった方は是非お気軽にご連絡くださいー!

www.wantedly.com

3人目のSREとしてジョインして半年やってきた話

こちらは、トラストバンク Advent Calendar 2022の17日目の記事です。

入社エントリーを書こう書こうと思い、気がついたらすでに半年経っていましたiwateaです。
しかしふるさと納税も12/31日までにやればセーフなので、弊社的にセーフでしょう、うん!

iwateaってどんな人?

バックエンドをメインにWebシステムを作ってきた人です。
エンジニア歴も15年ということで、昔ながらの開発会社でJavaPHPの請負開発から、スタートアップでRailsやGoの自社サービスの開発等々、振り返ると時代の流れをなぞってきたなぁという経験をしてきました。

そんな中でもスタートアップに在籍していた期間では何でもやらなきゃいけなかった事もあり、 AnsibleやTerraformを使ってのインフラ構築や、エンジニアマネージャー業もやってきたのですが、 このあたりのスキルセットが今のトラストバンクのSREにフィットしたことを考えると色々手を出してみるもんだなと思ってます。

若干自分の得意分野には迷走してますが、チームに1人いると便利なタイプであると割り切って生きてます。

入社の経緯

トラストバンクでは絶賛組織の改革を行っていますが、それに伴い @Tocyuki がSREチームの立ち上げをすることになりました。

@Tocyukiとは前職が同じでやけに意気投合していた仲でありつつも、結局同じプロジェクト働くことができなかったのですが、 SRE立ち上げの話を聞いて、いっちょやってみっか!という事でジョインさせて頂きました。

これまでSREチームには @Tocyuki @butadoraという運用のバックボーンを持っているメンバーがいる一方で、アプリケーション開発のバックボーンを持っているメンバーがいませんでした。
そこで両方に知見がある私が、お互いの橋渡しをしてDevOpsを体現できる開発組織を作っていくのが野望です。

実際に入社してやったこと

DevOpsの第一歩

弊社が運営しているふるさとチョイスは急成長した一方で、整備が間に合っていない箇所も多くあります。

例えば入社当時はエラーがでてもエラーログを見る以外なく、また、ログを見るにもサーバーにsshしてログファイルを見る必要があり、Linuxに慣れていないメンバーは忌避感がある状態でした。
しかし、現在はNewRelicを導入して布教したことで、APMを見てみよう・ログもNewRelicで見ようという文化が定着しつつあり、エラー調査のハードルが少しずつ下がりつつあります。

また、弊社のふるさと納税事業は12月が最もアクセスが多く、夜間・休日も監視体制を作っているのですが、アプリケーション開発のメンバーが程よくメトリクスを見れるものがなく、目隠しで待機をする状態になっていました。
そこでどちらの事情も分かる私がWebシステムとして「最低限このあたりが見れれば異常が起きてるかどうかは検知できるよね」なダッシュボードを用意することでOpsを身近に感じてもらえるように整備をしました。

※CPUやメモリ使用率等のアプリケーション起因で動きやすいメトリクスも入ってますよ!

DevOpsの第二歩

一般的なエンジニア組織では、インフラ担当者がいるとアプリケーション開発者はインフラについて知識を得る機会があまりありません。
そこで毎週SREチームと開発チームでカジュアルに会話できる勉強会を開催するようにしました。

内容は何でもありで、標準的に使ってるgossm等のツールの使い方だったり、 ネットワークってどんなもの?新しく機能を作る時どういう設計にしたらいい?等々、 普段聞けなかったことから、個人的にやっている技術習得の疑問点なんかも話したりしています。

普通に新規インフラ構築もする

一方で新規システムの立ち上げも複数走っており、ECSやGithubAction等を使ったモダンな環境を1から構築をすることもありました。
といってもこのあたりはこれまでの会社でやってきたことでもあるので、ちょちょいのチョイタ君といった感じです。

また、当然稼働中のシステムの保守も必要で、スケールアップやディスクの拡張等々、Theインフラな作業もしています。
幸い協力会社様との体制ができていて、自分では分からない事はフォローして頂けているのでなんとかなっています。

これから何をする?

AWS移行というビッグイベントや、Toilの自動化などやる事はまだまだ山積みです。
実はこの半年間はキャッチアップや急ぎの作業に追われ、改善作業に着手できるようになってきたのもこの1ヶ月くらいという状況でした。

若干人手不足気味ですが、1月に4人目のSREの入社が決まっているので、徐々に攻めのSREチームになっていける予感がしています。

おわりに

入社当時は「フルリモートって人間関係の構築難しいんだよなぁぷるぷる」と捨てられた子猫のように震えてましたが、 SREチームは常時Gatherにいる&月1回くらいは出社して直接会ってうまい飯を食う(本題)という新人に優しい環境だったので、すっかりふてぶてしい家猫のようになりました。

これから大晦日にかけて最も緊張感のある時期が続きますが、事件が起きないようにして平穏な正月を向かえられるよう全力を尽くしたいと思います。

最後に入社以来ずっと残っていたissue君、この記事を持ってやっと君をクローズできるよ。
サンキュー、GitHub Project。

エンジニア募集

弊社ではSREを絶賛募集中です。 興味がある方はぜひ一度お話しましょう!

www.wantedly.com

ふるさとチョイスを取り巻くインフラ環境2022

この記事は、トラストバンク Advent Calendar 2022の16日目です。

トラフィックの増加から年末を感じているSREのbutadoraです。

実は昨年のアドベントカレンダーでほぼ同じタイトルの記事を書きましたが、 今年からテックブログの運用がスタートしたということもあるので、 再掲しつつ、変化したデプロイフローについて書いていこうと思います。

サービス構成図

大分抽象化してますが、ふるさとチョイスのサービスはこのようになっています。

  • 主要サービスはさくらのクラウド、さくらの専用サーバで運用しています
  • 一部閉域網内向けのサービスでラックサーバをさくらのハウジングサービスで利用
  • 一部サービスは今年からCloudflareやAWS上で運用しています
  • 入り口はすべてCloudflare経由となっており、CDNとWAFを兼ねている
  • WEBサーバやDBサーバだけでなく、LBもLinuxサーバで実装

ふるさとチョイス

https://www.furusato-tax.jp/

  • ふるさとチョイスのメインサービスである、ふるさと納税総合サイト
  • 寄付することができる自治体や、自治体が提供するお礼の品を横断的に検索できるサービス
  • 今年新規開発された社内システム等、一部環境はECSで構築されています。

ふるさとチョイスCMS

  • 自治体様がふるさとチョイスへ掲出するお礼の品や寄付情報を管理するためのサービス
  • インターネット経由で利用できない自治体様向けに、総合行政ネットワーク(LGWAN1網内でも提供しています

OEMサイト

  • ふるさとチョイスに掲載されたお礼の品情報をベースにしたふるさとチョイスのOEMサイト
  • WEBサイトの形で法人向けサービスとして提供しています

OEM API

  • ふるさとチョイスに掲載されたお礼の品情報をベースにしたOEM APIサービス
  • 今年リリースされた新しいサービスでECSで構築されています。 www.trustbank.co.jp

読むふるさとチョイス

https://yomu.furusato-tax.jp/

特設サイト

  • 例年開催している、「ふるさとチョイス大感謝祭」や「ふるさとチョイスAWARD」の特設サイト daikanshasai.furusato-tax.jp award.furusato-tax.jp
  • 今年からCloudflare Pagesを採用して、サーバレスと構成なっています🎉
    • Cloudflare Zero Trustで認証機能をつけることができたり、PR毎のプレビュー環境機能があったりとすごく便利です
  • 当初はその他社内ツールで使い始めたCloudflare Pagesでしたが、上記特設ページ以外にもいくつかの静的ページでの利用が進んでいます

トラフィック特性

CDN

Cloudflareで受けている、月ごとのトラフィック総量をグラフにしてみました。

一年の上半期とくらべると、12月は5倍ほどのトラフィック量となっており、 やはり年末である12月が1年でトラフィックが最も増えるシーズンとなっています。

WEB

CDNの後ろにいるWEBサーバのアクセス数はこんな感じです。

具体的な数字はお見せできないですが、11月頃から徐々に増えはじめ、 世間的に年末休みとなる頃から大晦日にかけて爆増する様子です。

今年のおもしろ特性

通常の平日深夜帯は下図のとおりアクセス数は指数的に減少していく傾向となっています。

平日深夜帯のアクセス数

しかし、2022/12/6(火)の深夜帯はこのように3:00頃までアクセス数に波があり、その後線形的に減少していくといった傾向となっていました。

2022/12/6(火)深夜帯のアクセス数

お気づきの方もいるかもしれませんが、この日はサッカーW杯のクロアチア戦が0:00キックオフで皆さん夜ふかししていたようです⚽️

1:00前後でアクセスが急上昇しているのは、ABEMAでW杯配信のアクセス制限がかかったタイミングが影響していたりするのかもしれないです。

www.itmedia.co.jp

マシンスペック

こんなトラフィック特性のトラストバンクの年末をどのようなマシン構成で迎えるか? とあるアプリケーションサーバのスペックは...

\ドン!!/

とあるDBのスペックは... \ドドン!!/

といった具合に各サーバ専有ホストで、 * アプリケーションサーバ: 10core、24GBのマシン数十台 * DBサーバ: 20core、224GB(最大スペック)のマシンを数十台 を並べて12月の大規模トラフィックを待ち受けています💪

デプロイフロー

ここでは、ふるさとチョイス、ふるさとチョイスCMSのデプロイフローを紹介します。 コード管理にはGithub Enterprise Serverを利用しており、デプロイツールにはJenkinsを利用しています。

PR作成をトリガーとした自動デプロイのフローと、JenkinsのWeb UI上から起動する手動デプロイがあります。

下図のようにパラメータ指定で環境、ブランチ、AWS or さくらを指定できる事ができるようになっています。

AWSへのデプロイフローは昨年時点で完成していましたが、さくら環境へののデプロイフローは今年追加したので後述します。

開発・ステージング環境

弊社の開発・ステージング環境はAWSで構築しており、大きくtest/staging環境とfeature環境と呼ぶ2つがあります。

  • feature環境
  • test/staging環境
    • JenkinsのUI操作でデプロイができ、特定のドメインが割り当てられている環境(上図緑線)
    • test環境に関しては、ドメインに依存する検証などのfeature環境で実施できない検証を行う用途で利用します
    • staging環境は、本番相当のデータを用いた検証やを実施するような目的で利用されています

本番・LGWAN環境

各サーバへのデプロイにはlsyncが使われており、親となるmasterサーバにリソースが配置されると各サーバに配布される仕組みとなっています。

仕組みが昨年まではmasterサーバ上でgit pullすることでの更新となっていましたが、 前述の通り、今年はtest/staging環境同様にJenkinsのUI上からデプロイすることができるようにしました🎉

masterサーバをオンプレミスインスタンスとしてCodeDeployへ登録し、 デプロイグループを追加することで同一のアプリケーションでAWS/さくら両環境へのデプロイを可能としています。

工夫点として、appspec.ymlのBefore/AfterInstallで各グループで違う処理をさせたかったため、 以下のようなbashの変数展開を使って条件分岐させています。

if [ ! ${DEPLOYMENT_GROUP_NAME%%*-for-sakura} ]; then
  # デプロイメントグループ名が-for-sakuraである時に実行
else
  # デプロイメントグループ名が-for-sakuraでない時に実行
fi

あとがき

思いの外ふるさとチョイスに関連するインフラ環境のアップデートが多く、1年を振り返る良い機会になったと共に、 徐々にインフラ環境が改善されていっていることを改めて実感しました。

昨年の転載で手抜きできるかなと思って書き始めたとは言えない

来年もCloudflareやAWSを活用したアップデートをお届けできるように精進していきます💪

宣伝

AdventCalendar2022開催中!

この記事もですが、

qiita.com

明日は今年JoinしたSREのiwateaがなにか書いてくれる予定ですのでお楽しみに🥳

エンジニア募集

弊社ではSREを絶賛募集中です。 興味がある方はぜひ一度お話しましょう!

www.wantedly.com


  1. Local Government Wide Area Networの略。参考: J-LIS LGWAN について

GASでGoogle Analytics 4のAPIを叩いてリアルタイムなユーザー数を監視する

この記事はトラストバンク Advent Calender 2022の14日目の記事です。

久しぶりの投稿になってしまいました/(^o^)\
どうも、トラストバンクのフロントエンドエンジニア、田口です。
今年一番嬉しかったことは先日の某ゲームの約10年ぶりの新作発表です。トレーラー見て気を失いかけました。

昨日のAdvent Calender 2022の記事は飯島さんによるレンダリングエンジンとJSエンジンについてでした。

qiita.com

フロントエンドエンジニアとしてブラウザ自体を知ることはとても大事ですよね。
私も数年前にChromiumのソースを読むためにC++を学習しようかと考えたときがありましたが、当時は結局手をつけなかったです。改めてやってみようかな…

さて今回は、数ヶ月前にGA4のAPIをGASで叩いてリアルタイムユーザー数を継続監視する仕組みを作ったので、そのレポートです。
GA4のAPIはまだベータ版という扱いで、知見がインターネッツにもあまり転がっていなさそうなので(最近増えてきていると思いますが)、誰かの助けになればと思っています。

前提:Google Analytics 4(GA4)とは?

簡単にいうとGA4とは、Google Analyticsの最新メジャーバージョンです。
これまで広く使われてきたGoogle Analyticsはユニバーサルアナリティクス(UA)と呼ばれており、UAは2023年7月にほぼ廃止となります。*1
その後継がGA4です。

GA4への切り替えにともなって、APIの方も新しくなりました。
GA4のAPIは「Analytics Data API」という名前になっており、現在v1のアルファ版とベータ版が利用できます。

developers.google.com

アルファ版は正式版が出た時に変更される可能性があるそうなので、基本的にはベータ版を使うのが良いかと思います。
なおこの記事ではGA4自体を使い始める手順等には触れません。

GA4 APIをGASで実行する

ということでAnalytics Data API(以降"GA4 API"とします)を使ってユーザー数の取得などをやってみましょう。
簡単なものであればGASのWebエディタで十分です(むしろ便利まである)

GASでAnalytics Data APIを有効にする

GASでGA4 APIを使用するには、UA用のAPIと同じく、GAS上でAPIを有効化する必要があります。
左の「サービス」の+ボタンをクリックします。

すると以下のような画面が出てきますが、似たような名前のAPIがいくつかあるので注意が必要です。
Analytics Reporting API とか Google Analytics API とか Google Analytics Admin API とか……
Google Analytics APIUA用のAPIですので、使用自体はできますが、廃止されてしまうので今後は使用しないでしょう。
Analytics Reporting APIとかは何かに使えそうですが、今回は触れずにいきます。

Google Analytics Data APIを選択

インテリセンスも機能するようになり、無事APIが使用できるようになりました。

Google関連サービスのAPIを簡単に叩くのにGASが便利な点ってやっぱりこういうところですよね…。

GA4 APIを実行してみる

まずはとりあえずGA4 APIを実行してみましょう。
先ほど追加した「サービス」の"AnalyticsData"からドキュメントを開くとサンプルコードがある*2ので、それをベースに、より簡素にしたコードを作りました。

function myFunction() {
  // GAのプロパティを取得しておく
  const props = PropertiesService.getScriptProperties();
  const gaProp = `properties/${props.getProperty('GA_PROPERTY')}`
  
  // Metric(指標)を指定する --- ①
  const metric = AnalyticsData.newMetric();
  metric.name = 'activeUsers';

  // 取得する期間を指定する --- ②
  const dateRange = AnalyticsData.newDateRange();
  dateRange.startDate = '2022-12-01';
  dateRange.endDate = '2022-12-02';

  // GA4へのリクエスト内容(Metricや期間)を指定する --- ③
  const request = AnalyticsData.newRunReportRequest();
  request.metrics = [metric];
  request.dateRanges = dateRange;

  // APIを実行 --- ④
  const report = AnalyticsData.Properties.runReport(request, gaProp);

  // 雑に出力 
  console.log(report.rows[0].metricValues[0]);
}

↑の出力は ↓こうなりました。値は例です(念の為)

{ value: '999999' }

GA4 APIは基本的に①と②のように指標等を決めて、③のようにリクエストをまとめ、それを引数にして④のようにAPIを実行するという手順になります。
newMetric(), newDateRange()は通常のレポート実行だけでなく、リアルタイムレポートの方でも使用でき、またあえてMetricインスタンスやDateRangeインスタンスを作らなくても、

  const report = AnalyticsData.Properties.runReport({
    "metrics": [{"name": "activeUsers"}],
    "dateRanges": [{"startDate": "2022-12-01", "endDate": "2022-12-02"}]
  }, gaProp)

と、オブジェクト形式の書き方でも問題なく取得が可能です。

なおrunReport()においては"metrics""dateRanges"は必須です。 "dimensions"なども使用することで、色々なレポートの作成ができそうです。
runReport()の詳細については以下を参考にしてください。 developers.google.com

リアルタイムレポートを取得する

ここからようやくタイトル回収のリアルタイムレポート編です。とはいえ通常のレポート出力ができていれば特に問題はないでしょう。

function realtime() {
  // GAのプロパティを取得しておく 
  const props = PropertiesService.getScriptProperties();
  const gaProp = `properties/${props.getProperty('GA_PROPERTY')}`
  
  // Metric(指標)を指定する
  const metric = AnalyticsData.newMetric();
  metric.name = 'activeUsers';

  // GA4へのリクエスト内容(Metric)を指定する
  const request = AnalyticsData.newRunReportRequest();
  request.metrics = [metric];

  // APIを実行
  const report = AnalyticsData.Properties.runRealtimeReport(request, gaProp);

  // 雑に出力 
  console.log(report.rows[0].metricValues[0]);
}

↑の出力は ↓こうなりました。値は例です(再び)

{ value: '99999' }

runRealtimeReport()の場合はmetricsの指定だけでOKです。
リアルタイムレポートの場合、GA4自体の仕様の都合上、過去30分間のレポートになります。この縛りはAPIでも同様になりますので、同じようなレポートの取得をしても、UAとは値が大きく変わる可能性があります。
実際、弊社のふるさとチョイスではUAでのレポートよりかなり大きな値になりました。
runRealtimeReport()の詳細は以下からどうぞ(説明をブン投げるな)

developers.google.com

ちなみに取得できるMetricsは以下から見ることができます。

developers.google.com

が、こちらのページを日本語で見ると固有名であるはずのAPI Name(一列目)も訳されてしまうので、Englishに設定して読みましょう。

日本語
English

claspを使った際の注意点

私は今回GA4 APIを利用するにあたって、claspを使用してVSCode上でTypeScriptで開発を行いました。
claspとはGASをローカル等の環境で開発するためのNode.jsパッケージなのですが、これを使うことでTypeScriptを使用したGASの開発が可能になります。
Sheets APIなどのライブラリも用意されているので、APIのメソッドを実行する場合でも型安全に開発することができます。
しかしAnalytics Data APIはまだclasp用のライブラリが存在せず、この部分だけはTypeScriptの恩恵が受けられません。仕方ないので一部のみを自作でinterfaceとして定義しました。

// なんか…こういうの……
interface Response {
  "dimensionHeaders": object,
  "metricHeaders": object,
  "rows": Array<Row>,
  "totals": Array<object>,
  "maximums": Array<object>,
  "minimums": Array<object>,
  "rowCount": number,
  "propertyQuota": object,
  "kind": string
}
interface Row {
  "dimensionValues": Array<object>,
  "metricValues": Array<MetricValue>
}
interface MetricValue {
  "value": string
}

はてなブログスニペットってtypescriptもいけるんですね)
かなり少ないコード量ですので定義したところで…というところではありますが、今後もし大きくなってきたときに役に立つはずです。きっと。多分。
claspに関してはまた別の記事で書くかもしれません。

まとめ

GASでのGA4 APIの使い方としてはこれまで書いたように、かなり簡単に扱うことができます。
GA4 APIで取得した値は、例えばWebhookでSlackに流したり、Sheets APIを使ってスプレッドシートに流し込むことで、分析や急激なユーザー数増減のアラートとしても活用することができます。
UAからGA4の移行でAPIも変わり、Google Analyticsの利用は大きく変わりましたが、GA4でも積極的にデータを活用していきましょう。

ということでいつもの

トラストバンクではフロントエンドをはじめとするエンジニア各職を募集しております!
www.wantedly.com (久しぶりに覗いたらめっちゃ自分の写真乗っててちょっと恥ずかしい)

LaravelにおけるクリーンアーキテクチャなAPI実装の解説

はじめに

この記事は、トラストバンク Advent Calendar 2022の12日目です。

こんにちは、今年サーバーサイドエンジニア2年目になった石川です。

本記事では、現在私が社内で開発に携わっているAPIアーキテクチャに関する解説記事となっています。

APIアーキテクチャ

では早速APIアーキテクチャについてご紹介したいと思います。

まず、本APIアーキテクチャには、クリーンアーキテクチャが採用されています。

こちらはAPIチームのテックリードによってAPIの設計/実装に落とし込まれており、 私も日々お世話になっているアーキテクチャなわけですが、 現状、外向けの解説記事があまりなかったため本記事にて解説したいと思います。

※実際のAPIの内容については触れませんのでご了承ください

下記、テックリードの記事になります。

Laravelでクリーンアーキテクチャ (ディレクトリ構造編) - トラストバンクテックブログ

DDDの実務への落とし込み(with PHP) - トラストバンクテックブログ

ディレクトリ構成

まず、下図がAPIディレクトリ構成とクリーンアーキテクチャとの関係図になります。

※今回はサンプルで返礼品情報を取得するAPIを作成します

解説前にAPIの挙動イメージの説明

今回は、返礼品のIDから返礼品の名前と金額を取得するAPIを例に作成します。 まずはこちらの挙動のイメージを記載いたします。

パスパラメータに返礼品IDを指定し、GETリクエストを実行します。

GET /products/{product_id}

レスポンスとして、返礼品ID、返礼品名、金額を返します。

{
  "id": 123456,
  "name": "トラストバンク市ふるさと納税の返礼品",
  "amount": 10000,
}

上記のようなAPI機能について、ここからは実装例も交えて解説していきたいと思います。

実装例

では実際に、Controllerから実装を追っていきたいと思います。

Controller App\Http\Controllers\Product\Api\ProductsController

まず、APIが叩かれると、Controllerが呼び出されます。(今回はMiddlewareの処理等は省きます)

呼び出されたControllerは下記のように振る舞います。

  1. リクエスト値をもとにしたInputの生成

  2. InputをもとにしたInteractorの実行

  3. Outputのレスポンス整形

<?php
namespace App\Http\Controllers\Product\Api;

use App\Http\Controllers\Controller;
use App\Http\Requests\Products\GetRequest;
use App\Contexts\Product\Infrastructure\Presenter\CreateResponse;
use App\Contexts\Product\UseCase\Get\Interactor;
use App\Contexts\Product\UseCase\Get\Input;
use Illuminate\Http\JsonResponse;

class ProductsController extends Controller
{
    /**
     * @param Interactor $interactor
     * @param GetRequest $request
     * @return CreateResponse|JsonResponse
     */
    public function get(Interactor $interactor, GetRequest $request): CreateResponse|JsonResponse
    {
        $input = Input::fromArray($request->validated());
        $output = $interactor->execute($input);
        return new CreateResponse($output);
    }
}

Input App\Contexts\Product\UseCase\Get\Input

Controllerから呼び出されたInputはリクエスト値からValueObjectを生成します。

今回の場合は、パスパラメータで指定されたproduct_id(返礼品ID)からProductIdというValueObjectを生成し、Inputにもたせています。

Inputでは、ユーザからの入力値がValueObjectにマッチしているかバリデーションを行うことで、入力値不正によるビジネスロジックでのエラーを未然に防止します。 (なお、ValueObjectのバリデーションの前に、LaravelのFormRequestを用いてもバリデーションを行っています。)

InputValidatorSeasalt1の機能で、リクエスト値がValueObjectにマッチしているかバリデーションを行ってくれます

<?php
declare(strict_types=1);

namespace App\Contexts\Product\UseCase\Get;

use App\CoreDomain\Value;
use Seasalt\Nicoca\Components\UseCase\InputValidator;

/**
 * 入力パラメータ
 *
 * @see Interactor
 */
final class Input
{
    public function __construct(private Value\Product\ProductId $productId)
    {

    }

    /**
     * @return Value\Product\ProductId
     */
    public function getProductId(): Value\Product\ProductId
    {
        return $this->productId;
    }

    /**
     * Requestからインスタンスを作成する
     *
     * @param array<string, string> $input
     * @return static
     */
    public static function fromArray(array $input): self
    {
        $validator = new InputValidator(
            requiredFields: [
                'product_id' => Value\Product\ProductId::class,
            ]
        );
        $validator->validate($input);

        return new self(
            Value\Product\ProductId::fromNumber((int)$input['product_id']),
        );
    }
}

Interactor App\Contexts\Product\UseCase\Get\Interactor

次に、Interactorでは、先程生成したInputをもとにProductエンティティの復元などビジネスロジックの処理を実行していきます。

なお、このInteractorのコンストラクタでは、この後にEntityを復元する際に用いられる、ProductRepositoryを記載していますが、このProductRepositoryはInterfaceとなっており、実際にはこのInterfaceを実装したProductRepositoryImplがLaravelのサービスコンテナとして注入されています。(リポジトリとは、データの永続化を担うコンポーネントのことです)

ここでは、Productエンティティに対してProductRepositoryというInterfaceのみを渡すことで、ビジネスロジックと外部に依存する実装を明確に分離しています。

これによりアプリケーションのテストや移植が容易になるとともに、データストアの切り替え等も容易になります。

<?php
declare(strict_types=1);

namespace App\Contexts\Product\UseCase\Get;

use App\Contexts\Product\Domain\Entity\Product;
use App\Contexts\Product\Domain\Persistence\ProductRepository;

final class Interactor
{
    public function __construct(private ProductRepository $productRepository) {

    }

    public function execute(Input $input): Output
    {
        $product = Product::restore($input->getProductId(), $this->productRepository);
        return new Output($product);

    }
}

Entity App\Contexts\Product\Domain\Entity\Product

では、引き続きEntityの説明になります。

こちらではInputの内容をもとに、リポジトリからProductエンティティの復元を行います。

Entityとは、ざっくり説明すると特定のContextにおいて、ビジネスロジックを表現するためのオブジェクトであり、 (あくまで本APIの実装では)複数のValueObjectとそれらを操作するメソッドによって構築されています。

もっとざっくり説明すると、ValueObjectが意味のあるまとまりになっているのがEntityなんだな〜程度の認識でも一旦大丈夫です。

また、本APIでは、エンティティで使用するValueObjectをApp\CoreDomain\Value配下で管理しており、Contextに依存せず、横断的にValueObjectを使用できるようにしています。

なお、ProductRepositoryを継承したProductRepositoryImplの具体的な実装の説明については、今回のアーキテクチャの話からはそれますので割愛いたしますが、基本的にLaravelのEloquentを用いて実装されています。

<?php
declare(strict_types=1);

namespace App\Contexts\Product\Domain\Entity;

use App\CoreDomain\Value;
use App\Contexts\Product\Domain\Persistence\ProductRepository;
use App\Contexts\Product\Domain\Persistence\ProductRepositoryRecord;

final class Product
{
    private function __construct(
        private Value\Product\ProductId $id,
        private Value\Product\ProductName $productName,
        private Value\Product\Amount $amount
    )
    {

    }

    /**
     * @return Value\Product\ProductId
     */
    public function getId(): Value\Product\ProductId
    {
        return $this->id;
    }

    /**
     * @return Value\Product\ProductName
     */
    public function getProductName(): Value\Product\ProductName
    {
        return $this->productName;
    }

    /**
     * @return Value\Product\Amount
     */
    public function getAmount(): Value\Product\Amount
    {
        return $this->amount;
    }

    /**
     * 永続化されたエンティティを復元する
     * @param  Value\Product\ProductId  $productId
     * @param  ProductRepository  $repository
     * @return static|null
     */
    public static function restore(
        Value\Product\ProductId $productId,
        ProductRepository $repository,
    ): self|null
    {
        $record = $repository->restore($productId);
        return self::restoreFromRecord($record);
    }

    /**
     * @param  ProductRepositoryRecord  $record
     * @return static
     */
    private static function restoreFromRecord(ProductRepositoryRecord $record): self
    {
        return new self(
            id: $record->getId(),
            productName: $record->getProductName(),
            amount: $record->getAmount(),
        );

    }
}

Output App\Contexts\Product\UseCase\Get\Output

Outputでは、先程復元されたProductエンティティをInteractorから受け取ります。

ここでは、あくまで生成したProductエンティティを保持するだけで、実際のレスポンスの整形等は行いません。

上記の理由として、現在のAPIの仕様上では、レスポンス形式がAPIだけと決まっていますが、今後ウェブビュー等でもレスポンスを行いたいとなった場合に、Outputが特定のレスポンス形式に依存していると改修にコストがかかることが挙げられます。

<?php
declare(strict_types=1);

namespace App\Contexts\Product\UseCase\Get;

use App\Contexts\Product\Domain\Entity\Product;

/**
 * 出力結果
 *
 * @see Interactor
 */
final class Output
{
    public function __construct(private ?Product $product)
    {

    }

    /**
     * @return Product|null
     */
    public function getProduct(): ?Product
    {
        return $this->product;
    }
}

Response App\Contexts\Product\Infrastructure\Presenter\CreateResponse

最終的に、Outputからレスポンスを生成して返します。

こちらは冒頭のControllerで呼ばれています。

<?php

declare(strict_types=1);

namespace App\Contexts\Product\Infrastructure\Presenter;

use Illuminate\Http\JsonResponse;
use App\Contexts\Product\Domain\Entity\Product;

final class CreateResponse extends JsonResponse
{
    public function __construct($output)
    {
        /** @var $product Product */
        $product = $output->getProduct();
        $data = [
            'id'     => $product->getId()->getValue(),
            'name'   => $product->getProductName()->getValue(),
            'amount' => $product->getAmount()->getValue(),
        ];
        parent::__construct($data);
    }
}

と、全体の流れは以上になります。

ユニットテスト

ここからは、軽くAPIで実装しているユニットテストの例を記載していきたいと思います。

と、本題に入る前に少し前提を整理します。(Modelの定義やらなんやら)

まず、App\Models\Productを作成し、Factoryを持つよう記載しておきます。

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

/**
 * 返礼品
 */
class Product extends Model
{
    use HasFactory;
}

次にDatabase\Factories\ProductFactoryを作成します。

これによって、UnitテストからModels\Product::factory()するだけでデータが生成されます。

なお、$this->fakerは継承元のFactoryクラスにて宣言されているため、そのまま使用できます。 (fakerはよしなにダミーデータを生成してくれるLaravelのライブラリです)

<?php
declare(strict_types=1);

namespace Database\Factories;

use App\Models;
use Illuminate\Database\Eloquent\Factories\Factory;

final class ProductFactory extends Factory
{
    /**
     * @var string
     */
    protected $model = Models\Product::class;

    /**
     * @return array
     */
    public function definition(): array
    {
        return [
            'name' => '[返礼品名]' . $this->faker->title(),
            'amount' => 1000,
            'created' => '2022-12-12 00:00:00',
            'modified' => '2022-12-12 00:00:00',
        ];
    }
}

では実際のテストの実装に移りたいと思います。

Featureテスト Tests\Feature

Tests\Featureでは、APIのリクエストからレスポンスまでの一連の動作を担保します。

BusinessCheckTest Tests\Feature\Product\GetBusinessCheckTest

こちらのBusinessCheckTestでは、主に200 OK以外のリクエストパターンをカバーします。

今回のケースでは、返礼品が削除された場合の404エラーのチェックを行っています。

<?php

declare(strict_types=1);

namespace Tests\Feature\Product;

use App\Models;
use Illuminate\Support\Facades\Artisan;
use Tests\TestCase;

/**
 * API 例外のテスト
 */
final class GetBusinessCheckTest extends TestCase
{
    /**
     * 削除された返礼品の取得で404が返ってくること
     */
    public function testException1(): void
    {
        Models\Product::factory()->create([
            'id' => 2,
        ]);
        Models\Product::find(2)->delete();

        $this->json('GET', '/products/2'
        )->assertNotFound();
    }

    public function setUp(): void
    {
        parent::setUp();
        Artisan::call('migrate:refresh');
    }

    public function tearDown(): void
    {
        Artisan::call('migrate:refresh');
        \DB::connection()->disconnect();
        parent::tearDown();
    }
}

BusinessLogicTest Tests\Feature\Product\GetBusinessLogicTest

こちらのBusinessLogicTestでは、正常系のリクエストパターンをカバーします。

今回は、返礼品の情報を正しく取得できていることをテストしています。

<?php

declare(strict_types=1);

namespace Tests\Feature\Product;

use App\Models;
use Illuminate\Support\Facades\Artisan;
use Tests\TestCase;

/**
 * 取API 正常系のテスト
 */
final class GetBusinessLogicTest extends TestCase
{
    /**
     * 返礼品情報が取得できること
     */
    public function testPassing1(): void
    {
        Models\Product::factory()->create([
            'id'     => 1,
            'name'   => 'テストの返礼品名',
            'amount' => 1000,
        ]);
        $response = $this->json('GET', '/v2/products/1')
            ->assertOk()
            ->assertJsonStructure([
                'id',
                'name',
                "amount",
            ]);
        $product = json_decode($response->getContent(), true);
        $this->assertEquals(expected: 1, actual: $product['id']);
        $this->assertEquals(expected: 'テストの返礼品名', actual: $product['name']);
        $this->assertEquals(expected: 1000, actual: $product['amount']);
    }

    public function setUp(): void
    {
        parent::setUp();
        Artisan::call('migrate:refresh');
    }

    public function tearDown(): void
    {
        Artisan::call('migrate:refresh');
        \DB::connection()->disconnect();
        parent::tearDown();
    }
}

ValidationTest Tests\Feature\Product\GetValidationTest

GetValidationTestでは、LaravelのFormRequestが正しく動作しているかをチェックします。

今回は、リクエストのクエリパラメータが適切な数値でない場合のチェックを行っています。

なお、ここではFormRequestのバリデーションエラーとなりすぐにレスポンスを返すため、Modelでのデータの生成等は不要です。

<?php

declare(strict_types=1);

namespace Tests\Feature\Product;

use Tests\TestCase;

/**
 * FormRequestによるバリデーションのテスト
 */
final class GetValidationTest extends TestCase
{
    /**
     * 返礼品IDのバリデーション
     */
    public function testCanValidateProductId(): void
    {
        // 未指定エラー
        $this->getJson('/v2/products/')
            ->assertStatus(422)
            ->assertJsonValidationErrors(['product_id' => 'The product id field is required.']);

        // 下限エラー
        $this->getJson('/v2/products/0')
            ->assertStatus(422)
            ->assertJsonValidationErrors(['product_id' => 'The product id must be at least 1.']);

        // 上限エラー
        $this->getjson('/v2/products/1000000000')
            ->assertstatus(422)
            ->assertjsonvalidationerrors(['product_id' => 'The product id must not be greater than 999999999.']);

        // 英字含み
        $this->getjson('/v2/products/a1234')
            ->assertstatus(422)
            ->assertjsonvalidationerrors(['product_id' => 'The product id must be an integer.']);
    }
}

Unitテスト Tests\Unit

Unitテストでは、基本的にUsecase配下のモジュールのテストを行います。

Usecaseのテスト Tests\Unit\UseCase\Product\DetailTest

このテストでは、Usecase内の動作をチェックします。

基本的に、Featureテストと同様の内容に感となっていますが、 APIの場合には、ユースケースのOutputとAPIのレスポンスとの間にあまり差異がないため、 FeatureテストとUnitテストとの間の差異も余りありません。

<?php
declare(strict_types=1);

namespace Tests\Unit\UseCase\Product;

use App\Contexts\Product\Domain\Exception\ProductNotFoundException;
use App\Contexts\Product\UseCase\Get\Input;
use App\Contexts\Product\UseCase\Get\Interactor;
use App\Models;
use Illuminate\Support\Facades\Artisan;
use Tests\TestCase;

final class DetailTest extends TestCase
{
    /**
     * 返礼品情報を正しく取得できる
     */
    public function testPassing1()
    {
        Models\Product::factory()->create([
            'id'     => 1,
            'name'   => 'テストの返礼品名',
            'amount' => 1000,
        ]);
        $input = Input::fromArray(['product_id' => 1]);
        $interactor = resolve(Interactor::class);
        $output = $interactor->execute($input);
        $product = $output->getProduct();

        $this->assertEquals(expected: 1, actual: $product->getId()->getValue());
        $this->assertEquals(expected: 'テストの返礼品名', actual: $product->getName()->getValue());
        $this->assertEquals(expected: 1000, actual: $product->getAmount()->getValue());
    }

    /**
     * 返礼品情報が存在せずエラーとなる
     */
    public function testFailure1()
    {
        // 以降の処理で例外が発生することを検証
        $this->expectException(ProductNotFoundException::class);

        Models\Product::factory()->create([
            'id' => 2,
        ]);
        Models\Product::find(2)->delete();

        $input = Input::fromArray(['product_id' => 2]);
        $interactor = resolve(Interactor::class);
        $interactor->execute($input);
    }

    public function setUp(): void
    {
        parent::setUp();
        Artisan::call('migrate:refresh');
    }

    public function tearDown(): void
    {
        Artisan::call('migrate:refresh');
        \DB::connection()->disconnect();
        parent::tearDown();
    }
}

以上、かる〜くユニットテストのご紹介でした。

クリーンアーキテクチャで良かったこと

今回、初めてクリーンアーキテクチャを取り入れた開発に携わりましたが、 そのなかで、これはよかった!と思う点がいくつかあったため、 そちらについて書かせていただきたいと思います。

何故かここにあってはいけないはずの実装がある問題

まず、個人的な一番の嬉しさは、モジュールごとの役割がはっきりしているため、 「なぜこの実装がこんなところに書いてあるんだろう・・・」といったようなコードが、 比較的少ないといった点に感じています。

私自身まだ業務経験が浅いため、API以外のサービスのコードに触れる機会自体少ないのですが、 以前すこし、API以外のソースコードを触っていた際に、この実装はここで良いんだろうか? といったような実装を見ることがしばしばありました。

ですが、クリーンアーキテクチャを取り入れているAPIでは、 各モジュールでの役割がはっきりしているため、 多少アーキテクチャの知識がなくても、他の実装を真似していくことで、 最低限、ビジネスロジックはDomain内に書いてるよね、とか Entityから直接Model呼んだりしないよね、とか そういった基本的なレイヤーごとの分離が守られることになり、 比較的「このロジックがなんでこんなところに!?」とビックリするようなことは少ないのかなと思っています。

また、なにがどこに書いてあるかが明確なため、 バグ発生時の確認なんかもスピーディーに行えるのではないかと感じています。

開発者がレベルアップする

私のような若手のエンジニアだと特にそうかもしれませんが、 クリーンアーキテクチャを取り入れている開発に参画すると、 最初はわけも分からず、なぜかめちゃくちゃ複雑なコードがあるようにしか見えないのですが、 長い期間をかけてレビューを頂いたり、自身でも実装を進めていく中で、 徐々に全体のアーキテクチャを理解できるようになってきたと感じています。

これは本来意図しない、副次的な効果かもしれませんが、 開発者に、ある意味でクリーンアーキテクチャに沿った実装を強制させることで、 時間はかかるものの徐々にクリーンアーキテクチャへの理解が進み、 最終的には、開発者本人の設計力が上がっていくといった側面もあるのではないかと感じています。

クリーンアーキテクチャを取り入れる上での課題

上記のようなクリーンアーキテクチャの恩恵を受けつつも、 逆に、クリーンアーキテクチャを取り入れる中でいくつか課題も感じており、 今度は逆に、私自身が感じている今感じている課題感についても書かせていただきたいと思います。

開発メンバーの入れ替え問題

開発メンバーの入れ替えはどこでも難しい問題ではあると思うのですが、 難しいアーキテクチャを利用している場合には、 例えシステムの仕様を理解したとて、全体のアーキテクチャが理解できていないことで、 実装の速度が落ちたり、品質低下に繋がる恐れがあります。

これは中々難しい問題で、今後API開発チームのメンバーが入れ替わった場合にも、 開発効率や品質を落とさずに開発を行っていくということが課題になってくるのではないかと思っています。

この対策としては、まず、クリーンアーキテクチャを導入するに至った背景や、 クリーンアーキテクチャをアプリケーションにどう落とし込んでいるかについてのドキュメンテーションを行ったり、 開発メンバーの入れ替えを行う際には、ある程度伴奏期間としてペアプロを行い、 徐々にフェードアウトしていくといったような動きが必要になるかなと考えています。 (クリーンアーキテクチャに造形が深い開発メンバーだけをアサインするというのも中々現実的ではないので)

開発工数について

こちら、クリーンアーキテクチャを取り入れる際にしばしば上がる話題ですが、 個人的には、一旦スパゲッティコードが出来上がってしまうと、 最終的に、当初かかるはずのなかった莫大な工数が発生することになるので、 はじめから、適切なアーキテクチャにそって実装をしたほうが、 むしろ最終的には、工数を最適化できて良いのではないかと考えています。(がケースバイケースだとは思います)

ただ、今後拡張の予定がなかったり、そもそもドメインを分けるほどのサイズでないアプリケーションについては、 クリーンアーキテクチャを使用すると逆に開発工数が見合わなくなってしまう可能性があるため、 クリーンアーキテクチャを導入する際には、アプリケーション毎に向き不向きを考える必要があるかなと思っています。

インフラについて

本記事では、インフラのご紹介については割愛させていただきますが、 先日弊社SREが登壇していた際の資料がございますので参考までにこちらをご覧ください。

DevOps実装初期フェーズの組織がTerraformとecspressoで求めるAmazon ECS CICDの最適解/AWS ECS CICD with Terraform and ecspresso

さいごに

1年ぶり2度目のAdventCalenderとなりましたが、 昨年は実務経験も浅く、中々書くことがまとまらず技術に関連する話があまり書けなかったため、 今年は、アーキテクチャ周りの話が書けてホッとしています。

なお、今後も業務を通じて学んだ知見や個人的に学んだアウトプットを、 テックブログへ投稿していきたいと思いますので、よろしくお願いいたします!


  1. APIチームのテックリードがクリーンアーキテクチャでよく使用するコンポーネント公開しています