search
LoginSignup
27
Help us understand the problem. What are the problem?

posted at

updated at

Organization

学生エンジニアのためのチャットサービスをNext.js + TypeScript + AtomicDesign + Firebase9 + Dockerで作った(β)

はじめに

※今回の開発は、株式会社OwN様からいただいた技術課題の一環で行ったものになります。

自己紹介

兼業で個人サービスの開発・運営・保守を行っております。
フロントエンジニアのふぁると申します。
統合テストのクラウド管理・実行プラットフォーム「Itamaster」を運営しております。
よろしくお願いいたします。

【Twitterリンク】
itamaster

【Itamaster公開記事リンク】
https://qiita.com/Itamaster/items/f821be4c33caab640a93

【Itamaster】
https://itamaster.work

テスト管理プラットフォーム Itamaster

最近、フロントエンドのみですがOSSとして公開を行いました。
知見の浅い当時書いたコードなので、コードやディレクトリは綺麗ではないですが、i18nの実装、stripe連携やvuetifyの導入等には役立てていただける点があると嬉しく思います!
https://github.com/FAL-coffee/itamaster-front

サービスについて

サービス名

hacker-class-roomを略して、「はかくら」です!
image.png

リポジトリURL

こちらになります。
https://github.com/FAL-coffee/hacker-class-room

サービスの概要

  • 「プログラミングの授業が始まるが、ついていけるのか不安......」
  • 「情報処理の勉強が難しい。もしかして、こんなに苦労しているのは私だけ?」
  • 「ゲーム作りに興味があって、プログラミングを始めてみたいけど、何から始めればいいのかわからない」
  • 「ネットで見た通りにやったのに、環境構築でつまずいてしまった。」
  • 「プログラミングの知見を活かして、学生起業を行いたい。仲間をどうやって見つければいいだろうか?」

そのような若い学生エンジニアにありがちな悩みを解決するために、私ははかくらを開発しました。
はかくら(Hacker Class Room)は、学生エンジニアのために開発したコミュニティサービスです。

使用技術

  • Next.js ( TypeScript ) --人気のOSSからディレクトリ構造を参考にし実装。src/を採用
  • Firebase v9 --サーバーサイドとして利用
    • Firestore --NoSQLのDBとして利用
    • Firebase Authentication --Google Loginに利用
  • Docker --コンテナ
  • Material UI --主にatomsコンポーネントとして利用
  • Storybook --コンポーネントのカタログとして利用
  • Jest --Component単位のテストを実装(jest, enzyme)
  • Cypress --E2Eテストに利用
  • GitHubActions--継続的インテグレーション(CI)による、UT自動化
  • vercel --ホスティングに利用

お知らせ

'22/03/04
電気通信事業の開業届が受理され、電気通信番号をいただけたため、Direct Message機能を有効化しました。
他ユーザーのアイコンをクリックし移動出来るプロフィール画面にあるメールアイコンをクリックすることでDMを開始できます。

採用アーキテクチャ

  • GitHub-Flow --導入・運用の簡単さを理由に起用。単純なコミットを繰り返す開発フローにも最適だった
  • Atomic Design --コンポーネントのディレクトリデザイン手法。単純にAtomicDesignが好き
  • issueドリブン開発 --残り作業タスクをブランチの一単位として利用できる

開発のきっかけ

きっかけとしては、上述させていただいた株式会社OwN様ツイートに私が反応しました。
お話を続ける中で、技術課題として以下のような要件でシステム開発を行ってほしい、と言っていただき、一か月の期間内で開発を始めました。

要件

  • 12 ~ 22歳のエンジニアを対象としたチャットサービスであること
    • それらが抱える潜在的な課題を定義し、解決することを目的とした機能を実装する
  • Googleログインを使ってユーザー認証・登録を行うこと

技術要件

  • TypeScriptを開発に用いていること
  • DBに情報を保存できること
  • セキュリティに配慮した設計・実装であること

任意要件

  • CI/CDパイプラインが設定されていること
  • チーム開発を意識したアーキテクチャであること
  • OAuth/OIDCのクライアントを自前で実装すること
  • アプリケーションをコンテナ化すること
  • 依存の少ないアーキテクチャであること

ニーズ調査

要件を満たすために最初に行ったのは、「12 ~ 22歳のエンジニアがどのような課題を抱えているのか」の調査です。
私は12~22歳という年齢を指定されていることから、対象をその中でも更に「学生」と定義しました。

私の出身高校には情報科が設置されております。
現職で該当科の担任を受け持っておられる先生との縁が現在も続いているので、先生や生徒数名を対象にインタビューをお願いし、現在の状況やプログラミング・情報の授業中に難しく思った点を主に教えていただきました。
その結果をもとに、学生エンジニアの成長を取り巻く環境として、以下のような状況を発見しました。

  • クラスという区切りでは人数が少なく、向上心のある学生が高め合えるような仲間と巡り合えないことが多々ある
  • 一部の生徒を除き、ほとんどの学生エンジニアは課題に直面した際は周囲の友人や、教職員に対し質問や相談を行う事で情報を得ている
  • 同様に、明確な目的や目標を持っていない学生エンジニアが多く見られる

それらは、以下のようなリスクを持ち合わせていると考えました。

  • 周囲の人間が回答が出来なかった際、本人が課題解決へのモチベーションを低下させてしまう場合がある
  • 周囲の友人や仲間がだらけている場合、雰囲気に流される者が一定数発生してしまう場合がある。それは、自身の成長が順調でない時に顕著である
  • 明確な目的、目標を持たず、学習に対して意味を見出していない場合、モチベーションの低下に繋がる場合がある

それらを私は潜在的な課題であると定義し、リスクを解決するために必要な要素として、周囲の人間関係や、慢性的に目にする情報の質の向上を図る必要があると考えました。
具体的には、以下のような環境が不足していると考えました。

  1. 質問を行った場合でも、誰かが必ず答えたり、得意分野を教え合うことの出来る環境
  2. 自分がだらけてしまうときに、同世代の向上心のある学生エンジニアとコミュニケーションを取ることで、目標設定や意識の改善を図ることの出来る環境
  3. 高め合う事の出来る仲間と繋がることの出来る環境

それらを潜在課題と定義し、要件定義フェーズを完了しました。

各技術, アーキテクチャの選定理由について

Docker

コンテナ技術としてDockerを採用した理由としては、連携可能なソフトウェアが多いこと、デファクトスタンダードであり、情報量や導入事例の観点から見て大きなメリットが見込めることです。
他候補としては、Containerd, lxc, lxd等がありました。

Firebase、Firestore

サーバーサイド技術としてFirebaseを採用した理由としては、以下です。

  • 私の技術スタッツ上の問題から、サーバーサイドの実装を行う場合、納期を超過してしまうリスクがあった。
  • 他Baasと比較し、Firebaseが今回には最適であると判断したため。

かみ砕いて記述させていただきます。

私の技術スタッツ上の問題から、サーバーサイドの実装を行う場合、納期を超過してしまうリスクがあった。

今回の開発は、GoogleLoginを可能としたチャットサービスです。
現職ではCRUD処理を主とする業務システムの開発が主であるほか、バックエンドはフロントエンドと比較し得意ではないため、私のスキル不足の原因からバックエンドを自前実装することは工数上リスクであると考えました。

バックエンドを実装する場合、Express(node.js), Laravel(PHP)を候補としていました。
またその場合は、socket.io等の外部サービスを利用することでチャット機能を比較的容易に実装可能であるようです。
(socket.ioとは、リアルタイムWeb技術の一つであるwebsocket通信を利用したjsライブラリで、リアルタイムの双方向通信を実装可能なようです。)

参考サイト:
ExpressとPassportでOpenID Connect認証を実装する
OpenID Connect実装調査 (oidc-provider編)
IDトークンが分かれば OpenID Connect が分かる

他Baasと比較し、Firebaseが今回には最適であると判断したため。

GoogleLogin, リアルタイムのチャット機能ともに、特異な仕様ではないため、実装事例の多い技術を活用しようと思いました。
Firebase Authentication(FirebaseのOAuthクライアント)は、OAuth2.0, OAuth/OIDCの標準を満たしています。
(→Firebase Authentication
セキュリティ上のリスクは小さく、情報量が多い・公式ドキュメントがとても充実しているため、Firebaseを採用しました。

また、他Baasでは、PobNub, Milkcocoa等を候補として選定を行いましたが、情報量・導入事例の多さによるセキュリティ面の安心感・公式ドキュメントの観点から見て、Firebaseを活用するメリットが大きいと感じました。

参考サイト:
Firestore導入前に知ってほしい。3層に分解して、メリット・デメリット比較と使いどころを考える
MilkcocoaでBaaSを体験!~バックエンドの仕組みと使い方~第8回 リアルタイム系BaaSの徹底比較! 国内外BaaS 3社の特徴を理解して,技術選定の幅を広げよう!

Material UI

React版のマテリアルデザインのデザインライブラリです。
Atoms単位、Molecules単位のコンポーネントを提供しており、ドキュメントも充実しております。
Vue.jsのデザインライブラリのVuetify, Reactだとantや、bootstrap系にも種類は豊富ですが、その中からMUIを選定した理由としては以下があります。

  • 非常に高いカスタマイズ性
  • 私自身のマテリアルデザインへの慣れ
  • 情報量・導入事例

Storybook

コンポーネントカタログのデファクトスタンダードです。

Jest( +enzyme )

Jestは最もメジャーなJavaScriptのテスティングフレームワークです。
enzymeはReactのテストユーティリティで、jestと連携させることでshallowレンダリングやフルマウント等を使い分けることが出来ます。

Cypress

CypressはE2Eテストのライブラリです。
localhost:3000にアクセスして、このボタン押したらURLがー......とかをテスト出来ます。
実行過程を動画としてエクスポートしたり、リアルタイムで画面への実行を監視したり、初めて使ったのですが仰天しました。

GitHubActions

現在はまだjestのみですが、自動テストを行っています。
変更に対して他コンポーネントでのエラー、バグを検知出来るため、とても助かっております。

vercel

ホスティングはVercelを利用しました。
選定基準としてはCDパイプラインを握りやすく、セキュリティが強固であり、無料で使え、情報が多いものが良いと思っておりました。
vercelの採用理由としてはそれらに加え、Next.jsにフォーカスされていることや、UIがわかりやすい等です。

他の選定候補としては、Netlify, AWS amplify, cloud run, github pages, Cloudflare Pages, Firebase Hosting等がありました。
なるべく設定が簡単なものを、と思うと更に絞られますが、基本的には特別大きく変わるものは無い印象です。
vercelがNext.jsにフォーカスされてるのでvercelでいいや vercel最高!
vercelの無料のhobbyプランでは商用利用が規約上出来ないため、今後見直しは必要かも

GitHub-Flow

Git-Flowと迷いましたが、Git-Flowを個人でやるのはちょっと面倒過ぎたため、GitHub-Flowにしました。
現に、スプリントを早めに回しながら小規模リリースを繰り返しているので、GitHub-Flowにしたのは正解だったと思います。

Issueドリブン開発

タスクをまずissueに起こし、トピックブランチをfeature/#xxx_add_xxxxxxみたいに切る使い方をしています。
残りのタスクが見えるため、イマイチ頭の回らない時でも、その日行うものとして簡単な作業を選べたり、逆算して行動が出来るためこちらも割りと正解でした。
ただ、issueに起こすほどでもない小さなコミットをどうするか、は課題だったと思います。
私はfeature/odd_add_xxxxxxxxx雑用ブランチとして切っていますが、、、
うーん、、、あまり芸術的じゃない、、、

Atomic Design

コンポーネントのディレクトリデザイン手法です。
好みなので導入しました。
マイクロフロントエンド沼にハマりたいのでAtomicDesignを私は足掛かりにしながらもっと知見を深めたいです。

実装中の参考サイト、紆余曲折など

Firebase導入

1. 必要ライブラリのインストール

npm i firebase @types/firebase

概要:firebaseのライブラリ、型定義をインストール

2.  .env.localの作成

.env.local
NEXT_PUBLIC_FIREBASE_API_KEY=""
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=""
NEXT_PUBLIC_FIREBASE_PROJECT_ID=""
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=""
NEXT_PUBLIC_FIREBASE_MESSAGE_SENDER_ID=""
NEXT_PUBLIC_FIREBASE_APP_ID=""
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID=""

⇑こちらですが、NEXT_PUBLIC_を文頭に置かなければソースコードから読めません。(APIルートだと読めるんだったっけ......?)
少しの間、ここに詰まった経験があります。

3. process参照のために必要なパッケージをインストール

npm i --save-dev @types/node

4. 設定ファイルの作成
oukayuka/ReactFirebaseBook
React + TypeScript + Firebase環境を超高速で作る

Firebase * GoogleLogin

参考サイト:
公式ドキュメント(Firebase-GoogleSignin)
FirebaseでOAuth2の認証・認可を30分で実装する
【完全版】ReactのFirebase Authentication(認証)を基礎からマスターする
【完全版】ReactのFirebase Authentication(認証)を基礎からマスターする

AuthProviderの作成
概要:user情報をcontext化 ログイン・ログアウト
Next.jsの共通コンポーネントのPropsに型をつける方法 v8→v9リリース
Next.jsでFirebase Authenticationを使う(with Context API)
ウェブサイトで Firebase Authentication を使ってみる

認証パッケージについて
認証パッケージ Firebase認証

Firebase + Firestore * チャット機能

参考サイト:
React Firebase入門 Realtime Databaseでchatアプリ(一覧)
ReactとFirebaseを使ってチャットアプリにアイコン画像を追加する方法
React.js + firebaseでリアルタイムチャットアプリ作った。
TypeScript & React & Firebase で何かつくってみる3 React Hooks
firestore, vue.jsでリアルタイム同期のチャットを実装してみる [チュートリアル形式]

onSnapshotにより、特定のチャットルームのmessagesというコレクションを監視し、messagesにデータが追加された時のみ処理を行う旨の記述です。

const q = query(
      collection(db, "chats", `${router.query.id}`, "messages"),
      orderBy("postedAt", "desc"),
      limit(10)
    );
    onSnapshot(q, (snapshot) => {
      snapshot
        .docChanges()
        .reverse()
        .forEach(async (change) => {
          if (change.type === "added") {

pageでmessagesが更新された時、チャットを表示するコンポーネントでは最下まで自動でスクロールします。

  const ref = createRef<HTMLDivElement>();
  const scrollToButtom = useCallback(() => {
    ref!.current!.scrollIntoView({
      behavior: "smooth",
      block: "end",
    });
  }, [ref]);

useEffect(() => {
    if (!!props.messages) scrollToButtom();
  }, [props.messages, scrollToButtom]);

MUI + Storybook

以下のようにしました。

.storybook/main.ts
const path = require("path");
module.exports = {
  stories: [
    "../src/components/**/*.stories.@(js|jsx|ts|tsx)",
    "../src/components/**/stories.@(js|jsx|ts|tsx)",
  ],
  addons: ["@storybook/addon-actions", "@storybook/addon-essentials"],
  webpackFinal: async (config) => {
    config.resolve.alias = {
      ...config.resolve.alias,
      "@": path.resolve(__dirname, "../src/"),
      "@components": path.resolve(__dirname, "../src/components/"),
      "@types": path.resolve(__dirname, "../src/types/index.ts"),
      "@fixtures": path.resolve(__dirname, "../src/fixtures/index.ts"),
    };
    return config;
  },
};

webpackFinal以降では、webpackの設定をオーバーライドし、エイリアスの設定を行っております。

.storybook/preview.tsx
import CssBaseline from "@mui/material/CssBaseline";
import { ThemeProvider } from "@mui/material/styles";
import { StylesProvider } from "@mui/styles";
import { createTheme } from "@mui/material";
import * as NextImage from "next/image";

const theme = createTheme({
  palette: {
    primary: {
      light: "#226cd6",
      main: "#1976d2",
      dark: "#1565c0",
    },
    secondary: {
      light: "#ba68c8",
      main: "#9c27b0",
      dark: "#7b1fa2",
    },
    error: {
      light: "#ef5350",
      main: "#d32f2f",
      dark: "#c62828",
    },
    warning: {
      light: "#ff9800",
      main: "#ed6c02",
      dark: "#e65100",
    },
    info: {
      light: "#03a9f4",
      main: "#0288d1",
      dark: "#01579b",
    },
    success: {
      light: "#4caf50",
      main: "#2e7d32",
      dark: "#1b5e20",
    },
    background: {
      default: "#fff",
    },
  },
});

export const parameters = {
  actions: { argTypesRegex: "^on[A-Z].*" },
  docs: {
    inlineStories: false,
    iframeHeight: "700px",
  },
};
const OriginalNextImage = NextImage.default;

Object.defineProperty(NextImage, "default", {
  configurable: true,
  value: (props) => <OriginalNextImage {...props} unoptimized />,
});
const withThemeProvider = (Story, context) => {
  return (
    <StylesProvider injectFirst>
      <ThemeProvider theme={theme}>
        <CssBaseline />
        <Story {...context} />
      </ThemeProvider>
    </StylesProvider>
  );
};
export const decorators = [withThemeProvider];

かみ砕いていきます。

export const parameters = {
  actions: { argTypesRegex: "^on[A-Z].*" },
  docs: {
    inlineStories: false,
    iframeHeight: "700px",
  },
};

parametersとして、actions-addonのマッピングと、docs-addonのinlineStories:falseを指定しています。
※ver6.2.x, .3.x系では不具合が発生することがあるようなので、注意してください。 => https://github.com/storybookjs/storybook/issues/15143

inlineStoriesについて

Inline rendering
In Storybook’s Canvas, all stories are rendered in the Preview iframe for isolated development. In Storybook Docs, when inline rendering is supported by your framework, inline rendering is used by default for performance and convenience. However, you can force iframe rendering with docs: { inlineStories: false } parameter, or inline={false} in MDX.

インラインレンダリング
StorybookのCanvasでは、孤立した開発のために、すべてのストーリーはプレビューiframeでレンダリングされます。Storybook Docs では、フレームワークでインラインレンダリングがサポートされている場合、パフォーマンスと利便性のために、デフォルトでインラインレンダリングが使用されます。しかし、docs を使用して iframe レンダリングを強制することができます。{inlineStories: false } パラメータ、または MDX で inline={false} を指定することで、iframe レンダリングを強制することができます。

inline renderingが発生する事で、Material uiのコンポーネントを利用しているコンポーネントのdocsが表示できない場合がありました。
私はdocs.inlineStoriesをfalseにすることで解決しました。
原因については、判明次第追記しようと思います。

const OriginalNextImage = NextImage.default;
Object.defineProperty(NextImage, "default", {
  configurable: true,
  value: (props) => <OriginalNextImage {...props} unoptimized />,
});

Next/Imageのタグを利用して画像を読み込む場合、このようにするとエラーが出ません。
(デフォルトでは確かエラーを吐いてNext/Imageタグを読み込めませんでした。)

const withThemeProvider = (Story, context) => {
  return (
    <StylesProvider injectFirst>
      <ThemeProvider theme={theme}>
        <CssBaseline />
        <Story {...context} />
      </ThemeProvider>
    </StylesProvider>
  );
};
export const decorators = [withThemeProvider];

MUIのテーマプロバイダです。
previewファイルで読み込まなければ、theme.color系が使えないはず

Genericsを使ったComponentのStories化

今回はジェネリクスは使っていませんが、以下のようにするとキレイに書けます
https://qiita.com/Itamaster/items/ca5b2b05144b17427cb3

Jest + enzyme

react17系はenzymeのadapterがまだ公式から出ていないため、以下を利用しました。
https://www.npmjs.com/package/@wojtekmaj/enzyme-adapter-react-17

設定

.jest/jest.config.js
module.exports = {
  roots: ["../"],
  transform: {
    "^.+\\.(j|t)sx?$": "babel-jest",
    "^.+\\.(ts|tsx)$": "ts-jest",
  },
  globals: {
    "ts-jest": {
      tsconfig: "<rootDir>/tsconfig.jest.json",
    },
  },
  moduleNameMapper: {
    "@/(.+)": "<rootDir>/../src/$1",
    "@fixtures": "<rootDir>/../src/fixtures/index.ts",
    "@components/(.+)": "<rootDir>/../src/components/$1",
    "\\.(css|scss)$": "<rootDir>/../node_modules/jest-css-modules"
  },
  testPathIgnorePatterns: ["/node_modules/"],
};
.jest/setupTests.js
import { configure } from "enzyme";
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
configure({ adapter: new Adapter() });
.jest/tsconfig.jest.json
{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "jsx": "react",
  }
}

これでshallow, mount使えるので便利です

LICENCE

とりあえずMITライセンスにしました。

alias設定

とりあえずtsconfがこんな感じです

tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "esModuleInterop": true,
    "module": "commonjs",
    "jsx": "preserve",
    "lib": [
      "es2019",
      "dom"
    ],
    "sourceMap": true,
    "target": "es6",
    "noUnusedLocals": true,
    "resolveJsonModule": true,
    "skipLibCheck": true,
    "allowJs": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "moduleResolution": "node",
    "isolatedModules": true,
    "paths": {
      "@/*": [
        "./src/*"
      ],
      "@components/*": [
        "./src/components/*"
      ],
      "@types": [
        "./src/types/index.ts"
      ],
      "@fixtures": [
        "./src/fixtures/index.ts"
      ],
      "@hooks": [
        "./src/hooks/index.ts"
      ],
      "@routes": [
        "./src/constants/routes.ts"
      ]
    },
    "incremental": true
  },
  "include": [
    "next-env.d.ts",
    "src/**/*.ts",
    "src/**/*.tsx"
  ],
  "exclude": [
    "node_modules"
  ]
}

AtomicDesign(ディレクトリ構造)

AtomicDesignの導入・運用は、なるべく多くのOSSを参考にし行いたかったのですが、star数の多いリポジトリがなかなか見つからず...
(思想から大好きなんですが、react,vue共に公式リファレンスではコンテキスト別でのコンポーネント分割を推奨しています。落ち目なんでしょうか......)
良いリポジトリ等、コメント欄を使って皆さまと共有していければ嬉しく思います......!

https://github.com/saleor/saleor-storefront
https://github.com/danilowoz/react-atomic-design/tree/master/src/components/templates

電気通信事業開業届について

法律上の話なのですが、DM機能やオープンでないチャットルーム機能等のあるサービスは「電気通信事業」と見做されるため、総務省への届け出を行い、認可される必要があります。(ご注意ください)

各自治体に届け出用のフォーマットがあるため、住んでいる都道府県を検索ワードに含めて調べてみてください。

Register as a new user and use Qiita more conveniently

  1. You can follow users and tags
  2. you can stock useful information
  3. You can make editorial suggestions for articles
What you can do with signing up
27
Help us understand the problem. What are the problem?