ReactのコードをHooksにリファクタリングしていく話

フロントエンジニアの渡辺(@pentla)です。

AI 緊急情報サービスの「FASTALERT」は、Reactをフロントエンドのスタックとして採用しており、3年ほどコードベースは大きく変えずに運用しています。その過程で、Flow → TypeScriptへのスタックの変更など、継続的にリファクタリングを進めています。

tech.jxpress.net

今回は、Classベースのコンポーネントを、Hooksを使って関数ベースのコンポーネントに書き直す話です。

今回の対象読者は、

  • Reactを書いたことがある方
  • Reactのパフォーマンスについて気になっている方
  • Hooksは知っているけれど、プロダクション環境に入れるか迷っている方

を対象にしています。

Hooksとは

v16.8から追加された機能です。今までは、Stateやライフサイクルメソッド(表示時に一度だけ呼び出したいときなど)の機能はClassを使って書く必要があったのですが、このHooksを使えば関数ベースのコンポーネントでもStateを扱えたり、「表示後に一度だけ呼び出したい」などの要件を満たすことができるようになりました。他にもHooksを使うことで利点があり、このあたりに書かれているので、実際に使おうと思っている方は一読の価値ありです。

ja.reactjs.org

それぞれのHooksの特徴

hooksはライフサイクルの考え方が少し特殊で、最初はやや違和感があるかもしれませんが、少しずつ慣れていくと、思い通りにコンポーネントの状態を管理することができます。 簡単なhooksの紹介をします。

useState

stateの書き方を変えるだけなので、最もわかりやすいです。

// before
constructor() {
  this.state = {
      isSample = true
  }
} 

// after
[isSample, setSample] = useState(false)

公式ドキュメント「useState」

useEffect

componentDidUpdateなど、特定のタイミングでのみ起動させたいときは、useEffectを使います。 気を付けたいのは、第二引数です。useEffectの中で使用している変数、関数についてはすべてuseEffectの第二引数の配列に入れます。この引数に値を入れることで、「この中の値に変化があった場合のみ、useEffectの中の処理を走らせる」ことができます。

// before
componentDidMount() {
  this.callAPI(this.props.id);
}

// after
useEffect(() => {
  callAPI(props.id)
}, [props.id, callAPI])

公式ドキュメント「ヒント: 副作用のスキップによるパフォーマンス改善」に詳しい処理の内容があります。

リファクタリングのモチベーション

Reactのライフサイクルメソッドに、今後いくつかの修正が入ります。具体的には、ComponentWillReceiveProps、ComponentWillMount、ComponentWillUpdateの3つの関数がdeprecatedになります。

この機能は、Reactのv17.0以降削除されることが決まっていて、その際はUNSAFE_というプレフィックスをつけることが求められます。 新しいライフサイクルメソッドを使って書き直すことも可能ですが、せっかくの機会なので、少しずつ関数ベースのコンポーネントに書き直しています。

Hooksに書き直したときの利点

コード量が減った

明らかに減ります。このようにstateを少し使った簡単なコンポーネントであれば、行数もかなりの数減らせることができます。21行から9行と、半分程度まで減っていますね!

Classベースのコンポーネント: 21行

export default class MemberList extends PureComponent<Props, State> {
  constructor(props: Props) {
    super(props)
    this.state = { filter: { name: '' } }
    this.handleClickRow = this.handleClickRow.bind(this)
  }

 handleClickRow(rawId: number) {
    history.push(`/xxxx/${rawId}`)
  }

  render() {
    const { members } = this.props
    const {
      filter: { name }
    } = this.state
    // フィルタ機能
    const filteredMember = (members && members.filter(m => m.name.includes(name))) || []
    return <div>{...レイアウト}</div>
  }
}

関数ベースのコンポーネント: 10行

const OrganizationList: React.FC<Props> = ({ organizations }) => {
  const [filter, setFilter] = useState('')
  const handleClickRow = (rawId: number) => history.push(`/xxxx/${rawId}`)
  // フィルタ機能
  const filteredMember = useMemo(() => members && members.filter(o => o.name.includes(filter))) || [], [members, filter])
  return <div>{...レイアウト}</div>
}

export default MemberList

パフォーマンスの考え方がわかりやすくなった

Memo化を使うと、描画の際のパフォーマンスの調整が簡単になります。今までは、PureComponentsなどで描画回数を制限するなどしていましたが、useCallback, useMemoなどメモ化を使用することで変数ごとに計算回数の調整がしやすいです。

今までは、shouldComponentUpdate関数を使って、「このコンポーネントをいつ描画するか」を管理する必要がありました。例えば、下は、webページにタイトルを表示するReact.Compoentの例です。

class Title extends React.Component {
  render() {
   // すっごく重い処理
   const title = heavyFunction(props.title)
   return <div>{props.title}</div>
  }
}

class Parent extends React.Component {
  render() {
    return (
      <div>
        <Title title={this.props.title}/>
        {...その他いろいろなコンポーネント}
      </div>
    )
  }

Titleコンポーネントには、すっごく重い処理が書かれています。Titleコンポーネントは、Parentコンポーネントの内部にあります。 仮に、Parentコンポーネントが再度描画された場合、このTitleコンポーネントは変更がないにもかかわらず、再度描画処理が走ってしまいます。この場合、titleは変更がないにもかかわらず大きい処理が走ってしまい、パフォーマンスに影響が出るケースがあります。

そのようなケースに対応するために、

  • shouldComponentUpdate関数で、props.titleに変更がなければ描画しないようにする
  • pureComponentを使う

などの対策を取る必要がありました。

Memo化を使うと、このようになります。

const Title = props => {
  // すっごく重い処理は、props.titleが変更されない限り走らない
  const title = useMemo(() => heavyFunction(props.title), [props.title])
  return <div>{props.title}</div>
}

useMemoを使うことで、props.titleに変更がない限り、再度重い処理が走ることはありません。useMemoの使い方については、下にも説明を記載しています。

書き直していく上でのtips

ここからは、FASTALERTを実際にリファクタリングした時の話です。

小さめのコンポーネントから取り組む

特に最初はuseEffect、useCallbackなどの特徴を掴んでいくために、小さめのコードをFunctional Componentに書き直していくことをお勧めします。小さいコンポーネントであればその分修正も少なく、挙動の変更も確認しやすいです。

無理に書き直さない

特にテストがないコンポーネントの場合や、ライフサイクルメソッドに複雑な処理を書いている場合、大きな変更を入れるとどうしても変更が読めない箇所というのも出てしまいます。目的は将来のアップデートに追従するためで、必ず全てのコンポーネントをhooksを使って書き直す必要はないので、代わりのメソッドで置き換えるか、一旦後回しにする選択肢をとっています。

まとめ

今回のリファクタリングの途中で、

  • 書き直している途中に明らかにパフォーマンスが悪い箇所がある
  • APIの呼び出し箇所のコードがわかりづらく、簡潔でない

箇所に気づけたりして、問題になる前にコードの見直しができたのも良かったです。コードの行数も目に見えて減るので、効果がわかりやすいです。

さいごに - フロントエンドをいい感じにするフレンズまってます!

JX通信社では、FASTALERTやNewsDigestといったプロダクトの改善, 特にフロントエンドまわりを頑張っています。

上記以外にも現在進行系で行っている取り組みがあります、仲間を募集しているので興味があるかた気軽にお話に来てください!

www.wantedly.com

FastAPI で独自に定義した API エラーも仕様書に自動反映したかった話

こんにちは、サーバーサイドエンジニアの @kimihiro_n です。 最近は FastAPI という Python の Web フレームワークが社内で密かなブームとなっています。 今回はその FastAPI を使ったエラー定義まわりの話をしたいと思います。

FastAPI とは

FastAPI の概要については先日ちょうど社内勉強会用に資料を作ったのでこちらを見てもらえるのが早いです。 ざっくり言えばシンプルなインターフェースとドキュメント(OpenAPI)の自動生成が強力なフレームワークになります。

OpenAPI でドキュメント管理

今回注目したいのはドキュメントの自動生成のほうです。 開発チームでもドキュメントとAPIの実際の仕様が一致しない問題が時々発生していて、どうドキュメントを管理していくかが課題となっています。 KPTで振り返った結果、「人がドキュメントを書くからメンテが大変なのであって、ソースから仕様書が動的に生成されれば問題は解消するのではないか」という希望から FastAPI へと焦点があたりました。

このフレームワークでは入力(リクエスト)と出力(レスポンス)の形式を Python のモデルとして記述してあげることで、自動で OpenAPI 形式のドキュメントを出力してくれます。 OpenAPI はツールが充実しており、APIを試験的に叩くための Web コンソールを利用できたり、様々な言語から API へアクセスするときのソースを自動生成をしたりできます。

ちょうど小さめの開発プロジェクトが立ち上がったので、検証かねて FastAPI で作ってドキュメントが実用に耐えうるのかやってみました。

ちなみに、FastAPI は WSGI ではなく ASGI のフレームワークなので AWS のサーバレス構成で動かせるかは懸念だったのですが、ちょうどSREチームのたっちさんが AWS Lambda で動かすサンプルを検証してくれており、スムーズにプロジェクトに投入できました。

エラーレスポンス問題

実際 API を作って、フロントの開発メンバーに生成された OpenAPI を仕様書として渡してみたところ、正常系に関してはうまく機能することができました。 しかし どんなエラーをハンドリングしなくてはいけないのか という点に関しては生成された OpenAPI では判別できず、別途仕様を共有する必要がありました。

デフォルトだと正常系の 200 のドキュメントと、リクエストが指定したモデルと合致しているかの 422 エラーしか OpenAPI に記載されていませんでした。 認証が不正なのか、渡してるパラメータがおかしいのか、それとも権限・状態がおかしいのかという情報はクライアント側の挙動を適切にするためにも不可欠なものです。 5xx 系のサーバー内部のエラーは一旦置いておくとしても、4xx 系のエラーについてはドキュメントとして明記しておきたいです。

独自エラーを OpenAPI に記載する

FastAPI のドキュメントを調べてみたところ、Python の Exception をそのままレスポンスとして登録する手段は見つからず、responses という引数に OpenAPI の定義を付加情報として加えてあげる必要がありました。

# https://fastapi.tiangolo.com/advanced/additional-responses/ より
@app.get("/items/{item_id}", response_model=Item, responses={404: {"model": Message}})
    async def read_item(item_id: str):
    ...

responses は辞書になっていて、それぞれのステータスコードをキーに、レスポンスとなるモデルを記述(もしくは OpenAPI の 構造をそのまま記述)することで、生成されるドキュメントにエラー情報を付与することができます。

ただ実際のユースケースとして、

  • エラーレスポンスのデータ構造はほぼ共通で変わることがない
  • エラーで重要なのは構造よりエラーの中身
  • HTTP ステータスコードとしては同じだがエラーの理由が異なるケースがある

といったことがあげられます。

OpenAPI はインターフェースを記述するためのドキュメントなので、エラー内容まで関与しないというのは正しいのですが、そうするとエラー管理のための別のドキュメントを用意しなくてはなりません。 当初の目的としては API ドキュメントを1つにまとめて楽に管理することなので、なんとかこの OpenAPI 上でエラーの種別まで列挙するようにしたいです。

実際にやってみた

やっと本題になります。

上記のエラーの種別の列挙までを FastAPI で実現できないかをやってみました。

f:id:nsmr_jx:20200615134744p:plain

実現したい API ドキュメントとしては上記のようなイメージになります。

応答するステータスコードが列挙され、200以外のときはそのレスポンスの内容も網羅されているのが理想的です。 こうすることで API を叩く側がどんなエラーをハンドリングしなくてはならないのかが明確になり、連携を円滑に進めることが出来ます。 (HTTPステータスコードとは別にエラーコードを定義するのもよさそうです。)

併せて、Python 側のコードも複雑になりすぎないということを目標とします。 Open API の定義をそのままコードに埋め込めてしまうので、愚直に返しうるエラーを文字列で手動編集する、みたいなことは避けたいです。 認証のエラー文言が変わる度、全部のビューを編集するのはつらい…。

エラーを定義

まずは Python で API 独自のエラーを定義していきます。

# https://github.com/pistatium/fastapi_error_sample/blob/master/api/errors.py

class ApiError(Exception):
    """ エラーの基底となるクラス """
    status_code: int = 400
    detail: str = 'API error'  # エラー概要


class DontSetDummyParameter(ApiError):
    status_code = 403
    detail = 'ここに値をセットしないでください'


class InvalidFizzBuzzInput(ApiError):
    status_code = 400
    detail = 'input の値が不正です'


class WrongFizzBuzzAnswer(ApiError):
    status_code = 400
    detail = '不正解です'

ApiError という共通のエラーを定義してあげて、これを継承して具体的なエラーを列挙していきます。 同じエラーは同じ HTTP ステータスコードを吐くので、ここで一緒に定義してしまいます。

View でエラーを使う

# https://github.com/pistatium/fastapi_error_sample/blob/master/api/main.py

@app.post("/check_fizzbuzz", response_model=FizzBuzzRequest)
def check_fizzbuzz(req: FizzBuzzRequest):
    if req.dummy:  
        raise DontSetDummyParameter()  # # このパラメータを設定していたら強制的にエラーを発生。※実際は validator でやると綺麗
    try:
        expected = fizzbuzz(req.input)
    except ValueError:
        raise InvalidFizzBuzzInput()  # input の入力が不正であればエラー。※実際は validator でやると綺麗
    if expected != req.answer:
        raise WrongFizzBuzzAnswer()  # input と回答がマッチしなければエラー
    return FizzBuzzResponse(message='Good!')

例外を定義したら、FastAPI の View の中でその例外を利用するようにします。 ApiError を継承したエラーは HTTP のステータスコードをもってるので、View 関数の中でのみ利用するようにするとエラーの管理が簡単です。

エラーハンドラを登録する

@app.exception_handler(ApiError)
async def api_error_handler(request, err: ApiError):
    raise HTTPException(status_code=err.status_code, detail=f'{err.detail}')

独自のエラーを勝手に定義して raise するだけだと FastAPI がエラーとして認識できないので、500 エラーになってしまいます。 なので、FastAPI が ApiError を扱えるようエラーハンドラを追加します。 ここでは FastAPI の HTTPException にあわせて再度 raise しています。 こうすることで FastAPI 内部のエラーとも構造を共通化できるため、インターフェースとしてすっきりします。 もちろん独自の構造を定義して返してあげてもよいです。 HTTP ステータスコードとは別にユニークなエラーコードを振って返すようにすると、クライアントでもハンドリングしやすそうです。

ドキュメントにエラーを登録する

エラーハンドラを登録することでアプリケーションとしては問題なく動くのですが、OpenAPI の仕様書にはまだエラー情報が反映されてません。 自動でエラーを登録できると理想的なのですが、コードを静的に解析したりしない限りはどんな例外が発せられるかは把握できません。 なので半手動でエラーレスポンスを列挙することにします。

def error_response(error_types: List[Type[ApiError]]) -> dict:
    # error_types に列挙した ApiError を OpenAPI の書式で定義する
    d = {}
    for et in error_types:
        if not d.get(et.status_code):
            d[et.status_code] = {
                'description': f'"{et.detail}"',
                'content': {
                    'application/json': {
                        'example': {
                            'detail': et.detail
                        }
                    }
                }}
        else:
            # 同じステータスコードなら description へ追記
            d[et.status_code]['description'] += f'<br>"{et.detail}"'
    return d

このような ApiError を OpenAPI の書式に変換するユーティリティ関数を用意してあげます。 同じ HTTP ステータスコードのものが複数あると上書きされてしまうため、プログラム的に重複を確認し、ぶつかっていれば detail への追記を行うだけにしています。

そして先ほどの View 関数のルーティング部分を

@app.post("/check_fizzbuzz", response_model=FizzBuzzRequest,
          responses=error_response([DontSetDummyParameter, InvalidFizzBuzzInput, WrongFizzBuzzAnswer]))  # 追加
def check_fizzbuzz(req: FizzBuzzRequest):
   ...

のように変更します。 View 内で raise している ApiError をあつめて、error_response ユーティリティ関数へ渡しているだけです。 View の中で扱うエラーが増減したときは忘れずに反映する必要はありますが、View 以外で ApiError を発生させないみたいなルールとともに実装すれば管理しやすいかと思われます。

これにより、OpenAPI へのエラー定義を統一して扱えるようにできました。

OpenAPI で表示

FastAPI を動かしてみると上記のような OpenAPI の定義を生成することが出来ます。 開発サーバーの /docs にアクセスして UI ごと表示してもいいですし、 Python から定義を JSON として出力し外部のツールから利用することも可能です。

Swagger の Editor を利用するとこのような Web コンソールを出力できます。

GitLab だとデフォルトで OpenAPI に対応しているので、openapi.json をレポジトリに入れておくだけで勝手に UI 付きで表示してくれるので便利です。

おわりに

FastAPI を利用して API エラーも含めてドキュメント化することができました。 管理のコストを下げつついい感じの仕様書をつくれるので便利ですね。 コーディングする上でもはまりどころが少なく、FastAPI もっと活用していきたいと思いました。

説明に利用したコードのサンプルはこちら。

Flow/PostCSS の大規模プロジェクトを TypeScript/emotion に移行して数万行のプルリクを投げた話

JX通信社CDOの小笠原(@yamitzky)です。

AI 緊急情報サービスの「FASTALERT」は、報道機関や公共機関に導入いただいている(お堅めな) BtoB SaaS でありながら、 事業開始当初から React を使った Single Page Application(SPA) として作っています。 2017年には、より信頼性のあるフロントエンドを提供するため、 Facebook の Flow を導入しました。しかし、昨今の TypeScript の盛り上がりや、社内の他プロダクトで TypeScript を使っていることなどを受けて、フロントエンドのアーキテクチャを大幅に見直しました。

今回取り組んだ大きな変更は、

  • Flow から TypeScript への移行 (型チェックの移行)
  • TypeScript 化に合わせた、 babel-plugin-proposal-*** の廃止 (文法の移行)
  • PostCSS から [emotion] への移行 (CSS の移行)

などです。

今回のブログでは大規模プロジェクトにおいて、Flow から TypeScript、PostCSS から emotion へ移行した際の勘所や、知見などをご紹介します。Flow を使ったことないけど、JavaScript から移行したい方にも役立つかと思います。

Flow と TypeScript

Flow は Facebook が中心に作っている JavaScript のための静的型チェッカーで、TypeScript は Microsoft が中心に作っている JavaScript の型付きな上位互換言語(superset)です。 Flow は JS への型宣言拡張、TypeScript はプログラミング言語と、微妙な立ち位置は違いますが、共通するモチベーションとして 「静的型付き言語でない JavaScript に型安全を持ち込む」 というものがあるかと思います。

また、両者の文法も似ている部分があり、下記は TypeScript でも Flow でも同じような挙動をするコードです。

// @flow
function square(n: number): number {
  return n * n
}
square("2") // Error!

2017年に技術選定した当時は TypeScript の方が盛り上がるとは思っていませんでした*1。2020年現在、TypeScript の方が GitHub 上のスター数やサードパーティーの型宣言も多く、React との相性や DX(開発者体験) も良いと感じています。

PostCSS と emotion

React などの SPA でデザインを実装していく際には、グローバルな CSS ではなくモジュール化された(コンポーネントに閉じた)CSSを使うことが望ましいです。FASTALERT ではそのために、PostCSS を利用していました。

PostCSS を使うことによって、

  • モジュール化された、グローバルを汚染しない CSS の実現
  • 自動的な vendor prefix
  • ネストした CSS のような新しい構文
  • 変数を活用した DRY な CSS 定義

などが実現できます。一方で、emotion でも同様のことは実現できます。emotion は CSS in JS と呼ばれるような派閥の一つで、JavaScript(TypeScript) 内に CSS を書くことができます。

そのため、「CSS の構文で書くことができる」という点で PostCSS と emotion は共通してるものの、定義方法は大きく異なります。

PostCSS版

/* index.css */
.square {
  width: 100px;
  height: 100px;
}
// index.tsx
import styles from './index.css'

const Component = () => <div className={styles.square}>四角形</div>

emotion版

// index.tsx
const Square = styled.div`
  width: 100px;
  height: 100px;
`
const Component = () => <Square>四角形</Square>

PostCSS では CSS に書く、emotion では JavaScript (TypeScript) 内に直接書いています。

emotion の良いところは、なんと言っても React x TypeScript の流儀で書くことができる ことです! CSS プロパティも型定義されていたりと、型の恩恵 が受けられます。同じ理由で、Styled System を使ってのデザインシステムの構築もしやすいです*2

移行方法

mizchi 氏の「非破壊 TypeScript」 を参考にしつつ、次のようなステップで行いました。ピックアップしてご紹介します。

  1. TypeScript 向けにライブラリのインストールや設定変更
  2. .js の拡張子を .ts(x) にして、 // @flow のコメントをなくす
  3. babel を走らせると怒られるので、 TypeScript 文法の誤りを地道に直す
  4. tsc --noEmit するとやっぱり怒られるので、TypeScript の型エラーが発生したところを地道に解決する
  5. 無理だったら諦めて as any.js
  6. .css を .tsx 内に ひたすらコピペ
  7. eslint で自動フォーマットを走らせて微調整

これらの作業に特に面白いツールなどは使っておらず、基本的には力技と、VSCode の正規表現による置換、そして TypeScript に怒られドリブンで進めました。気合があればできる と思います。

TypeScript 向けライブラリの設定

次のような変更を行いました(かなり省略しています)

package.json

+    "@emotion/core": "^10.0.27",
+    "@emotion/styled": "^10.0.27",
....
-    "@babel/plugin-proposal-class-properties": "7.1.0",
-    "@babel/plugin-proposal-decorators": "^7.0.0",
-    (@babel/plugin-** が続くため略)
-    "@babel/preset-flow": "^7.0.0",
+    "@babel/preset-typescript": "^7.8.3",
+    "@typescript-eslint/eslint-plugin": "^2.13.0",
+    "@typescript-eslint/parser": "^2.13.0",
+    "babel-plugin-emotion": "^10.0.27",
+    "emotion": "^10.0.27",
-    "flow-bin": "^0.50.0",
+    "jest-emotion": "^10.0.27",
-    "postcss-cssnext": "^2.10.0",
-    "postcss-extend": "^1.0.5",
-     (postcss-* が続くため略)
+    "typescript": "^3.7.4",

.babelrc

-    "@babel/preset-flow"
+     "@babel/preset-typescript",
...
-    "@babel/plugin-transform-flow-strip-types",
-    "@babel/plugin-proposal-function-bind",
-     (@babel/plugin-*** が続くため略)
+    [ "emotion", { "labelFormat": "[filename]--[local]" } ]

.webpack.config.js

-      extensions: ['.webpack.js', '.web.js', '.js', '.jsx', '.json'],
+      extensions: ['.webpack.js', '.web.js', '.js', '.jsx', '.json', '.ts', '.tsx'],
...
-          test: /\.jsx?$/,
+          test: /\.[jt]sx?$/,
...
-          { loader: 'postcss-loader' }

特筆すべきは、Webpack 関連の設定を大きく変えずに済んでいる ことです。

TypeScript 化の方法は2つあり、

  • tsc コマンドなどを使い、TypeScript のツールセットで実現する
  • @babel/preset-typescript を使う

今回は後者にしました。すでに babel を活用している場合は、 @babel/preset-typescript を使うと簡単でおすすめです*3

無理だったら諦めて as any.js

「非破壊TypeScript」にもある通りロジックを変更しないのがとても大事です。ロジックを変更すると、なにかあったときの切り分けが難しくなります。ロジックを変更するぐらいだったら as any でキャストしたり、 .js のままで放置したりしました。

初めて TypeScript 化する場合は、積極的に諦めましょう! Done is better than perfect です。

たとえば、次のようなものにすら as any を使いました。*4

document.querySelector('.some-class-name').setAttribute('hidden', true as any)

.css を .tsx に移行

emotion では、次の2つの書き方が使えます。前者は css として書くことができる反面*5、後者では px を省略したり、パラメータをすっきりした形で使えます。

const Wrapper1 = styled.div<{ height: number }>`
  text-align: center;
  width: 240px;
  height: ${({ height }) => `${height}px`};
  & + & {
    margin-top: 4px;
  }
`
const Wrapper2 = styled.div<{ height: number }>({
  textAlign: 'center',
  width: 240,
  '& + &': {
    marginTop: 4,
  }
}, ({ height }) => ({
  height
}))

今回は「PostCSSから移行する」ということを念頭において、前者の CSS チックな書き方を採用してひたすらコピペしました。個人的には両方とも使いますが、主に後者の object 方式を使うことが多いです。

かかった期間

だいたいの作業としては、年末年始にテレビを見ながら2,3日でかきあげました*6。地道な作業が多いので、ゆっくりお酒を飲んだりバラエティ番組でも見ながらやりきるのがおすすめです。

移行してよかったところ

  • TypeScript 化によって、 Flow では気づけなかった型のミスが出てきた
  • PostCSS では気づけなかった、 使われてない CSS を削除できた
  • 社内メンバーがコントリビュートしやすくなった

1つめに関しては、React の 型指定の誤りや、型の指定が緩すぎるものなどが出てきました。Flow の使い方が悪かった可能性もありますが、TypeScript の方が型定義は厳しめな印象です。例えば、 Set の型パラメータの指定がなくて怒られました。無理なものは諦めた一方、地道な型解決でかなり型安全にできたと思います。

2つめに関しては、typescript-eslint の未使用変数チェックを通るため、使われてない CSS をあぶり出すことができます。仮にうっかり削除しても TypeScript のコンパイルエラーで気付けるので、削除の心理的障壁がありません。

3つめに関しては、TypeScript の方がエコシステムが充実していたり安心感があったりするのか(主観です)、プロジェクトのコアメンバーでなくてもコントリビュートしやすくなったように感じます。実際、FASTALERT 新型コロナ機能のプロジェクトでは、SRE のエンジニアやインターン生のコントリビューションもありました。

speakerdeck.com

まとめ

今回は、信頼性の求められる BtoB SaaS で、Flow を TypeScript に、PostCSS を emotion に移行したときの話でした。差分は数万行に渡ります*7。今回得たベストプラクティスな知見としては、

  • 非破壊 TypeScript を参考にする
  • TypeScript に怒られながら進める
  • ロジックは絶対に変えない、積極的に諦める
  • なにかのついでにやると、地道な作業が多くても問題ない

以上です。後学のため、もしツールなどでスマートに移行する方法があれば教えて下さい!

宣伝

今回ご紹介したとおり、JX 通信社のフロントエンドプロジェクトは、 TypeScript、React、emotion が活用されていたりとモダンな開発環境です。フロントエンド(や Pythonも!) を一緒に書きたいインターン生を積極募集しています。まだ新型コロナも収束していませんので、エリア不問&リモートOKです!

*1:Flow も React も同じ Facebook 由来のため

*2:新型コロナダッシュボード爆速リリースの舞台裏 〜小さく始めて大胆に変えるフロントエンドプロジェクト〜 - JX通信社エンジニアブログ で紹介したような共通ライブラリ化が容易というメリットなどもあります

*3:ただしトランスパイル時の型チェックはされないので、 tsc --noEmit を走らせて型チェックをしてください

*4:ちゃんとやれば any を使わず書き換えられますし、なんならもっと React っぽく移行できそうですが、動いていたものを最優先

*5:プラグインによる色付けもしやすいと思います

*6:他作業もあり、マージされるまでにはこれ以上かかっています

*7:正直に言うと、 eslint の自動フォーマットもあるので盛ってます

新型コロナダッシュボード爆速リリースの舞台裏 〜小さく始めて大胆に変えるフロントエンドプロジェクト〜

JX通信社CDOの小笠原(@yamitzky)です。

JX通信社は「今起きていることを明らかにする報道機関」というミッションの元に、新型コロナリアルタイムダッシュボードを 2月16日 から提供し続けています。今回は、「新型コロナプロジェクト」の発足から現在に至るまでの、プロジェクトの進化についてご紹介します。

f:id:yamitzky:20200529014632p:plain

プロジェクト発足

そもそものプロジェクトの発足としては、2月14日の下記のツイートが発端でした。およそ 2 日でリリースしたことになります。

当時は、東京都のような自治体公式の特設サイトや、国内全体の動向をまとめたサイトはほぼありませんでした。国内の公共機関・報道機関のなかで、かなり速くリリースできた部類に該当するかと思います。

フェーズ1: Vue.js でのプロトタイピング

現在は React で作られているプロジェクトですが、当初のプロトタイピングフェーズでは Vue.js を使っていました。

Vue.js は Progressive(漸進的) なウェブフロントフレームワークです。最もシンプルなのは、次のような単体の HTML として配信する方法です。この形式ではトランスパイル(Webpack によるビルド等)の必要もなく、HTML 単体を配信するだけでも SPA になり、非常に手早くプロジェクトを開始できます。

// index.html
<html>
  <body>
    <div id="app">{{ message }}</div>
    <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
    <script>
      new Vue({
        el: "#app",
        data: () => ({ message: "hi" })
      })
    </script>
  </body>
</html>

今回のプロジェクトでは「なるべく早くリリースする」という目的があり、ツール周りでつまずきたくなかったので、まずは Vue.js で雑にプロトタイピングしました。

フェーズ2: React × TypeScript × Emotion × Parcel への移行]

f:id:yamitzky:20200529030051p:plain

プロトタイプとして実現したものの、NewsDigest のウェブメディアで配信したいという課題がありました。NewsDigest の CMS は、Django (Python) で管理しており、必ずしも SPA との相性が良いとは言えません。

そこで次のような形で、 CMS にたった 2 行のタグを埋め込むだけで新型コロナのダッシュボードを配信できるようにしてみました*1

f:id:yamitzky:20200529020642p:plain

技術的な必要条件としては、

  • 1 つの script タグに JavaScript の依存関係を全て詰め込む
  • 1 つの script タグさえ埋め込めば、デザイン(stylesheet) も再現できる

です。これを最短で実現するため、バンドラー(Webpackの代替)として Parcel を利用しました。Parcel はほぼ設定不要で使えるバンドラーなので、今回のような急ぎのプロジェクトにピッタリです。また、CSS ファイルに依存せずデザインを指定するため、Emotion も使っています。Emotion は React と相性の良い TypeScript ベースのデザイン用ライブラリで、JS 内にデザイン指定を埋め込めます。

// index.tsx
const Wrapper = styled.div({
  padding: 8,
  color: 'red',
  display: 'flex',   // vendor prefix も自動付与
})
export const Component = () => <Wrapper>some content</Wrapper>

つまり、プロトタイピングから本番配信までの間に、Vue.js から React・TypeScript にまるっと置き換えたことになります*2

フェーズ3: gitでのデータ管理から、サーバーレスなデータ管理へ

新型コロナの感染者数字は、リアルタイムに更新されます。実は、プロジェクト初期ではデータベースを用意しておらず、TypeScript のソースコード内に直書きする形でデータ管理をしていました。数値更新するたびに GitLab CI が動いて、JS ファイル自体をデプロイしていたのです・・・!

f:id:yamitzky:20200529130138p:plain

// 実際の data.ts
export const data = {
  modified: '2020.02.19 11:30',
  japanStats: {
    infected: 616,
    infectedChange: 96,
    dead: 1,
    deadChange: 0,
    // クルーズ船(ダイヤモンド・プリンセス号)
    cruiseInfected: 542,
    cruiseInfectedChange: 0
  },
   ...
}

さすがに、機械化しづらい、型安全でなく事故が起こりやすい、git でデータ更新をするのは属人性が高い、Python 製の FASTALERT API としての提供が難しい... という事情もあり、Google Spreadsheet をデータベースとして利用し、JSON から使えるようにしました。また、データ更新の仕組みは GitLab CI ではなく、AWS Lambda でサーバーレスな形で動かすようにしています。

f:id:yamitzky:20200529130900p:plain

フェーズ4: Next.js での SSR への移行

<script> タグでの配信は、SEO 的に弱い可能性があるのではないか、という懸念が生まれました。そこで当初の Parcel から、Next.js へ移行しました。Next.js はReact のフレームワークで、サーバーサイドレンダリングの機能などが組み込まれています。

この Next.js のサーバーは、Amazon ECS(Fargate) 上にデプロイしています。

フェーズ5: ウィジェットとしての外部提供と yarn monorepo 化

JX 通信社が提供している新型コロナダッシュボードは、メドピア社LINE社にウィジェットとしても提供しています。また、NewsDigest だけでなく FASTALERT 内でも配信しています。NewsDigest での提供、ウィジェットとしての提供、FASTALERT 内での提供、、、これらを 1 プロジェクトでやるのは現実的ではなかったため、次のような monorepo 構成を行っています*3

f:id:yamitzky:20200529164607p:plain

@corona/components ・・・ 各種グラフを提供するライブラリ。FASTALERT からも npm install している
@corona/server ・・・ @corona/components に依存する、next.js のプロジェクト。newsdigest.jp 用
@corona/widget ・・・ corona/components に依存する、webpack のプロジェクト。ウィジェット配信用

この monorepo 構成は、yarn の workspace 機能を使っています。

また、@corona/server と @corona/widget では、ビルドツールなども異なっています。これは、プロジェクトの目的や、求められる安定性*4などに応じて、意図的に使い分けたものです。

うまく monorepo 構成にすれば、コードを使いまわしつつ、最適な技術選定ができるようになります。

フェーズ6: グラフライブラリの移行

プロジェクト当初は、chart.js と d3 などを使っていましたが、 vx という React 向けのグラフライブラリに移行しました。

Chart.js は canvas ベースのグラフライブラリです。しかし、 React のような宣言的な UI の思想との相性の悪さや、サーバーサイドレンダリングできない、柔軟にカスタマイズできないなどの課題がありました。

d3.js もデータ可視化に使っていましたが、厳密には「ドキュメントをデータに基づいて操作・構築するためのライブラリ」です。雑に言えば「DOMを操作するためのライブラリ」なので、 React(React-DOM)のような DOM 操作のライブラリと、役割的にかぶっています。

そこで、 React ベースの低レイヤーなデータ可視化ライブラリである vx に移行し、React だけで SVG での可視化をしています。

  • React 的な、宣言的データ可視化の実現
  • TypeScript の型
  • 柔軟なデータ可視化の実現

などができるようになりました。一方で、パフォーマンスチューニングや、コード量の増加などは起きています。

f:id:yamitzky:20200529115825p:plain
凡例や横軸の位置など、かなり微調整しています

まとめ

新型コロナウイルスに関しては、社会的な需要や、刻々と変わる状況などを踏まえ、かなりスピード重視でプロジェクトが発足しました。当初は git でデータ管理していたほど、一般的なアプリケーション構成のセオリーからは外れた作り方です。

一方で、新型コロナダッシュボード提供開始から3ヶ月経ち、ビジネスの状況に応じて大規模なアーキテクチャ変更(式年遷宮)を行っており*5「ビジネスとスピードと品質の両立」も実現できたと思います。これらの両立のポイントは「ビジネス要件に合わせて技術を使いこなし、いかに漸進的に進めるか」です。

f:id:yamitzky:20200529132312p:plain

今回のプロジェクトでは、かなりインターン生に協力いただいています。本当にありがとうございます! 引き続き、フロントエンドのエンジニアインターンを募集中です(まだ新型コロナは収束していないのでリモート中心です)。ぜひ一緒に、インパクトのある開発をしましょう!

*1:この JS 自体は、Amazon S3 と CloudFront で配信しています

*2:同じようなことを実現する方法は他にもあります。この技術選定は、慣れの部分が大きいです

*3:データ更新用サーバーレスパッケージなども含めると、10 個ほどパッケージが含まれています

*4:ウィジェットは絶対にサーバーを落としたくないため、SSR は行っていません。また、顧客ごとにカスタマイズする関係で SSG にもしていません

*5:プロジェクト発足から 3 ヶ月ぐらいしか経っていないのに、1000行以上差分のあるプルリクが複数あります。同僚から「走りながら車輪を全交換した話」というタイトル案が出るくらいです

コワクナイBigQuery - チームでデータを活用するための活動について

JX通信社でシニア・エンジニア&データ基盤エンジニアをしています, @shinyorke(しんよーく)です.

最近のStayHome生活は, 「YouTubeでUK Rock🎸, お笑いの動画を見まくる」「地元のお取り寄せグルメを楽しむ」「野球データ分析に集中する」で楽しんでいます.

JX通信社では, 「FASTALERT」などのプロダクトのデータをBigQueryに集約し,

  • 機械学習を用いたプロダクト開発・施策
  • プロダクト・サービスの改善に関する分析
  • 日々のイベントをメトリクス化して可視化(いわゆるBI的なもの)

に活用しています.

このエントリーでは,

???「BigQuery?使い方わからないし💰かかるって噂を聞くのでコワイよ」

ワイ将「BigQueryコワクナイヨー, ガンガンBigQuery使えるように仕組み作ったので乗っかりましょ!」

というお話を残したいと思います.

TL;DR

  • 「仕組みの理解」「コストの可視化」を愚直にやっていけば, BigQueryはコワクナイ. むしろ友だちになれる!
  • RDBMSとの違い(特にカラムナー), データサイエンスな人にはPandasとの共通点を教えれば仕組みはしっかり伝わる
  • コストの可視化は本気でやれば2日くらいでシュッとできます.

会社やご家庭でBigQueryをお使いな方の参考になると嬉しいです.

スタメン

全体像 - 今現在の姿

JX通信社では, 昨年の秋から全社的なデータ基盤を構築・運用を開始し, 利用者・使いたい人の声を元に日々改善を行っています.

データ基盤全体はGCP上に構築(ETLの一部タスクを除く*1), DWHはBigQueryを使っています.

利用者であるチームメンバー(エンジニアおよびデータを使いたい人すべて)には以下の図のような仕組みを提供しています.

f:id:shinyorke:20200425131222p:plain
【図】BigQueryを取り巻く仕組み

データ基盤を構築し, 運用し始めた当初は「Colab(Jupyter notebook)とRedash, Data Portal用意すればまあイケるのでは?」と思っていたのですが,

  • BigQueryは噂でしか聞いたことない, サービスのDBを直接分析する場合との違いは?
  • BigQueryはお金がかかる, という風潮. 「数百万円飛ぶでしょ?」的な

という声があったり雰囲気を感じたりしました.

確かに, 自分でBigQueryを使っていても「このクエリって流したらいくらだ💰?」とか気になります.

というわけで,

  • BigQuery(含むデータエンジニアリング)の正しい知識を普及しよう
  • 毎日コストを見ることができればええやん!

この2つをシュッとやることにしました.

BigQueryに関する正しい知識を

正しい知識を普及する, 植え付けるのであれば自分から動けばエエやん!...ということで,

  • BigQuery使いたいぞっていうメンバーがいるとき
  • データ分析のインターンがJoinする時のオンボーディング*2

...というタイミングで社内勉強会を不定期に開催しています*3.

これについては, 私が作ったテキスト(社内のKibelaに公開)を用いてやっています.

f:id:shinyorke:20200425133707p:plain
JX通信社版・BigQueryの教科書

このテキストのTL;DR的なモノを一部紹介すると,

  • BigQueryは普段使ってるRDBMSとは違い, DWHであり列指向データセットである.
  • select文で検索する, joinする, 集計する的なのはDBと変わらないが, 必ず必要な列だけ使う&範囲を絞って使う.
  • 必要な列だけ取得し, 対象範囲を絞ってもらえればお安く使える.

必要なことを最短距離で覚えられる内容にしています. 3分34秒以上かかるかもですが*4.

ちなみに教え方のコツとしては,

  • RDBMSを知ってる人には「列指向」な事をしっかり教える. MySQLやPostgreSQLで培ったIndexとかの知識は使えないよっていうのを理解してもらう.
  • そもそもDBやSQLを触ったことがない(インターンなど若い人に多い印象)にはPandasやRでよく使うDataframeにたとえて教える.*5

これでいい感じになります.

常にコストを監視できる仕組み

正しい知識を身に着けてもらった後, BigQueryの利用頻度が増えてウッキウキでしたが,

インターン生「shinyorkeさんこのクエリ実行して大丈夫ですか💰?」

ワイ将「お, おう...(そっか心配だよね)」

っていうやり取りが増えました.

せっかくいい感じに分析してもらったりレポート出してもらってるのに彼らを心配にさせちゃいけないよなあ〜ということで,

毎日GCP全体およびBigQueryのコストを通知するSlack Botを開発・運用し始めました.

f:id:shinyorke:20200426122234p:plain
毎日通知, 一定金額超えたら赤くなります

大変ありがたいことに, GCPにはアカウントの利用料金を定期的にBigQueryに流す仕組みが提供されており,

  • 毎日BigQueryの特定のテーブルにデータが入る
  • ↑のコストをSlackにつぶやかせればいい!

という実にシンプル過ぎるやり方でできる事がわかり, 二日ちょっとで開発・運用スタートしました.

ちなみに仕組みはこんな感じです.*6

f:id:shinyorke:20200425140045p:plain
コスト監視マンのアーキテクチャ

数行のSlack Botでできちゃうのでこれは強くオススメです!

結び

「コワクナイBigQuery」の成果ですが,

  • BigQueryに関する知識がついた・恐怖心が無くなったメンバーが使ってくれたり
  • インターン生たちが自主的にSQLを覚えて, データ分析やダッシュボード作りに活用してくれたり
  • 特定のパターン・クエリに対するお値段という「相場」が誕生したことにより, 大胆にBigQueryでガンガンSQLを流すことが可能に

...という, 「まずは使ってみよう, アウトプットだそう!」という流れができました.

基本を教える, コスト感を掴むという視点で心理的安全性ができたのかなーというのが私の感想です*7.

ちなみに自分はインターン生にSQLを共有した所, 「shinyorkeさんもっといいやり方あるよ」的なレビューを受けたりもしました, これは素直に嬉しい.

今後は,

  • ビジネス・セールスメンバーがもっとデータで攻められる用にデータセットやダッシュボード増やしたり
  • 筋がよいSQLやデータ・ダッシュボードを共有したり

といった所で活用事例を伸ばしたいなと思っています.

最後までお読みいただきありがとうございました!

*1:サービスの大半がAWSで運用しており, Extractに当たるタスクはAWSを使っています.

*2:ちなみにですが, 4/30現在ではデータ分析インターンの募集は締め切っております :gomen:

*3:過去に3回ほどやりましたが, いずれもZoomでリモート参加可能な会でした.

*4:なんでや阪神(以下略)

*5:PandasもRも列方向で探索するのに最適化しているモノなので.っていうのとMethodとの関係も伝わるので相当おすすめです

*6:アーキテクチャそのものは自分が過去にやった事例をベースにやりました.もっというと構成図もパクった.

*7:正直な話, コワクナイBigQueryの施策の真意はここにありました. 知ることと見える化は大事なのです.