トレタ開発者ブログ

飲食店向け予約/顧客台帳サービス「トレタ」など、飲食業界のVertical SaaS企業です。

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

アクセシビリティ本の輪読会をした話

こんにちは。フロントエンドエンジニアの白濱です。

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

昨年のアドベントカレンダーでは 「アクセシビリティを気にし出したきっかけと、2020年の振り返り」 という記事を書きました。

zenn.dev

その中で、「来年は輪読会をやるぞ!」と言っていました。

今年実際にアクセシビリティ本の輪読会を行なったので、この記事ではそのことについて振り返りたいと思います。

プロダクト開発部の有志での輪読会を提案

去年のアドベントカレンダーを書いている最中、気持ちが高まって勢いで提案しました。

当時は仕事で関わりがあったデザイナーさんがお一人だけだったということもあり、提案した時ちょっとドキドキしていた気がします。笑

f:id:punipunityan:20211216032459p:plain
f:id:punipunityan:20211219001919p:plain

輪読会開催の目的

輪読会を提案した目的は以下のようなものでした。

  • アクセシビリティに関して知り、考えるきっかけを作ること
  • アクセシビリティ向上に取り組む土台を作ること
  • プロジェクトを超えてデザイナーとエンドエンジニアの情報共有を気軽に行えるようにすること

2021年上期の輪読会

読んだ本

www.amazon.co.jp

まずは、「デザイニングWebアクセシビリティ - アクセシブルな設計やコンテンツ制作のアプローチ」 を読みました。

わかりやすい具体例が多く掲載されており、初心者にとってとても読みやすい本です。

この本を選んだ理由は、有識者の方からおすすめされた本の中で、一番デザイナーと一緒に読みたいと思っていた本だからです。

去年、実装面での改善を行おうとした際、実装だけでは変えようがないことが思っていたより多いと感じました。

そのため、デザイン時点でアクセシビリティの考慮が必要なことを一緒に学びたいと思っていました。

輪読会の進め方

参加メンバーはデザイナー全員に加え、興味を持っていたエンジニア・PMで合わせて5〜10名ほどで行なっていました。

進め方はメンバーで相談し、以下のように進めていました。

  • 毎週月曜日17時から18時開催
  • 事前準備なし
  • 前半30分、各自黙読(毎回約20ページほど)
  • 後半30分、感想・疑問など自由に共有
  • 議事録をとって共有

各々属しているプロジェクトで多忙だったため、なるべく負担にならないやり方を採用しました。

1冊読み終わったあとは、読んで終わりだと勿体無いので、それをどう業務に活かしていくかを考えました。

結果、実際に取り組んでいきたいと思う箇所をまとめてチェックリストを作成することにしました。

f:id:punipunityan:20211216034603p:plain

チェックリストを独自で作った理由は

  • 自分たちで本を見返してピックアップすることにより、気をつけようという意識が高まること
  • まずは小さく始めるため、いくつかピックアップする方が良い

と考えたからです。ただ、まとめみると結果的に80項目ほどになり、いきなり始めるにはちょっと大変な量になりました。

チェックリストを作って以降、運用は個人任せにになっているので、今後のやり方は考えていきたいです。

2021年下期の輪読会

1冊読み終わった時点で、当初の目的としていた

  • アクセシビリティに関して知り、考えるきっかけを作ること
  • プロジェクトを超えてデザイナーとエンドエンジニアの情報共有を気軽に行えるようにすること

は達成できたと感じていました。

その上で、より学んでいきたいと言う声が多かったので下期も輪読会を行いました。

読んだ本

www.amazon.co.jp

下期は「Form Design Patterns ―シンプルでインクルーシブなフォーム制作実践ガイド」という本を読みました。

こちらの本も具体例が多く掲載されており、今後も読み返したい一冊です。

トレタの既存プロダクトを時折振り返りながら読み進め、有意義な輪読会となりました。

輪読会の進め方

下期も上期とほぼ同じような進め方でしたが、議事録のとり方は変えました。
上期は私が議事録を書いていたのですが、下期は各自読みながら感想や疑問点をまとめる形に変更しました。
このやり方に変更することで、それまでより効率的に進めることができるようになりました。

輪読会を終えて

最後に、輪読会参加メンバーから輪読会の振り返りコメントをいただいたので紹介したいと思います。 (わかりやすくするため、デザイナーとエンジニア分けて掲載しています。)

良かったこと

デザイナー

  • 輪読会という形式でエンジニアと一緒に読み進めたので、ディスカッションすることで理解度が増した。
  • フォームのUI・作法の理解度が深まったことで、デザインとして採用する際にもなぜこれが適切なのか説明しやすくなった。アクセシビリティを理解せずに推奨されたフォームをアレンジすると機能しなくなるケースもあるなど学びも多かった。
  • トレタのプロダクトにもあるようなフォームが実例をもとにまとめられており、既存のUIにもアクセシビリティ観点で問題のある箇所を発見することができた。例えば、これまではスクリーンリーダーユーザーに対して読み上げられる要素や順番について考慮ができていなかったことなど。

エンジニア

  • これまで意識できていなかったことも多々あり、認識を改めることができた
  • 本で読んだことに関して共通認識が持てているので、実際の業務における相談がやりやすくなった
  • 普段業務で関わっていないデザイナーともコミュニケーションを取りやすくなった

課題に感じたこと

デザイナー

  • これまでトレタは飲食店向けのtoBプロダクトが主要でユーザーの状況もある程度限られており、「誰にとっても使いやすいこと」より「特定のユーザーにとって使いやすいこと」を重視したいた。でもO/Xのようなプロダクトではこれまでのスタンスを見直す必要がある。知識の習得で終わらせずに、既存プロダクトの改修など実践に移すためにどう取り組むかが今後の課題。

エンジニア

  • チェックリストを作ったが、運用が個人任せになっており取り組みにくい状態になっている
  • 自動チェックの仕組みを増やすなどやりたいことはたくさんあるが、なかなか時間を作れていない
  • アクセシビリティ輪読会から実践に移していく場合、輪読会として動き続けるのは難しい。サブプロジェクトもしくはプロジェクトとして立ち上げたりと、やり方を考える必要がありそう。プロジェクト毎の改善活動の一貫としてやるのが現実的かも。

今後取り組んでいきたいこと

デザイナー

  • フォーム周りは文字入力の時など実際触ってみないと気がつかないことが特に多い。デザイン段階でもユーザーの状況・環境を具体的にイメージできるようプロトタイピングなどで積極的に検証していきたい。
  • 既存プロダクトのアクセシビリティチェックをして現状の問題点を把握したい。そのうえでトレタのサービスに触れるユーザーの特性を整理して、まずは最低限遵守するべきアクセシビリティのルールを作りたい。
  • 策定したルールをもとに既存コンポーネントの再整備を行いたい。

エンジニア

  • まずは、自身が関わっているプロジェクトで「知覚可能」のチェックを行い、issueを作成を行いたい
  • 自動チェックの仕組みを増やしたい
  • 改善のための時間を確保したい

おまけ:フロントエンドチームへの知見共有

この記事では輪読会にフォーカスして振り返りましたが、そのほかの取り組みも少し紹介させてください。

フロントエンドチームでは週一で定例があり、そこで技術共有会を行なっています。 自分のターンの時は、主にアクセシビリティ関連の情報共有を行いました。

以下、話題にした内容です。

  • 個人的に、「東京都新型コロナウイルス感染症対策サイト」のアクセシビリティプレ試験に参加したので、その際のTips共有(どんなツールを使ってどういったチェックをした、など)
  • アクセシビリティ輪読会で作成したチェックリストに関する共有
  • 自動チェックツールをいくつか検討している話(stylelint-a11y、acot)
  • カルーセルUIが嫌われている理由を改めて考えた話(とても盛り上がった)
  • 「知覚可能」の観点で自身が関わっているプロジェクトの改善点を検討した話
  • 押せないボタンがデフォルト非活性なのは推奨じゃないらしいという話(これも盛り上がったと思ってる)

(ここでは詳しいことはここでは書きませんが、雰囲気だけ伝わればと思います。)

終わりに

2021年アクセシビリティ改善に費やした時間は、自身の稼働でいうとの2%に満たないと思います。
関わっているプロジェクトの 0->1フェーズであったこともあり、アクセシビリティ改善活動に割く余力が正直あまりありませんでした。

それでも、確実に成果があった1年だと思っています。

昨年、アクセシビリティ啓蒙活動をされている方から、

「勝手にPRを出すのはアンチパターン」

というお話を聞いてから、ひとりで進めてしまわないよう輪読会の実施や、フロントエンドチームへの知見共有を心がけてやってきました。(どちらにせよ、ひとりでできることは限られますが。)

今年、それはできたと思っています。

来年は、実際の改善をより進めていけるよう、取り組んでいきたいと考えています。

トレタでは一緒に働くメンバーを募集しています!

トレタは、エンジニアとして色々とチャレンジできるチャンスが多い環境だと思っています。

少しでも興味があれば、カジュアル面談などお気軽にご応募ください!

www.wantedly.com

corp.toreta.in

© Toreta, Inc.

Powered by Hatena Blog