目次
背景
Nicoleは2019年11月に運用を開始しました。(当時の記事はこちら)
NicoleはRailsを使って実装しています。Railsを採用した理由は私自身がRailsに慣れていて、一番速く開発を回すことができると考えたためです。
一方、リリースから1年半が経過して、徐々にRailsを使っているデメリットが顕在化しはじめました。具体的には以下のような問題です。
- UIとビジネスロジックが密結合していて、どちらか片方を変更するだけでもその両方を意識する必要がある
- Railsのバージョンアップの影響がシステム全体に波及してしまう
- いわゆるリッチなUI/UX(SPAや動的フォーム、複数のグラフの描画など)を実現し辛い
もちろん全てがRailsの問題ではなく、私の作り方も原因の一つだと思っています。とはいえ、この状況を打破するためには一度システム全体を見直す必要があると考え、リニューアルに踏み切りました。
システム構成
フロントエンドとバックエンドを分離
大方針としてフロントエンドとバックエンドを分離しました。最近のシステム開発の事例で良く見かける構成ですね。これにより、モノリシック(一枚岩な)システムから脱却でき、抱えていた問題を解決できると考えました。
Dockerコンテナも分離
このシステムは元々Docker上で運用されていたのですが、1つのコンテナ上でRailsが起動しているシンプルな構成でした。フロントエンドとバックエンドを分離することに伴い、コンテナも分離することにしました。
コンテナを分離することで影響範囲が限定でき、それぞれを個別にリリースすることができるようになりました。また、フロントエンド側のコンテナにNext.jsのサーバを起動しておくことで、将来的にSSR/SSG/ISRへの移行も視野に入れやすくなります。
フレームワーク
フロントエンド: Next.js
昨今のフロントエンド界隈を見ていると、自分にはVue.jsよりもReactの方が優勢だと感じたのでNext.jsを選択しました。実際に両方書いたことがあり、よりスピーディに開発できる点やTypeScriptの導入のしやすさといった点でメリットがありました。
バックエンド: Ruby on Rails
もともとRailsで作られていたシステムなので、バックエンドには引き続きRailsを使用しました。従来のViewやControllerはほとんど破棄、ModelとMigrationのみ流用しています。
またこれを機にRailsのバージョンを6.0から6.1へ、Rubyのバージョンも2.6から3.0にアップグレードしています。
API
APIはOpenAPIを採用しました。スキーマベースで開発することで、フロントエンドとバックエンドでやり取りするデータの乖離を防げると考えたためです。
OpenAPIのスキーマ記述にStoplight Studioを使用
OpenAPI自体は有用だったのですが、スキーマを自分で記述するのはそれなりに大変ですし、記述漏れがあっても気づけないといった問題も抱えています。そこで今回は、Stoplight Studioというツールを使ってスキーマを記述しました。
GUIベースで入力欄を埋めていくことで簡単にスキーマを生成できます。また、Stoplight Studioの画面自体がそのままドキュメントとして活用できそうな点もGoodでした。
OpenAPIによるスキーマベースのテスト
スキーマを記載しても実装がそれに準じていないとあまり意味がありません。そこで、APIがスキーマに反していないことをRSpecで確認するようにしています。
これにはcommittee-railsというRubyGemを活用しています。RSpecのrequest specのアサーションが assert_response_schema_confirm
の1行で済むので便利です。
OpenAPIによるクライアントの自動生成
OpenAPIを使用するメリットの一つに「クライアントコードを自動生成できる」というメリットがあります。今回はopenapi-typescript-codegenを使用してTypeScriptのクライアントコードを自動生成しました。
例えばユーザーの新規登録は自動生成されたコードを利用することで1行で実行することができるようになりました。
await NicoleService.createUser(session.authorization, userParams)
余談: GraphQLは採用せず
実は当初はGraphQLの導入も検討していました。しかし、検討の結果、今回は導入を断念しました。
これは以下のような理由からです。
- 特にバックエンドについて変更量が多すぎるため、過去の資産が活かせなくなる
- NicoleにはGraphQLに頼りたくなるほど複雑なリクエストがない
- OpenAPIでそれなりの開発スピードが担保できることがわかった
認証
リリース直後は従来の認証方式を維持
リリース当初は、以前から利用しているRailsのsessionを利用した認証方式をとっていました。弊社内には認証用の社内SSOが存在しており、独自方式での連携が必要であることからDeviseは利用していません。sessionの保存先にはcookieを利用していました。
本番環境は前段にNginxを配置し、フロントエンドもバックエンドも同じOriginなのですが、開発環境は別々に開発用サーバを起動しているためCrossOriginです。RailsのAPIモードでCrossOriginなSPAからsessionを利用するといろいろ罠があったため、以下の記事にまとめています。
同じ方法かどうか断定はできませんが、フロントエンドにNext.jsを使ったときの認証の実装方法は以下Zennの記事が参考になりました。ありがとうございました!
Slack OAuth2認証に変更
弊社内でも「認証は外部プロバイダを活用して徐々に社内SSOから脱却していこう」という機運があり、NicoleについてもいずれはOAuth2認証に切り替えたいと思っていました。Nicoleは他の社内システムに対してAPIを公開したいと考えていたことも、session認証から脱却したい理由の一つでした。
そんな中、NextAuth.jsというnpmが存在することを知ったのでこれを使ってOAuth2認証を実現しました。フロントエンド側で外部プロバイダからtokenを発行してもらい、バックエンドへのリクエスト時にAuthorizationヘッダにtokenを付与しています。
外部プロバイダにはSlackを使いました。このシステムはエンジニア以外の社員も使うのでGitHubアカウントを全員が持っているとは限らなかったこと、なんとなくMicrosoftアカウントとは連携したくなかった(ログイン操作が面倒だと思っている)ことを踏まえ、消去法で選定しました。
ちなみに、SlackのOAuth2認証はSSL対応が必須なので開発環境でもフロントエンド側はSSLに対応する必要がありました。この問題はNext.jsのCustom Serverを使用してLets's Encryptで作成した証明書を適用することで解決しています。このあたりの検討含め、いずれ別の記事で詳細を解説できればと思います。
UI/UX
Tailwind CSSでコンポーネントにデザインをまとめる
以前はMaterializeを使ってページデザインしていたのですが、今回はTailwind CSSを使用しました。ネットに作例が多々あるので、デザインが苦手な自分でもそれなりに使えそうだったのと、ユーティリティファーストというアプローチに魅力を感じたことが採用理由です。
Reactを利用していてもCSSだけは独立しがちだと感じていたのですが、Tailwind CSSを採用したことでこの問題が払拭されました。HTML/CSS/JavaScriptを一つのReactコンポーネントにまとめられるようになりましたし、他のコンポーネントに影響しない形でのデザイン修正が容易になりました。
ちなみにコンポーネント設計はAtomic Designを見様見真似で採用しましたが、コンポーネントの粒度の妥当性を判断するのが難しいなと感じています。
React/JavaScriptのエコシステムを活用
Reactを採用したことでUI周りのエコシステムをフルに活用できるようになりました。具体的には以下のようなnpmを利用しています。
- react-select: セレクトボックスをリッチにしてReactコンポーネントに埋め込みやすくする
- react-textarea-autosize: textareaが自動的に伸縮するようにする
- react-dropzone: 画像アップロードをドラッグ・アンド・ドロップで行えるようにする
- react-toastify: トーストと呼ばれる通知バナーを表示する
- react-emoji-render: テキストの絵文字を綺麗にレンダリングする
- linkifyjs: テキスト内のURLを自動的にリンクする
ちなみに、react-emoji-renderとlinkifyjsの併用にはひと工夫が必要でした。以下の記事で詳細を解説しています。
開発効率化
SWRで効率的にデータフェッチ
OpenAPIでTypeScriptのクライアントコードを作成したものの、データフェッチ(データをGETする処理)はSWRを利用しました。Reactはコンポーネント内のstateが変わる度に再レンダリングが発生するのですが、SWRを使うことでデータ取得待ち→データ取得後のレンダリングを簡単に書くことができます。
export const UsersIndex = (): JSX.Element => {
const session = useRecoilValue(sessionState)
const { data: users, error } = useSWR<User[]>('/users', fetcher)
if (error) {
toast.error('ユーザーの取得に失敗しました')
return <Loading />
}
if (session === undefined) {
return <Loading />
}
return (
// 画面のJSX
)
)
export default UsersIndex
ページネーションについてはSWRInfiniteを使った無限スクロールとしています。ただ、古い情報にたどり着きにくくなるので、今後普通のページネーションに作り直すかもしれません。
Recoilで必要最低限のステート管理
Reactを使う上で頭を悩ませるのがステート管理です。このシステムに関しては「基本的にページ(親コンポーネント)ごとに必要なデータをフェッチして、子コンポーネントにバケツリレーする」という方針を貫いています。このため、Reduxは使用していません。
一方、この方針だとあまりにも実装上不便な要素が2つありました。これに関してはRecoilでシンプルにステート管理しています。
- サイドバーのOpen/Close(ヘッダコンポーネントとサイドバーコンポーネントでステートを共有する必要がある)
- ログイン中のユーザー情報(あらゆるコンポーネントで使用する)
この2種類の要素は必ずしも1つのstoreにまとめる必要はないですし、Reduxの複雑なフローに載せる必要性を感じなかったのでRecoilで十分だと判断しています。
React Hook Formでフォームの状態管理
フォームの状態管理も実装上負担がかかる部分です。NicoleではReact Hook Formを使うことで負担を軽減しています。細かいステートの管理から解放されて、Submit時のハンドラだけに注力できるようになります。
const NikoForm: React.FC<Props> = ({ onSubmit, niko }) => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<Niko>({ defaultValues: niko })
const onSubmitLocal = (data) => {
onSubmit(data)
}
return (
// FormのJSX
)
)
export default NikoForm
バリデーションについては基本的にはフロントエンドで行うようにして、バックエンドでしか検出できないエラー(ユニークバリデーション等)のみバックエンドで行うようにしています。
既存システムからの移行
以上のように技術スタックの構成要素を選択してきたわけですが、最後の難関が「既存システムからの移行」でした。ただ、今回のリニューアルは「原則DBスキーマを変更しない」ことを徹底していたので、以下のようにしてシステムを移行しました。
- DBを共用する形で別エンドポイントでシステムをプレリリース
- 数日運用して問題ないことを確認
- 既存のエンドポイントを新システムに切り替える
一つだけ誤算だったのはRailsを6.1にアップグレードしたことで、ActiveStorageのDBスキーマが変わってしまったことです。このため、一時的にアップロードした画像が表示されないといった問題が発生しました。
それ以外には幸い、特に大きなトラブルは無かったのですが「いつでもロールバックできる体制を作る」ということを強く意識して移管作業をしました。本番環境はDockerを使用しているので、問題が起こった場合にはコンテナをスイッチすることでロールバックする想定でした。
開発期間
自分1人が1日1〜2時間ペースでもくもくと開発を続けていたのですが、4月〜6月の約3ヶ月を要しました。Railsだけで実装した場合と比較して、時間がかかったことは否めないですね。
ただ、以前、個人的に作ったポートフォリオサイトと今回のシステムリニューアルである程度ノウハウが確立できたと自負しています。次に同じ開発をしたら2/3くらいの工数で終えたい気持ちです。
感想
- フロントエンドとバックエンドが分離されたので、それぞれの変更に注力できるようになりました。
- リリースも個別に行えるようになったので、リリース自体が速くなりましたし問題が起こった場合の影響範囲も推測しやすくなりました。
- OpenAPI化&OAuth2認証化したことでAPIを社内の他のサービスに共有しやすくなりました。
- リニューアルによってバックエンドの責務が最小限となったので、バックエンドはもはやRailsじゃなくても良いかもと思い始めています。
まとめ
Railsで構築しているサービスが肥大化したり、UI/UXの強化に悩んだりすることは多いと思います。
そのような状況に陥ったときの「次の一手」として、本記事が参考になれば幸いです。
Related Posts
Daiki Urata
2024/06/10
Daiki Urata
2023/05/22