作っているもの
簡単にいうと、ショート動画を選択肢でつなげて会話化できる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らしいコードかは置いておいて)コードを書き始められると思います。
また機会があればより詳細な実装なども紹介していけたらと思います。