16
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ElixirAdvent Calendar 2022

Day 8

Elixir/Phoenix/Absintheで動画系SaaSのバックエンドを書いているので全体像を紹介します

Last updated at Posted at 2022-12-08

作っているもの

簡単にいうと、ショート動画を選択肢でつなげて会話化できるSaaSを作っています。

デモ

ひろゆきさんと会話出来ます。
https://story.shareloapp.com/fQ1d47tKM99Q0aTEHVgfV

主に使っている技術やライブラリ

サーバーサイド

  • Elixir/Phoenix/Absinthe - GraphQL API
  • TypeScript/Node.js/Express.js - iframelyとUppy Companion、PayloadCMSなどのオープンソースを使っているのみ
  • PostgreSQL
  • Clickhouse - ストーリー(いくつかのコンテンツを束ねたものをストーリーと呼んでいる)のアクセスデータを保存しています。
  • FFmpeg - 動画の処理に利用(外部APIも使っているので一部です)

クライアントサイド

  • React/Next.js

IaaS

  • Render.com
  • Google Cloud Run

なぜサーバーサイドElixir?

  • ElixirはRubyコミッターだった方が作っているだけに近い感覚で始められて好き(中身は全然違うけど)
  • Elixir書き始めた頃はRubyをメイン言語にしていたので型なしのものの方が馴染みやすかった。
  • GraphQLライブラリでAbsintheが一番洗練して見えた(ちょっと前の話なので今は違うかも)
  • Rubyは前に使ってたけど、メモリ問題解決が面倒だった(これも今は直ってそう)
  • チーム連携や動画閲覧の同時接続で相性が良い -> しかし現状はWebsocket全然使ってません笑
  • そして、なによりコミュニティがいい - 製作者のJosé Valim氏がいつ寝てるの?と思うぐらいフォーラムやGitHubで質問に積極的に答えてます。コミュニティの中の人もサポーティブです。SlackのElixirコミュニティも盛り上がっています。

実はElixirは結構採用されている

Elixir日本だとまだあまり使われてなさそうですが、海外だとDiscordやNotion、Fireworkなど有名startupで使われています。We are in good companies.

主な使っているライブラリ

  • Absinthe - GraphQL API
  • Broadway - Concurrent Processing
  • Bodyguard - AuthZ ポリシーを利用した権限管理
  • Oban - PostgreSQLを利用したジョブ
  • Tesla - HTTPクライアント
  • httpoison - HTTPクライアント
  • stripity_stripe - 決済
  • cachex - キャッシング

ディレクトリ構成

基本はPhoenixのチュートリアルに忠実です。

以下はその一部。

`-- lib/
    |-- sharelo/
    |   |-- mix/
    |   |   `-- tasks - mixタスクの一覧
    |   |-- accounts - Accountsコンテキスト/
    |   |   |-- policies - Bodyguardを利用したポリシー
    |   |   |-- services - サービスオブジェクト的なもの
    |   |   |-- brand_setting.ex - モデル
    |   |   `-- ...
    |   |-- analytics - Analyticsコンテキスト
    |   |-- billing - Billingコンテキスト
    |   `-- clients - httpクライアント
    |-- sharelo_web/
    |   |-- resolvers - メインのAPIのGraphQLリゾルバー
    |   |-- schemas - メインのAPIのGraphQLスキーマ
    |   |-- widget - story.shareloapp.com用のAPI/
    |   |   |-- resolvers
    |   |   `-- schemas
    |   |-- context.ex
    |   |-- dataloader.ex dataloader
    |   |-- router.ex
    |   `-- schema.ex
    `-- task_pipeline - Broadwayを利用したタスクパイプライン用
`-- test/
    |-- sharelo
    `-- sharelo_web/
        `-- schema/
            `-- accounts - アカウントコンテキスト/
                `-- auth/
                    `-- mutations/
                        |-- login_test.exs - login_mutationのテスト
                        `-- ...

AbsintheでGraphQL APIを書く

おすすめの本

https://pragprog.com/titles/wwgraphql/craft-graphql-apis-in-elixir-with-absinthe/
この本がおすすめ。

基本的に本の内容を参考にしていますが、本のままだとschema.exに全てが入っているのだけそこだけ、schemasというディレクトリに分離させたりしています。

ここでは、signup_mutationを例にとって書いていきます。

schemaディレクトリ

以下のようなディレクトリ構造で、Graphqlスキーマを定義しています。

schema
  accounts
    auth
      input_types.ex
      mutation_types.ex
      object_types.ex
      query_types.ex

input_types.ex

@desc "Input fields for signing up as a user with email."
input_object :signup_input do
  field :email, non_null(:string)
  field :locale, non_null(:locale)
end

mutation_types.ex

object :accounts_auth_mutations do # これをschema.exで読み込みます。
  @desc "Signup a user"
  field :signup, :auth_result do
    arg(:input, non_null(:signup_input)) # input_typesで定義したsignup_inputを渡す。
    resolve(&Resolvers.Accounts.Auth.MutationsResolver.signup/3)
  end
 
  # 省略
end

object_types.ex

@desc "Object returning after user auth"
object :auth_result do
  field :viewer, :user
  field :user_errors, list_of(:user_error)
  field :access_token, :string
  field :refresh_token, :string
end

resolversディレクトリ

resolvers/
`-- accounts/
    `-- auth/
        |-- fields.ex # フィールドリゾルバー
        |-- mutations.ex # mutationリゾルバー
        `-- queries.ex # queryリゾルバー

mutations.ex

def signup(_root, %{input: input}, _info) do
  case UserService.handle_signup_with_email(input) do
    {:ok, viewer} ->
      WelcomeEmailJob.new(%{
        email: viewer.email,
        confirmation_token: viewer.confirmation_token,
        locale: input.locale
      })
      |> Oban.insert() # Obanで登録確認メール送信

      Sharelo.Workers.ActiveCampaign.CreateContactJob.new(%{
        user_id: viewer.id
      })
      |> Oban.insert() # ObanでActive Campaignにコンタクトを追加

      token = Authentication.gen_access_token(%{id: viewer.id})
      refresh_token = Authentication.gen_refresh_token(viewer.id)
      {:ok, %{access_token: token, refresh_token: refresh_token, viewer: viewer}}

    {:error, changeset = %Ecto.Changeset{}} ->
      errors = changeset.errors

      user_errors =
        Enum.map(errors, fn {key, {message, _}} -> %{field: key, message: message} end)

      {:ok, %{user_errors: user_errors}}
  end
end

schema.ex

schema.ex内で全部のschema、resolversをまとめてます。

import_types(__MODULE__.Accounts.Auth.{InputTypes, ObjectTypes, QueryTypes, MutationTypes})
query do
  import_fields(:nodes_queries)
  import_fields(:accounts_auth_queries) # これはquery_types.exで定義されています
  # 省略
end

mutation do
  import_fields(:accounts_auth_mutations) # これはmuation_types.exで定義されてます。
  # 省略
end

# router.ex
```elixir

  pipeline :api do
    plug CORSPlug, ...
    plug :accepts, ["json"]
    plug ShareloWeb.Context # contextでユーザーがログインしているか確認してdataloadertとcurrent_userをcontextに入れます。
  end

  scope "/" do
    pipe_through :api

    forward "/graphql", Absinthe.Plug, schema: ShareloWeb.Schema

    if System.get_env("ENV") == "dev" do
      forward "/graphiql", Absinthe.Plug.GraphiQL, schema: ShareloWeb.Schema

      scope "/dev" do
        pipe_through [:browser]

        forward "/mailbox", Plug.Swoosh.MailboxPreview
      end
    end
  end

テスト

これも基本的に本に忠実に書いています。

signup_test.ex

@query """
mutation signup($input: SignupInput!) {
  signup(input: $input) {
    viewer {
      id
      email
      onboardingStatus
    }
    accessToken
    userErrors {
      field
      message
    }
  }
}
"""

describe "Mutation: Signup" do
    setup do
      conn = build_conn()
      UserRoleFactory.create_user_roles()
      {:ok, conn: conn}
    end

    test "it should successfully create a user.", %{conn: conn} do
      conn = post(conn, "/graphql", query: @query, variables: @valid_params)
      res = json_response(conn, 200)
      data = res["data"]["signup"]
      assert data["accessToken"] != nil
      assert data["viewer"]["onboardingStatus"] == "VERIFY_EMAIL"
      user = Repo.get_by(User, %{email: "elonmusk@tesla.com"})

      assert_enqueued(
        worker: Sharelo.Workers.Emails.WelcomeEmailJob,
        args: %{email: user.email, locale: "ja", confirmation_token: user.confirmation_token}
      ) # obanにジョブが積まれているか

      assert_enqueued(
        worker: Sharelo.Workers.ActiveCampaign.CreateContactJob,
        args: %{
          user_id: user.id
        }
      ) # obanにジョブが積まれているか
    end
end

Absintheの使い心地

好きです笑 Prisma + Nexus、Prisma + TypeGraphQLでもGraphQL API書いたことがあるのですが、スキーマコンフリクトやUnionタイプでエラーが起こって修正がしづらかった印象があったのですがAbsintheはおおむねスムーズです。
一つの難点は、ElixirのAuthライブラリの多くがREST APIを念頭に作られておりAbsinthe用のドキュメントがなかったので認証を自作する必要があったことでしょうか。これは勉強してRefresh Token Rotationwを実装していますが、ライブラリを使うのに比べると不安があります。

Elixirでサーバーサイドを書く上で困る点

多くの人が挙げるところかと思いますが、まだこれからという言語なのでオープンソースのライブラリが少なかったり、人気のものでもアップデートがあまりされてなかったり、またStripeを使う場合でもstripity_stripeというライブラリは対応しているStripeのAPIバージョンが古かったりします。また、外部APIを使う場合、公式SDKが使えずhttpクライアントで直接RestAPIに問い合わせるということがほとんどです。

また、僕の場合実はこのアプリが1つ目ではなくて、以前作っていたCtoCのプログラミング学習サービスもElixirを使っていたので、割とElixir歴長いのですがまだまだElixirらしいコードを書くのに頭をひねらないといけないので、エキスパートといえるレベルに達するには時間がかかりそうです。

結論

そんなわけでElixirでGraphQL APIをどんな感じで書いて行っているか簡単に紹介いたしました。最近ですとBFF(Backend for Frontend)を用意する企業が増えておりNest.jsのようなNodeフレームワークが使われていることが多いように見えるのですが、個人的にはAbsintheを推します。Rubyエンジニアだったら割とすぐElixir(Elixirらしいコードかは置いておいて)コードを書き始められると思います。

また機会があればより詳細な実装なども紹介していけたらと思います。

16
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?