話者
-
@tomoasleep
- https://www.wantedly.com/id/tomoasleep
- Qiita 社のエンジニアです
- 新卒で Qiita 社にいた → Wantedly で SRE → 最近 Qiita 社に戻ってきた
- 今は各アプリケーションの開発 & 開発者体験向上 etc... に取り組んでいます
- Ruby で変なものを作るのが趣味です
- 最近の趣味: 散財
話すこと
- Rails アプリケーション (Qiita Team / Qiita Jobs) とフロントエンド (React) の間の連携を、
- JSON Schema を吐き出す Ruby の Presenter を作って使うことで、
- 両者をうまく連携して幸せな開発体験を提供したという話
で話す/した内容です。
Qiita Team の紹介
- 企業等向け SaaS
- 簡単に言うと「社内向け Qiita」
- 今年で 10 歳
- Rails で開発
- Qiita との立ち位置
- Qiita, Qiita Team は同じ Rails アプリケーションとして開発
- コア機能 (エディタ, Markdown 処理) を流用
- その他の機能は徐々に分離を行っている
- 最初は本当に「Private Qiita」だったが、サービスの成長とともに違いが大きくなり実装の分離を行っていった
- Qiita 開発で得た経験、資産を取り入れる形で開発
- Qiita, Qiita Team は同じ Rails アプリケーションとして開発
Qiita Team の最近のフロントエンドのアップデート
Before | After |
---|---|
- グループ選択、タグ検索のリストなどをサイドバーから選択可能に
- チャンネルを見る感覚で記事を探すことがしやすい
デザインリニューアルに合わせて、 View 実装の設計を変更
- View の組み立てを Rails (Slim) → TypeScript (React) に寄せる
- これまで: View を基本 Rails の View (Slim) で + 部分的に React 化
- これから: View の一部 (head 要素 etc) 以外は React 側で View を組み立て
- Qiita Team はクライアントサイドレンダリングを利用
- Why?
- View を React に寄せることでの開発体験の向上
- Slim や React を行ったり来たりしなくて良い
- デザインを CSS-in-JS (Emotion) に寄せられる
- etc
- Qiita のフロントエンド資産の流用
- View を React に寄せることでの開発体験の向上
- Rails 側から React を利用する方法
- React on Rails を利用
↓
コードの雰囲気例
- React Component を Rails View (Slim) にマウントしているように書ける
html
head
title
= content_for(:title)
= javascript_include_tag
body
= react_component("HomeFeed", props: { username: "Mike", articles: @articles.map(&:to_h) })
export function HomeFeed({ username, articles }) {
return (
{articles.map(article => (
<div css={itemStyle} key={article.id}>
<ItemHeader article={article} />
<ItemBody article={article} />
</div>
))}
)
}
Rails バックエンド & フロントエンド連携の難しさ
- テストをするとき大掛かりになる
- SSR しない場合は、ブラウザテスト (System Test 等) が必要
- フロントエンドの型が信用できない
- バックエンドのデータと照合する保証がない。。。
- フロントエンドに渡すための Serialize どこでやる?
- erb か Slim に Hash リテラル直書きはしんどい
- React on Rails に渡す props を組み立てる場所が定まっておらず、散らかっていた
→ 間を取り持つのが React on Rails だけだと心許ない
この関係図を見ていて思うこと
View とそれ以外の間を取り持つもの → Presenter 層
- Presenter 層を置く (ViewModel, Serializer, Decorator などとも呼ばれる)
- 例: ActiveModelSerializer, Draper, jbuilder, alba など
- props どこで組み立てるか問題はこれらを参考にすると解決しそう
異なる2つのシステムを取り持つもの → スキーマ
- 個別にテストしにくい、型が信用できない → スキーマを作る
- Web API だと例えば OpenAPI, gRPC, GraphQL などがある
この2つをかけ合わせると…?
X
JSON Schema を生成する Presenter を間に挟む
- バックエンドからフロントエンドに渡すデータにスキーマ (JSON Schema) を付ける
- API にスキーマを与える技術 (OpenAPI/gRPC/GraphQL) を View でのデータのやり取りに応用する
- スキーマには JSON Schema を採用
- Qiita 内には JSON Schema を扱うための資産がある
作ったもの
-
increments/json_schema_view: View framework that brings Schema-driven Development to Rails view
- JsonSchema を生成する Presenter + ReactOnRails とのブリッジ
- Ruby コードの DSL として JSON Schema を記述
- ViewComponent 風のインターフェイスで ReactOnRails とブリッジ
- JSON Schema を生成することで、検証/コード生成に利用出来る
- Ruby 側は JSON Schema に対して値を検証
- JSON Schema から TypeScript を生成
- JsonSchema を生成する Presenter + ReactOnRails とのブリッジ
コード例 (Component 呼び出し)
= react_component("HomeFeed", props: { username: "Mike", articles: @articles })
↓
= render HomeFeed.new(props: { username: "Mike", articles: @articles })
コード例 (生成される JSON Schema)
{
"title": "HomeFeedProps",
"properties": {
"articles": {
"type": "array",
"items": { ... },
},
"username": {
"type": "string",
},
"required": [
"articles",
"username"
],
"additionalProperties": false
}
コード例 (Component 定義)
class HomeFeed < JsonSchemaView::Component
renderer_class(:react_on_rails)
props_class do
property(:username, type: String)
property(:articles, type: Array, items: { type: HomeFeed::Article })
attr_reader :username
def initialize(username:, articles:)
@username = username
@articles = articles
end
def articles
@article.map { |article| HomeFeed::Article.new(article) }
end
end
end
- ※ JsonWorld を View で render 出来るように & 最近の JSON Schema に対応できるように拡張して利用
コード例 (TypeScript)
json-schema-to-typescript を使って TypeScript の型を吐き出せる
import { HomeFeedProps, Article } from "./generated-types/home-feed"
export function HomeFeed({ username, articles }: HomeFeedProps) {
return (
{articles.map((article: Article) => (
<div css={itemStyle} key={article.id}>
<ItemHeader article={article} />
<ItemBody article={article} />
</div>
))}
)
}
実際に組み込んだ
- 導入
- いくつかのプロトタイプを作り
- コンセプト的に行けそうと判断したら社内ライブラリ化して Qiita Team に導入
- (便利に使ってたら他の開発メンバーが Qiita Jobs に組み込んでくれたり)
- その後 OSS 化
- 効果
- 信頼性向上 → 安心して開発出来るように
- TypeScript の型が信用できるものになり、
- Rails 側の動作検証も Request Spec などで出来るようになる
- 秩序が生まれた
- Presenter 層が整ってなかった部分がついでに整備された
- 必要なデータの問い合わせが Presenter 層に集約されたことで、クエリの効率化
- 信頼性向上 → 安心して開発出来るように
- 課題
- クライアントが叩く API の部分は未対応 (信用しにくい型定義のまま)
- 非同期で API を叩く必要がある箇所は実装済みのため後回しに
- Presenter が DRY になるための整備
- クライアントが叩く API の部分は未対応 (信用しにくい型定義のまま)
おまけ: render 出来るオブジェクトを作ることが出来る
-
ViewComponent で採用されていた手法
- Rails 側のアップデートで ViewComponent 以外でも出来るように
= render HomeFeed.new(props: { username: "Mike", articles: @articles })
Ref:
おまけ: Action ごとのテンプレート (Slim) を捨てる
- View の React 化が進むことで Rails 側のテンプレート (Slim) が限りなく薄くなる
- 使う React Component を宣言するだけのオブジェクト (ReactPage) を作る
- Controller だけで完結し、 Action 毎のテンプレート (Slim) が消える
class HomeController < ApplicationController
def index
render ReactPage.new(
HomeFeed.new(props: { username: current_user.name, articles: Article.page(1) })
)
end
end
設計面の FAQ
- Why not OpenAPI/GraphQL/gRPC ?
- View に必要なデータは、API 経由ではなく、直接渡したい
- GraphQL は小さくない初期投資をする必要がある
- GraphQL は Qiita 側で運用されているが、他のサービスでは導入はまだ
- 土台が出来てくると便利だが、そこまでのコストが大きい
- GraphQL でモデルの関係をどう表現したり、も慣れが必要
- Why not Rails API mode?
- 書き換えが大きくなる
- やりたいことはフロントエンドを React に寄せることだったので、やりたいことに対して労力が大きすぎると判断した
- 書き換えが大きくなる
- Why JSON Schema?
- Qiita 内では一部の箇所 (Qiita API) で JSON Schema が利用されていたため (馴染みのある技術だった)
- 最近の JSON Schema は enum, anyOf など、表現力が十分ありそう
- OpenAPI / HyperSchema などの転用も将来的に可能 (かも)
- Code First vs Schema First → Code First
- 社内で採用している技術が Code First なので (GraphQL-Ruby, Qiita API) 馴染む
- (個人的な考えとして) Ruby は型をチェックする仕組みがまだ弱いので、型定義と実装は近いところにある方が置いたほうが何かと良い
- API 等のスキーマはどうしている?
- やれてない (今回はスコープから外した)
- 「JSON Schema を吐き出せる Presenter」 が作れたので、 API と紐付ければ OpenAPI のスキーマを吐き出すことも将来的に可能 (かもしれない)