トレタ開発者ブログ

飲食店向け予約/顧客台帳サービス「トレタ」、モバイルオーダー「トレタO/X」などを運営するトレタの開発メンバーによるブログです。

トレタO/Xの開発にジョインしてやってきたこと

こんにちは、パパエンジニアのkentaroです.

本記事はトレタ Advent Calendar 2020の 24 日目の記事です。
去年の終わりにO/Xの開発に携わるようになってから様々な経験を積むことができたので、今回はそれを振り返ってみたいと思います。

なお、「トレタO/X」については代表のnoteをご覧ください。

note.com

O/Xジョイン前

トレタの主力商品である予約/顧客台帳サービスのiOSアプリ開発を担当していました。

toreta.in

トレタ入社以前も2014年8月から Swift / Objective-C によるiOS開発をずっとやってきていた、というキャリアです。
(なお、その前は営業等をやっておりエンジニアではありませんでした。これはこれで語れる話なのですが、別の機会に譲ります。)

O/Xジョイン

2020年11月中旬、当時開催されていたネイティブ定例にkitagawaさんが参加し、「O/Xでエンジニアが足りていないんだけど、やってみたい人いませんか?」というのに手を挙げたのがきっかけです。 まずはワンダーテーブルさまのよなよなビアワークス向けのモバイルオーダーアプリ開発を担当することになりました。

yonayona

技術スタックとしてはフロントエンドがFlutter Web、バックエンドがFirebaseという構成でした。

firebase

ジョインしたはいいがしんどい

11月中に諸々のインプットをしてもらい、12月入ったところで環境構築を行い実際に開発を開始しました。
今振り返ってみてもこの頃はめちゃくちゃしんどかったです。

しんどさの原因は「なんもわからん」状態だったこと。

技術面ではそれまで慣れ親しんできたSwiftからDartに変わり、Flutterもしくは採用しているpackageが提供する機能などとにかくキャッチアップすることだらけでした。
公式ドキュメントが充実していること、SwiftUIの経験があったので宣言的UIへの抵抗感がなかったことは救いでしたが、開発効率はiOSのときとは比べ物にならないほど落ちていた実感がありました。

また、モバイルオーダーという文脈だと予約事業とは異なったドメイン知識が要求されるのですが、それが圧倒的に不足していたことも辛かったです。

しんどさをなくす

これはもう特効薬はなく、愚直に「わかるようになる」しかありませんでした。

技術面では同僚エンジニアとビデオ会議をしまくり、実装で詰まっているところの相談をしたり、PRの意図の確認をさせてもらったりしていました。
その後は試行錯誤しながらコードを書いては動作確認をしたり、PR作ったり、うまくいかなかったらまた相談したり…と、ひたすらプロダクトと向き合うしかありませんでした。

ドメイン知識に関しては社内のドキュメントを読み漁り、わからんことがあれば有識者に質問をしたり…のような当たり前のことを継続し続けていました。

2021年2月に自分が担当した比較的大きめな機能追加がリリースされた頃にはしんどさはほとんど無くなっていたように記憶しています。

共通基盤対応

この頃のバックエンドはPoC期間のプロトタイピング用のものだったため、製品版として共通基盤を利用する対応が必要となってきていました。
全体像としてはマイクロサービスへの挑戦とその結果を参照ください。

micro-service

自分はフロントエンド側の以下対応を担当しました。

  • ユーザー向けアプリの共通基盤対応
    • 共通基盤に向けてのAPIリクエスト周りの実装
  • スタッフ向けアプリのリプレイス
    • 共通基盤に向けてのAPIリクエスト周りの実装
    • Flutter2系を採用
    • Riverpodによる状態管理

主な挑戦

APIクライアントの実装に挑戦してみました。
詳細は割愛しますが、リクエスト用抽象classをextendsしたリクエストclassを渡してあげると、リクエストclass側で期待している型のレスポンスが受け取れるような仕組みになっています。
SwiftではProtocolとGenericsで実装するイメージですが、同じ要領で抽象classとGenericsを使い実装しました。
Dartでも抽象的な処理が書けるんだなーと感動したのを覚えています。

6月リリース

こちらの共通基盤対応は無事6月にリリースされました!

議事録を書くようになった

この頃から日々のMTGやスタンドアップの議事録を書くようになりました。
もともと自分とは別の法人のプロジェクトを担当しているQAが取り組みをはじめていて「ええやん!」と思って真似したのがきっかけです。
話したのはいいんだけど、結局何が決まって何がNext Actionなんだっけ?というのがテキストとして残るとその場にいなかった人にも共有しやすいし、後から検索もできて便利だなと感じています。

tech.toreta.in

ちなみにNotionに各種議事録が作成されているのですが、会議体ごとにまとめて表示できるようにしてみたりもしました。
これを作ってたらNotion力が向上したような気がします。

notion

React化対応

PoCではFlutterで作っていたユーザー向けモバイルオーダーアプリをReactにリプレイスするプロジェクトです。
自分は2021年9月から本格的に参加しています。
こちらはまだリリースされておらず、絶賛開発中です。

Webフロントエンドへの挑戦

Flutter版もWebアプリではあったのですが、基本的にプラットフォームのことをあまり気にせず開発できるので、このプロジェクトでWebのフロントエンド開発に業務で初めて携わる感覚がありました。
技術スタックとしては、TypeScript・React・Next.jsを採用しています。

個人ブログを上記技術スタックで開発した経験はあったものの、業務での開発はやはり一味違いました。
TypeScriptの重鎮にPRをレビューしてもらうので色々と指摘されることはあるのですが、意図・根拠が明確でめちゃくちゃ勉強になりました。
まだまだ勉強することがたくさんありますが、楽しんでやれているのはいいことかなと思っています。

振り返ってみて

本当に色々なことやったんだなーと感じました。1年じゃなくて、2, 3年くらい経ってるんじゃねーの?という感覚です。
新しい環境に飛び込むとこんなにも密度が高いんだなーという気持ち…
まだ書きたいことはあったけど、キリがないので特に印象深いことに絞って書きました。

あと、SwiftもDartもTypeScriptも好きだなーということに気がつけました。みんないい言語!!

最後に

新しい技術に挑戦したい方も、得意な技術でバリューを発揮したいという方も、もし興味がありましたらお気軽にカジュアル面談等お申し込みください!

www.wantedly.com

RailsでBFFを構築した際、複雑にならないように気をつけたこと

はじめに

この記事はトレタ Advent Calendar 2021 の22日目の記事です。

こんにちは、昨年と同じ日の投稿になりました、サーバーサイドエンジニアの @shiroemons です。

GoとRuby on Railsを書いてます。

最近、RailsでBFF(Backends For Frontends)を構築しました。 BFFについての細かい説明は省略しますが、簡単に言いますとフロントエンドからのリクエストに応じて複数のバックエンドに対して、APIコールしデータを取得し内容を加工してフロントエンドに返却するAPIサーバーです。

構築した際に、複雑にならないように気をつけたポイントを3点紹介します。

バックエンド毎のClientをPOROで作成

データを取得するバックエンドは、GraphQLとRESTと異なるAPIが存在していました。

GraphQLと通信する際、graphql-clientを検討しましたが、AuthorizationヘッダーやIdempotency-Keyヘッダーをリクエスト毎に変える必要があったため、使い方がアンマッチでしたので不採用としました。 採用した方法は、HTTPクライアントライブラリの Faraday を使用し、各バックエンド用(GraphQL, REST)のClientをPORO(Plain Old Ruby Object) で作成して対応しました。

サンプルを以下に記載しています。

GraphQL用のClient

  • 親クラス
# app/models/graphql_example_client/client.rb
module GraphqlExampleClient
  class Client
    def initialize(auth_token, **options)
      @auth_token = auth_token
      @options = options
    end

    def call
      response = run
      JSON.parse(response.body)
    rescue JSON::ParserError
      {
        errors: [
          {
            code: response.status,
            message: response.body
          }
        ]
      }
    end

    private

    def run
      Faraday.post(example_graphql_url, request_body, headers)
    end

    # https://example.com/graphql
    def example_graphql_url
      ENV['EXAMPLE_GRAPHQL_URL']
    end

    def request_body
      {
        query: query,
        variables: variables
      }.to_json
    end

    def query
      raise 'query called on parent.'
    end

    def variables
      raise 'variables called on parent.'
    end

    def headers
      {
        'Content-Type' => 'application/json',
        'Authorization' => @auth_token,
        'Idempotency-Key' => idempotency_key
      }
    end

    def idempotency_key
      ...
    end
  end
end
  • 親クラスを継承し、1クエリ単位でファイルを作成
    • プレフィックスにQueryMutationを付けておく
# app/models/graphql_example_client/query_locations.rb
module GraphQLExampleClient
  class QueryLocations < Client
    private

    def query
      <<~GRAPHQL.freeze
        query locations($where: LocationWhereClause!, $limit: Int, $offset: Int) {
          locations(where: $where, limit: $limit, offset: $offset) {
            locations {
              id
              name
              createdAt
              updatedAt
            }
          }
        }
      GRAPHQL
    end

    def variables
      {
        where: {
          createdAt: {
            gt: 0
          }
        },
        limit: limit,
        offset: offset
      }
    end
      
    def limit
      ...
    end
      
    def offset
      ...
    end
  end
end
  • 使い方
response_body = GraphQLExampleClient::QueryLocations.new(@auth_token, limit: params[:limit], offset: params[:offset]).call

REST用のClient

  • 親クラス
# app/models/rest_example_client/client.rb
module RestExampleClient
  class Client
    def initialize(auth_token, **options)
      @auth_token = auth_token
      @options = options
    end

    def call
      response = run
      JSON.parse(response.body)
    rescue JSON::ParserError
      {
        error: {
          code: response.status,
          message: response.body
        }
      }
    end

    private

    def rest_example_base_url
      ENV['REST_EXAMPLE_BASE_URL']
    end

    def run
      raise 'run called on parent.'
    end

    def request_body
      raise 'request_body called on parent.'
    end

    def headers
      {
        'Content-Type' => 'application/json',
        'Authorization' => @auth_token
      }
    end
  end
end
  • 親クラスを継承し1API単位でファイルを作成
    • プレフィックスにGetPostを付けておく
# app/models/rest_example_client/post_example.rb
module RestExampleClient
  class PostExample < Client
    private

    def run
      Faraday.post(url, request_body, headers)
    end

    def url
      "#{rest_example_base_url}/v1/example"
    end

    def request_body
      {
        hoge: hoge,
        ...
      }.to_json
    end
    
    def hoge
      @options[:hoge]
    end
  end
end
  • 使い方
response_body = RestExampleClient::PostExample.new(@auth_token, hoge: params[:hoge]).call

使い方は、newしてcallするだけなので簡単です✨

1リクエスト毎にファイルが別れているのでコードをスッキリ書くことができます✨

Interactor層の導入

BFFの特性上、クライアントからの1リクエストで同じバックエンドに対し別々のAPIを呼び出したり、他のバックエンドに対してもAPIを呼び出すことがあります。 愚直に書くとすぐに複雑になりテストも書きづらくなります。 そこで、複雑なビジネスロジックを解消するために、Interactor層を導入しました。 Interactor層を導入することで、ビジネスロジックをカプセル化することができます。

使用したgemは、 collectiveidea/interactor-rails です。 導入方法は、READMEを参照ください。

サンプルを以下に記載しています。 良いサンプルを用意できなかったため、具体例を使って紹介します。

  • 処理
    • 請求書から領収書IDを取得する処理(GraphQL)
    • 既存の領収書を無効化する処理(GraphQL)
    • 領収書を再発行する処理(REST)

Interactorのサンプル

  • 取りまとめ役のファイル
# app/interactors/invoice_receipt.rb
class InvoiceReceipt
  include Interactor::Organizer

  # 上から順番に実行されます。
  organize InvoiceReceipt::GraphqlExampleFetchInvoiceWithReceipt,
           InvoiceReceipt::GraphqlExampleVoidReceipts,
           InvoiceReceipt::RestExamplePostReissueReceipt
end
  • 請求書から領収書IDを取得する処理
# /app/interactors/invoice_receipt/graphql_example_fetch_invoice_with_receipt.rb
class InvoiceReceipt::GraphqlExampleFetchInvoiceWithReceipt
  include Interactor

  def call
    result = fetch_invoice_with_receipt
    if result[:errors].present?
      context.fail!(error: result[:errors])
    else
      context.receipt_id = result[:data][:receipt_id]
    end
  end

  private

  # GraphQLから請求書を取得する処理(GraphQLのQueryを実行する処理)
  def fetch_invoice_with_receipt
    ...
  end
end
  • 既存の領収書を無効化する処理
# app/interactors/invoice_receipt/graphql_example_void_receipts.rb
class InvoiceReceipt::GraphqlExampleVoidReceipts
  include Interactor

  def call
    return if context.receipt_id.blank?

    result = void_receipts
    if result[:errors].present?
      context.fail!(error: result[:errors])
    else
      context.void_receipt = result[:data]
    end
  end

  # 途中で失敗した場合、rollbackメソッドが呼び出される
  def rollback
    ...
  end

  private

  # 既存の領収書を無効化する処理(GraphQLのMutationを実行する処理)
  def void_receipts
    ...
  end
end
  • 領収書を再発行する処理
# app/interactors/invoice_receipt/rest_example_post_reissue_receipt.rb
class InvoiceReceipt::RestExamplePostReissueReceipt
  include Interactor

  def call
    result = post_reissue_receipt
    if result[:error].present?
      context.fail!(error: result[:error])
    else
      context.receipt = result
    end
  end

  private

  # 領収書を再発行する処理(RESTのPostを実行する処理)
  def post_reissue_receipt
    ...
  end
end
  • 実際の使われ方
class Invoices::ReceiptsController < ApplicationController
  def create
    # 領収書再発行処理
    result = InvoiceReceipt.call(
      auth_token: @auth_token,
      location_id: params[:location_id],
      invoice_id: params[:invoice_id],
      mail_address: params[:email],
      name: params[:name]
    )

    if result.success?
      render json: ReceiptPresenter.new.as_json, status: :ok
    else
      render json: ErrorsPresenter.new(result.error), status: :bad_request
    end
  end
end

使い方は、取りまとめ役のファイルをcallするだけなので簡単です✨

個別の責務を1ファイルで書くことで、可読性も向上し複雑にならずコントローラーもスッキリ書くことができます✨

Presenter層の導入

BFFということで、クライアントが必要とする形でレスポンスを返さないといけません。 APIモードで動かしているためViewファイルでJSONの組み立ては行わず、Presenter層を導入して対応しました。 こちらもPORO(Plain Old Ruby Object)で実装しました。

サンプルを以下に記載しています。

Presenterのサンプル

  • 親クラス
# app/presenters/presenter.rb
class Presenter
  def initialize(object = nil)
    @object = object
  end

  def as_json
    raise 'as_json called on parent.'
  end
end
  • 親クラスを継承してファイルを作成
    • サフィックスにPresenterを付ける
# app/presenters/locations_presenter.rb
class LocationsPresenter < Presenter
  def as_json(*)
    {
      locations: locations
    }
  end

  private

  def locations
    @object&.map { |o| location(o) } || []
  end

  def location(object)
    {
      id: object[:id],
      name: object[:name],
      created_at: object[:created_at],
      updated_at: object[:updated_at]
    }
  end
end
  • 実際の使われ方
    • mergeメソッドを使用したりして整形しています
# app/controllers/locations_controller.rb
class LocationsController < ApplicationController
  def index
    response_body = GraphQLExampleClient::QueryLocations.new(@auth_token, limit: params[:limit], offset: params[:offset]).call
    status = :ok
    if response_body[:errors]
      response_json = ErrorsPresenter.new(response_body[:errors])
      status = :bad_request
    else
      locations_data = response_body[:data][:locations]
      total = locations_data[:total]
      locations = LocationsPresenter.new(locations_data[:locations])
      pagination = PaginationPresenter.new(Pagination.new(total, params[:per_page], params[:page]))
      response_json = locations.as_json.merge(pagination.as_json)
    end
    render json: response_json, status: status
  end
end

使い方は、newしてas_jsonするだけなので簡単です✨

おわりに

コードの紹介がメインとなりましたが、できるだけ複雑にならないように気をつけてコードを書いています。雰囲気だけでも読み取れてもらえたら幸いです。

トレタに少しでも興味を持っていただいた方がいれば、カジュアル面談などお気軽にご応募ください!

www.wantedly.com

マイクロサービスへの挑戦とその結果

こんにちは。エンジニアのkitagawaです。

こちらはトレタアドベントカレンダー2021 21日目の記事です。

今年は新規サービスのトレタO/Xに心血注いだ一年でした。 振り返りを込めて、トレタO/Xのバックエンドとしてマイクロサービスを導入したことについて紹介します。

新規サービス「トレタO/X」

www.toreta-ox.com トレタO/Xは飲食店向けのモバイルオーダーアプリです。飲食店の来店者はテーブルごとに渡されるQRコードが印字された紙を自身のスマートフォンで読み込んで、トレタO/Xアプリを開きます。 トレタO/Xから料理の注文が行えて、オンライン決済でスムーズに退店することができます。

トレタO/Xの特徴としては、そのお店ごとの雰囲気を表現したリッチなUIです。 そのためシステム構成は、メニューブックを柔軟に表現できるように各社ごとにフロントエンドのアプリをそれぞれ作っています。 一方でバックエンドは共通の機能を提供するSaaSモデルを提供しています。 そうすることで、各社ごとに異なったUIを提供しつつ、共通のバックエンドで機能エンハンスを行えるようにしています。

f:id:mkitagawa-312:20210803101055p:plain f:id:mkitagawa-312:20210803101115p:plain

マイクロサービスを導入した背景

トレタO/Xの開発が本格的に始動したのは約2年前です。 コロナ渦で飲食店が厳しい状況を強いられている中、トレタも大きな打撃を受けました。 そこで今まで予約台帳サービスをはじめとする飲食店の「予約」事業の会社から、「飲食店支援」の会社へ変わろうと新しい事業の模索が行われました。 いくつかの新規サービスが立ち上がり、その中で注力事業として置いたのがトレタO/Xです。

トレタO/XはプロトタイプでのPoC期間を経て、次はシステムをスケールさせるために製品版としてバックエンドのリニューアルを行いました。 その際にシステム構成を検討する上で主軸とした考えが、システムの再利用性です。

「強くてニューゲーム」ができる開発組織

トレタO/Xを予約事業の次の事業の柱にする意気込みでバックエンドの設計検討を行いましたが、 その先を見据えるとトレタが次は単に「オーダーアプリ」の会社になるのではなく、「飲食店支援」の会社として今後も次々と新規サービスを出していく会社になる必要があると考えました。

そこでトレタO/Xのシステムはその足がかりとなるように、トレタO/Xで作ったシステムの一部が他の新規サービスでも再利用できるような構成を理想の姿としました。 そうすることで、今後の新規サービスを立ち上げる際には毎回0からのスクラッチではなく、資産を流用することで開発期間が短縮された「強くてニューゲーム」が行える組織になると考えました。

そのため、「O/Xのバックエンド」ではなく「トレタの共通基盤システム」として、特定のコンテキストで区切られたシステムを組み合わせて構成するマイクロサービスを自然と選ぶことになりました。

トレタO/Xのシステム構成

f:id:mkitagawa-312:20211220230607p:plain トレタO/Xのシステムは大まかに以下のマイクロサービスで構成されています。

  • 認証サービス
    • Auth0をベースにし、各種サービスごとの権限やロールなどを管理するサービス
  • 注文サービス
    • トレタO/Xのメインとなる注文データやユーザーのセッション情報を扱うサービス
    • 変更履歴が全て保存されるDatomicをDBとして、Clojureで書かれたGraphQLサーバー
  • 決済サービス
    • 決済や返金なでの決済処理や決済データを管理するサービス
    • Stripeなどの決済プラットフォームをラップし抽象化する
  • 印刷サービス
    • 注文データやQRコードをキッチンプリンタへ印刷するサービス
    • 印刷ジョブを管理し、ネットワークプリンタから定期的に送られてくる印刷ジョブチェックのリクエストを捌く
  • APIサーバー
    • クライアントアプリと各種マイクロサービスとの間をかけもつファサード層
    • 外部公開用のAPIを提供する

ご覧の通り使用している言語がバラバラでも成立するのがマイクロサービスの一つの利点かと思います。 技術選定は在籍エンジニアの技術スタックであったり、システム要件としてデータベースにあわせた言語や連携先サービスのSDKの提供状況などを加味しています。

マイクロサービスにしてみて

「マイクロサービスは銀の弾丸ではない」とはよく言われるもので覚悟はしていましたが、やはりいろいろと地雷は踏みました。

分散トランザクションによる問題

  • タイムアウトなどにで一部にだけデータが入りロールバックできず不整合
  • リトライや冪等性、到達保証など考慮する点が多い
  • トラッキングIDを入れないと調査に一苦労
  • 結局は分散トランザクションが生じている時点でドメイン境界が正しく切れていない

インフラコストの問題

  • それぞれのコンテナ実行環境構築と管理するためのインフラエンジニアが不足
  • デプロイさせるシステムの順番を間違えると事故が起きる可能性もある

徐々にマイクロサービス化するという幻想

苦労する部分は多いですがやってみて良かったと思うことの一つに、最初からマイクロサービスを前提にしていたからこそ正しく正規化や抽象化されたデータ構造に近づけたという点です。

「初期の段階からマイクロサービスにするな」というのもよく言われる話です。 ただ、モノリスをつくってから徐々に切り出していく、というのもそう簡単ではありまえせん。 トレタでも予約台帳のサーバーは約8年越しの巨大なモノリスとなっており、部分的に切り崩そうとしてもデータが依存しあったりコードが絡みあっていたりで、話があがるたびには消えていました。 そもそもリプレイスや大規模リファクタリングは事業フェーズのタイミングとリソース(人、金)が揃わないとなかなか行うことができません。

今回のようにPoCでコンテキストの境界を検証したり仕様を概ね出し切っておき、プロトタイプを脱する時点でマイクロサービスを検討するのは良いタイミングだったかと思います。

さいごに

マイクロサービスにするとやはりエンジニアの頭数がどうしても必要になってきます。 トレタではエンジニアの採用を現在全方面オープンしています。 飲食店のDX化が急速に進んでいる昨今で、一緒に未来の飲食業界を作っていく仲間をお待ちしてます!

www.wantedly.com

© Toreta, Inc.

Powered by Hatena Blog