こんなに辛いことになるから、最初にがんばろう / 辛い開発状況をどうにかするためにやった13のこと
こんにちは!sugitaniと申します。
これまで有名芸能人と通話ができる(かもしれない)ライブ配信アプリとか、オリジナルマンガの配信サービスとか、コメントが横に流れるライブ配信システムとかを作ってきました。(SUGARは今も作業してます)
最近ご縁がありましてUUUMの子会社で、簡単に有料フォロワー向けの投稿が行えるFOLLOW MEを主に開発していて、NFTでデジタルトレーディングカード(※)を売り買いすることができるHABETをIndieSquare社さんと協業で運営しているNUNW株式会社(5月にFOROから社名変更)に入社し半年くらい経っています。最近CTOに任命していただきました!
※NFTについては思うことがある開発者の皆様が多いと思っていますが、自分がどう思っているかは後述します
少し前に「スタートアップがまともなわけ無いから入るな」というインタビュー記事を書いて頂いたんですが、あれは
- スタートアップだと低品質になりやすい体制になりやすい
- 低品質なプロダクトに関わるととても辛い
- 成長する会社はそれを何とか奇麗にして戦っている(と信じている)
- スタートアップでも大企業でもどんな会社であっても高速かつ高品質に作る努力をすべきだろう
- スタートアップだからと言い訳しないでちゃんとやろう
という内容でした。
FOLLOW MEは買収で得たサービスであることもありスタートアップらしい厳しさが多く「何とか奇麗にした状態」を目指して改善に取り組んでいます。この記事はどなたかのお役にたてるよう開発的につらい状況にあるプロダクトを改善しようとやったことをご紹介していきます。
献立
TypeScript/JavaScript/Swift/Kotlinを取り扱います。
【長文注意】25000文字超えています(Zenn判定で目安28分表示)。
全部読もうとせず、興味がありそうなところだけ読む、なんなら最後の「伝えたいこと」セクションだけ見るぐらいで大丈夫です。
概ね時系列順で、以下の内容になります。
- 環境構築しつつREADMEの整備
- ドキュメント置き場の整備とチームルールの明文化
- LinterとFormatterの整備
- JavaScriptプロダクトにtsc+JSDocによるゆるやかな型支援導入
- TypeScriptも使えるように拡張
- ESMへの完全移行とV2基盤の作成
- Firebase Emulator利用の整備
- 総点検プロジェクトの立ち上げ
- v2基盤の作成とストラングラーパターンでの移行
- iOSプロダクトのSPM利用への移行とxcodegenの導入
- Embedded frameworkでの分割によるUIプレビューの高速化
- GithubActionsによるテスト実行やデプロイの整備
- Androidのてこ入れ
環境構築しつつREADMEの整備
FOLLOW MEにはWeb版であるfrontendとiOS/Androidクライアント、それらを支えるbackendがあります。入社直後は動かしやすそうなfrontend+backendを動作させることを目標にして環境構築から取りかかりました。
backendとfrontendはTypeScript/JavaScript+Firebase(Functions/Firestore/Auth/…) が基本構成なのですがREADMEは「いつものやり方をよく知っている人」向けの数行程度のメモ書きしかなかったので、Firebase全依存プロジェクトが初めてな自分では直ぐには触れませんでした。
トレーニング用にFirebaseプロジェクトを作り一通り素振りしてから構築しつつ、今後の人がよりスムーズになるようREADMEに手順を書きためていきました。
最新版では以下のような構成になっています
# 概要 - これはバックエンドでサーバレスでFirebase多用していますよ、等
## 環境構築(※)
### Node.jsとFirebase CLIを準備しよう、Cloud Functionsのスタートガイドに倣いつついろいろ入れよう
### Emulatorに必要なのでJVMも入れておこう,SDKMANつかっておくと楽だぞ
### エディタも入れておこう
### checkoutしたり環境変数をセットしたりしよう,direnvとか使うといいぞ
## 開発フロー
### ローカルで動かす方法とか、デバッガをアタッチする方法等、テストを動かす方法
### デプロイの方法
## プロジェクト構成や、雑多なスクリプトの説明等
※ backendの例、あくまでイメージで本物はコマンドコピペして入れていけば大体OKレベルに具体的
※ 我々はまだやれてませんが、Macで遅い問題も改善するようなのでnodeだのfirebase cliだのはDockerコンテナに押し込めて環境構築は一瞬で終わるようにするのが理想だとおもいます。
ドキュメント置き場の整備とチームルールの明文化
実装に連動して変わることはREADMEに、それ以外の知見はNotionなりKibelaなりDocBaseなりにまとめるとよいと思います。特に会社やチームに新規加入したときのドキュメントが整っていると大変スムーズです。
NUNW(というかUUUM)ではDocBaseが導入されていましたがそれほど整っていなかったので
- NUNW社としてのトップページ
- NUNW社としての入社オリエンテーションページ(案内する方がやるべき作業も書くとスムーズ)
- FOLLOWMEチームとしてのトップページ
- FOLLOWMEチームに入ったときのオリエンテーションページ(案内する方 / される方)
- FOLLOWMEチームとしての資料置き場
などを整備し、入社してからやった事や疑問に思ったことをありったけ書き出しておきました。
またブランチの命名ルールやコードレビューにどこまで求めるかなどチームルール等も明文化し記載しました。
LinterとFormatterの整備
把握が進める間に様々な厳しさに遭遇したのですが共用環境にデプロイしないと動作させられないのがもっとも厳しいと思いました。動作確認が大変で効率が悪く、複数人で違う作業を行うと問題になります。というかデバッガが使えません。今までどうやって開発を…?
引き続きbackend+frontendを主にいじりながら、Firebase Emulatorを使ってローカルで気軽に動作確認できる+デバッガが使えるようにする、を次の目標にすることにしました。
まずはLinterとFormatterが未導入あるいは不十分だったので整備しました。
これらがないと
try {
〜
} catch (error) {
throw error
}
こんな虚無なコードが発生したり、var
が乱用されたり全角スペースが紛れ込んだりします(JavaScriptだと全角スペースは使ってよい(仕様では))
backendもfrontendもどちらもESlintとPrettierを導入しました。膨大な量の警告がでるのでルールは若干緩和させつつも、大半は歯を食いしばって修正しました。
frontendはなぜか当初からあるNext.js版(ごく一部のページ表示用にしか残っていない)と後で担当された会社様が作られたRiot.js v3を独自フレームワークでSSEしている版の二種類あります。
このRiot.js版ではテンプレートエンジンのpugが使われていてPrettierが素直に効かなかったのと、後述するts化を見据えて、以下のようにしてテンプレート部分とjs部分を分離しました(※)
-
pug-lexerを使ってhtml部分とjs部分を一括で別ファイルに分離し、( component.pugがあったらcomponent.pugとcomponent.jsができる )、切り出した部分を
script(src='pugのファイル名.js')
に差し替えるスクリプトをしゅっと書く - ParcelをRiot.jsに対応させるparcel-transformer-riotを改造してpugに対応させるコードがあったので、さらに改造して分離したjsがwatch対象になるように改造
※素直にLinterもFormatterも効く環境なら分離しなくて良いです
TypeScript+JSDocによるゆるやかな型支援導入
frontendはtsで無くjsで書かれているのと、global変数や既存ライブラリへのprototype拡張を活用した構造になっておりコード補完が殆ど効かない状態でした。これにより雰囲気だけで書かれた箇所もあるようで動作するはずのないコードも発生したりもしていました。
辛いのでTypeScript(tsc
)を型支援だけで導入することにしました。
- npmなりyarnで(今回はyarn)でtypescript追加
- tsconfig.jsonで
checkJs
とallowJs
を有効にする - JSDocで型情報をつける
-
npx tsc
--``noEmit
でファイル出力なしでtscを実行
という手順を行うとtscがかなり検査をしてくれるようになります。VSCodeやWebStormでもtscが適宜実行されてエラーと警告がでるようになります(といってもstrict = trueにできるほど整っていないので限度はある)
それから
- 独自フレームワークがいろいろグローバル変数に用意する仕組みになっていてtscがそんな変数無いよ!と警告を出しまくるのでglobal.d.tsにany上等で警告が消えるまで定義を書き出す。
- any探して適切な型に書き換える⇒tscが通るかチェック⇒書き換える⇒tsc⇒…
- よく使われているメソッドを優先してJSDocを書いていく
という作業を大量におこなって、ようやくそこそこ補間やエラー検出が効くようになったかな?という状態になりました。
我々のコードとVSCodeの相性が悪いのか適切に補間されなかったのでWebStormをチームの標準にしたりもしました。
TypeScriptも使えるように拡張
既存のコードはjsのままですが、新規のコードはtsにしたいところです。
使っているツールチェーンだとそのままtsは使えないので
- tscを実行するとtsがjsになる
-
allowJs
ならjsもjsのままコピーされる(型チェックもされる) - であればjs,tsだけを別ディレクトリに置いて、tscを動かすと既存の位置に上書きされるようにすればtsも使えるようになるはずだ
という当たり前の仕組みを活用して元に以下の構成のようにしてtsを使えるようにしました。
※実際にはこのままだとファイルが散らばって使いづらいとか、差分検知による再ビルドが動かないとかの問題がありますが、理屈の参考として記載しています
他にも型支援やts導入で見つかった怪しい箇所を直したり、長らく更新されてなかったFirebase Admin SDKをはじめとした利用ライブラリを全部最新版にしたらいろんなところが破綻したのでたくさん繕ったりしました。
なんとかfrontendに関してはLinter/Formatter/TypeScriptが使え、Firebase SDKも最新になってEmulator利用の準備ができた!まで持ってくることができました。
ESMへの完全移行とV2基盤の作成
backendは最初からTypeScriptが導入されていましたが、Firebase Admin SDKがFirebase Emulatorに対応しきれていないバージョンだったりNode.jsのバージョンが10なので更新できないライブラリがあるなど手当が必要でした。
jsの主なモジュール方式はESModuleとCommonJS(以降esm,cjs)ですがbackendのコードはesmとcjsが半端に混ざった状態のようでした。Nodeのバージョンを上げるとここら辺の取り扱いが厳格になったようで正常に動作しなくなり、esmに寄せようとしてもcjsに寄せようとしても上手く動作しなかったので、完全にesm化(Pure ESM)してしまうことにしました(※)。
※正確には何が原因でダメなのかがその段階では分からず、Pure ESMに挑んでダメだったらcjsに寄せようと思ったら通れてしまった
完全esm化するにあたり、esmにはimportするファイルの拡張子を省略できない仕様があり、これがTypeScriptと相性が悪いという問題があります。(参考: 最近のTypeScriptのES Modules対応事情)
この仕様により foo.ts
というコードをimportしたい場合
import foo from "./foo"
は通らず
import foo from "./foo.ts"
も実行時にtsファイルは存在しないので通らず
import foo from "./foo.js"
としないと動作しない、という問題があります
TypeScriptでも対応は進んでいるようですが、まだリリースされていないようです。4.7でリリース予定のはず? (参考: TypeScript 4.5 以降で ESM 対応はどうなるのか?)
解決方法は三種類で、最後の方法を採用しました。
- 存在しなくても
foo.js
と表記する方法 - ts→jsのトランスパイルを babel-plugin-module-extension-resolverを利用してbabelで行う方法(babelは型チェックをしてくれないので注意)
- 参考にした 最近のTypeScriptのES Modules対応事情にUnknown氏がコメントで紹介してくださったエイリアスで解決する方法
最後の方法そのまま再紹介させていただくと
tscの設定には[paths](https://www.typescriptlang.org/ja/tsconfig#paths)
というインポート検索場所のエイリアスを設定する機能があります。
nodeには[imports](https://nodejs.org/api/packages.html#imports)
と*にマッチできるSubpath patternsという機能があります
tscを動かすと src/foo.ts
から build/foo.js
が作られるとして、pathesとimportsを
tsconfig.json
{
"compilerOptions": {
〜
"paths": {
"#root/*": ["src/*"]
},
〜
}
}
package.json
{
〜
"imports": {
"#root/*": "./build/*.js",
}
}
と設定した上で
import foo from "#root/foo"
とすると tscからは src/foo.ts
、 nodeからは build/foo.js
と解決されて無事に動く、という原理です。
import文を全て書き換える必要はありますが、tscそのままで動かせるので動作が速い/型チェックが効く/差分コンパイルが効く/jestも苦労少なめで動かせる、などメリットが多かったです。
拡張子の問題以外にも
- 先頭以外にないimportを先頭に持ってくる
- importするだけで走ってしまう処理を削る
- require(“jsonファイル”)している箇所をcreateRequireにする
- jestもesm対応する
などを行い、無事にPure ESMで動作するようになりました。
Pure ESM化と一緒にライブラリ更新も行いbackendもFirebase Emulatorを利用する準備が整いました。
Firebase Emulator利用の整備
長い道のりでしたがfrontend/backend共にEmulatorを使う準備が整ったので、利用できるようにします。
Firebase Emulatorはカバー範囲が広く(Firestore/Database/Storage/Auth/Functions/PubSub)、簡単なプロジェクトなら基本的にbackendに関してはfirebase emulators:start
で起動、frontendに関してはuseEmulatorを利用するだけで利用できるはずです。(より具体的な利用方法は公式へ)
ただしStorage Emulatorがデプロイターゲットに対応しておらず「デフォルトバケット」にはstorage.rules
、「ビデオバケット」にはvideoStorage.rules
を適用する仕組みのFOLLOW MEでは動作させることができませんでした。
デフォルトバケットしか使わないようにすればよいだけなので、Emulator利用実行(以降local実行と表記)の場合は
- デフォルト向けの全てのファイルを
/
から/main
に置くようにする - ビデオ向けの全てのファイルを
/
から/video
に置くようにする -
storage.rules
とvideoStorage.rules
を統合してmatch /main
とmatch /video
でルールを適用し分ける -
firebase.json
をコピーしてfirebase.emulator.json
を作って使うようにして、適用するストレージルールファイルを切り替えられるようにする
という工事を行いました。
他にも細かい苦労はありましたが、ようやくEmulatorで動いているbackendにfrontendを接続させることができました。(さらにこれをコンテナ化したりしたが割愛)。
ここまで二ヶ月かかりました
総点検プロジェクトの立ち上げ
開発体験をよくする工事を経て状況把握が進み、FOLLOWMEの開発的な厳しさも分かってきました。
腰を据えて改善したほうがよいと思ったのですが、いくつくらい問題があって、それがどれぐらいのリスクなのか?を把握できないと経営判断をしてもらいようが無いので新規開発を止めてコードを総点検し問題を洗い出す総点検プロジェクトを行うことにしました。
プロジェクトは以下のように動かしました
- ことある毎にやばい、とにかくヤバいと報告して雰囲気を高めておく(大事)
- 問題が沢山潜んでいるのは確かなので、時間をとって総点検して問題を洗い出してすっきりしましょう、という合意をとる
- 総点検プロジェクトの実施要綱を作成し関係者全員に承認をとる。以下をはっきりするようにする
- プロジェクトの目的=問題提起 > 品質がやばい、問題も起きている、可視化させましょう
- 具体的な検査手順 > 全員新規開発を止めて、画面やfunction単位で全部コードを追いましょう
- 期間 > 調査に2週間使います、1週間後に中間報告を行いましょう
- でてくる成果物 > スプレッドシートでやばいリストを出しましょう
- 成果物を見た後の行動 > スプレッドシートみてからヤバい順に見積もって対応を決めましょう
- プロジェクトの開始 & 成果の作成
- 成果の共有と解説を行い、現状のやばさをチームで認識する
- 成果を元に改善プランを練ってチーム内で合意
- 改善プランに着手
ここ数ヶ月は新規開発はほどほどに、プランに従ってリスクの高い不具合や仕様をどうにかする作業を多めにやっています。
v2基盤の作成とストラングラーパターンでの移行
backendの実装には問題が多くありました
- DI可能な作りになっておらずMock/Stubも用意できないので、Firestoreに実データを用意する方法でしかIOの絡むテストが書けない
- Promise.allが多用されているがエラーハンドルが殆どされおらず、Firestoreのバッチ/トランザクションも活用されていないので半端なデータが発生するリスクが高い
- Repositoryと名乗るクラスがあるが、Firestoreの生データ読み書きを可能にする
Reference
オブジェクトを返す機能が主で、様々なクラスで好き勝手にFirestoreを直接読み書きしている - Repositoryと名乗るクラスに集計処理が入っているなど、クラスの名付けにふさわしくない処理が詰まっている
以下の手順で改善への道筋をつけることにしました
- 現コード(
/src
、以降v1)の横に/src_v2
と/src_demolition
を作成する -
/src_v2
には上記の問題を自然に解決できるフレームワークを作成する(以降v2) -
/src
の中から再実装しなおしたい対象を選ぶ - 対象のテストを書く。徹底的に書く
- 対象のクラスを小さく使い勝手の良い単位でメソッドに分解し尽くして
/src_demolition
に置く -
/src
のコードを分解したコードに差し替えていく(v1はほとんどスカスカになる) - 分解したコードをつかって
/src_v2
で同じ事ができる概念群に再構築する(元々1クラスだったとしても複数の概念が絡まってた場合は分解再構築すると沢山の概念やクラスになる) -
/src
から分解対象を使っていたところを、可能なところから/src_v2
で再現できるものに差し替えていく - 完全にv2に差し替えられたコードはv1から消していく。
/src_demolition
もv2に吸収していく
これを極限まで繰り返すと全てをv2に差し替えることができるはずです(こういうのをストラングラーパターンと言うそうですね)
※8〜9は差し替える部分のテストを整備しないといけないのでFOLLOWMEでは一度には行えませんが、普段からテストが整備されているプロダクトであれば/src_demolition
作成をスキップして一足飛びにv2を作る事も可能でしょう。
新規作成したv2基盤は以下のような特徴を持たせました。
①ValueObjectの強引な導入
杉谷自身は2014年からDDD(ドメイン駆動設計)を好んでおり、正しく実戦しきれているかはともかく今回もDDDの気持ちを生かした設計にしたいと考えました。
今回は既にできあがったものがあるので現状には引っ張られるので限界はあるとしつつも、再解釈によりできるだけ良質なモデリングを目指すことにしました。
例えばFOLLOW MEはSNSなので following
とか followed
という単語がよく使われていたんですが、よく観察すると
A
= 一般利用者 / B
= クリエーター としたとき
- AがフォローしているB - following
- BをフォローしようとしているA - following
- AにフォローされたB - followed
という使われ方でfollowing
に二つの意味が発生していました
これはフォロー操作に関係するメソッドで
async foo(userID:string, followingUserID:string)
async baa(userID:string, followingUserID:string)
の二つのメソッドがある場合、userIDとは?followingUserIDとは誰なのか?というのが文脈をみないと分からないということです。
v2ではfollowing
/ followed
は禁止して follower
/ followee
にしました。
また取り違え防止としてID系には string
ではなくFollowerID
/ FolloweeID
のように専用の型をふるようにしました。(正確にはUser
を状況によってFollower
とかFollowee
などの別のEntityに変換するようにした)
ただしTypeScriptはメンバーが同じなら同等にあつかう性質があり、素直に
// ダメな例
class FolloweeID extends ValueObject<string> {}
class FollowerID extends ValueObject<string> {}
と定義した場合、取り違えてもエラーになりません。
言語毎に特徴あるし使える手法もかわるよねー、と諦めてもよかったんですがstringで戦うのは辛かったので、masaphroさんの TypeScript のための ValueObject レシピより からトランスパイル時には消えるユニークメンバいれておけば別物扱いになるでしょう技を使わせていただきました。
declare const FolloweeIDTag: unique symbol;
export class FolloweeID extends ValueObject<string> {
// @ts-ignore
private [FolloweeIDTag]: void;
}
declare const FollowerIDTag: unique symbol;
export class FollowerID extends ValueObject<string> {
// @ts-ignore
private [FollowerIDTag]: void;
}
かなり複雑な気持ちになりますが使い勝手は良いです。
②ドメインイベントの導入とトランザクションの活用
DB書き込みを伴う処理中にエラーが起こったら書き込みを取り消したい、というときに便利なのがトランザクションですがFirebaseのトランザクション系には癖があり
- トランザクションとバッチがある
- バッチは一気に書き込むことだけができる
- トランザクションは「書き込みを始めるまでは読める = 書き込み始めたら読めない」「読んだ値に変更があったり処理にエラーがあったりするとエラー(ロールバック)」
という仕様です。
安全性を考えると頑張らなくてもバッチ/トランザクションが勝手に使われるぐらいにしたいものです。v2基盤では実践ドメイン駆動開発で紹介されているドメインイベントの考え方を取り込み
- 書き込みは基本的にドメインイベント経由でしか行わないようにする
- 自作のドメインイベント機構の一つである
BatchEvent
を継承したイベントは処理される際に書き込みを司るWriteContext
だけを受け取ることができる。その中で書き込むのは自由 - 同じく
TransactionEvent
を継承したイベントは準備
と実行
の2回呼び出しが行われ、準備
のときは読み込みを司るReadContext
、実行
のときはWriteContext
だけを受け取る事ができる - イベント処理中にエラーが発生した場合はロールバックされる
- 複数イベントをまとめて発行すると一つのトランザクションにまとめられる
- 一時エラーが発生したときは適度にリトライされる
という仕組みを作成しました。読み込みは各クライアントがFirestoreから直接それぞれ好き勝手に読む状況なのでCQRS風味(※)になります。
※ データストアの構造が分かれてないのでちゃんとしたCQRSではないです。
※ イベントと名乗る物が出てきてますがES(イベント履歴を唯一のソースにしてステートを作るデザイン)ではないです。今回のイベントはFirestoreの持ち味を活かすのが主目的です。
【余談】負荷対策とCQRS+ES、Firestoreについて考えていること
- CQRSとかESってなんぞや?はかとじゅん氏の資料とかAWSの「AWS モダンアプリケーション開発 – AWS におけるクラウドネイティブ モダンアプリケーション開発と設計パターン」をご参照ください
- 書き込みが過負荷になったとき読み込みに影響がでないようにする、あるいはその逆を実現したい場合は書き込み系(コマンド)と読み込み系(クエリ)の分離であるCQRSの導入が必須だと考えています
- 読み込み系と書き込み系では求められる要件が異なるので、系統が分かれていた方が実装的にもすっきりします(こっちも主目的)
- ガチでCQRSやるならESからはたぶん逃げられないです(コマンド系からクエリ系に更新を伝える仕組みが必要なのでイベントという概念がどのみち発生する→コマンド系のデータ更新してからイベント経由してクエリ系のデータ更新とするとダルい→イベント帳簿を基準=ESにしたくなる)
- ただし正直Akkaを使わないとやってられないと思います
- 今回採用している”データストアはFirebase + 書き込みはドメインを通してちゃんとやる + 読み込みは各クライアントが直接Firestoreから好き勝手に”というアーキテクチャは「Firestoreは勝手にスケールしてくれるSpannerなので負荷で完全停止することは少なそう = 系統は分けなくても良さそう※1」「コマンド系とクエリ系の実装は勝手に別になりそう※2」なので、お手軽にCQRS+ESに近い効果が得られるとおもいます。(今回持ち出してるドメインイベントはESとは全く関係ないのでご注意ください)
- でもトラフィックが増えるとFirebase代に苦しむことになる?
※1 - スケールするまで反応が悪くなることはあるでしょうから要注意。ホットスポットできるとどのみちダメです。
※2 - 読み書き両方を行うことになるbackendは分離っぷりがいまいちになります
③レイヤー分けとDIの導入
関心毎にレイヤーを分ける工事も行いました。基本的な設計思想は過去にやってきたことを踏襲して
- domain - ドメインとドメインイベントに関する定義群。Firestoreとやりとりするとかの副作用のあるコードは直接置かない
- application(一般的にはuseCaseとされることが多い) - domainを組み合わせてちょっと凝ったことするクラスを置く。domainの実装をinjectして使う
- adapter - domainに置かれたrepositoryなどの実装
- eventHandler - Cloud Functionsとの結合
- infrastructure - ライブラリやどこにでも持って行ける実装など
として「domainやinfrastructureは依存するの無いと思うしそれぞれテストしていこうね」「applicationはMock/Stubを注入してテストしようね」「adapterやeventHandlerはFirebase Emulatorと組み合わせてテストしていこうね」「eventHandlerのテストはいいや、できるだけコード書かずに凝った処理はapplicationに逃がしていこうね」とすることにしました。(※)
※ これまでの仕事では開発者全員にエリック・エヴァンスのドメイン駆動設計を読んで頂いたりしましたが活用する状況まで持って行くには遠いので、比較的読みやすいClean Architectureだけ読んで頂きました
レイヤー間の依存の強制はTypeScriptのreferences設定を使うことで設定できます。最終的には以下のような依存ルールになりました
DIコンテナは最初はコンパイル時に依存チェックができるnicojs/typed-injectを試してこいつぁすげえぜ!と思ってたんですが取り扱うクラスが増えるとtscの限界を超えるのかerror TS2589: Type instantiation is excessively deep and possibly infinite
で動かなくなってしまったのでmicrosoft/tsyringeに落ち着きました
v2の利用はまだごく一部で完全移行には時間がかかりますが、地道に書き換えていっています。
iOSプロジェクトのSPM利用への移行とxcodegenの導入
iOSの改善にも着手して
- READMEを整備
-
nicklockwood/SwiftFormatを導入
- 公式のapple/swift-formatでない理由は慣れている/Swiftバージョン指定が
.swift-version
だけで済む/Xcode拡張が用意されている、等
- 公式のapple/swift-formatでない理由は慣れている/Swiftバージョン指定が
- ライブラリ管理をCocoaPodsからSwiftPMに移行
- 使われてない大きめなコード群があったのでPeripheryを使って撤去
- 大規模リファクタリングをしていく予定なので作業が衝突しづらいようにyonaskolb/XcodeGenを導入
などを手始めに行いました
SwiftPMでCLIツールも管理
少し前までSwiftPMではCLIツール(SwiftFormat等)の導入ができなくて、しょうがないのでCLIツールはCocoaPods/ライブラリ管理はSwiftPMとした記憶があったんですが、いつの間にかできるようになっていた(or できる知見が見つけられた)ようです
今回は以下のように整えました(例: SwiftFormat)
-
./bin/.swiftformat/Package.swift
に以下を記述
// swift-tools-version:5.3
import PackageDescription
let package = Package(
name: "Tools",
dependencies: [
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.49.0"),
]
)
-
./bin/format
に以下を記述してchmod
+x ./bin/format
#!/bin/bash
cd `dirname $0`
cd ..
swift run -c release --package-path "./bin/.swiftformat" swiftformat --exclude "./bin" .
初回実行だけビルドが入るので遅いのが難点ですが、brewやmintやcocoapods等の追加操作無しで使えるのが良い感じです。
XcodeGen vs ほとんどSwiftPM化
XcodeGenはコンフリクト抑制を目的で導入しましたがd_dateさんのSwift PM centered iOS Developmentや、それを実践されたShun Uematsuさんのこちらの記事で「SwiftPMパッケージだと.xcodeproj無しでビルドできるし、殆どのコードをSwiftPMパッケージに押し込めてしまえばXcodeGen要らなくなるんじゃない?」というアイデアが紹介されています。
XcodeGen導入前に試してみたのですが、Build Phaseに相当する処理がないのでSwiftGenによるリソース生成を良いタイミングで動かすことができず、それを可能にするプロポーサルも検討中だったので諦めたのですがSwift 5.6(Xcode13.3)で実装されました。
なのでXcodeGenも今なら削れるかもしれません
Embedded frameworkでの分割によるUIプレビューの高速化
FOLLOW MEではXib + Viewクラスでカスタムコンポーネントを作成する手法が多くつかわれていましたが、細かい調整は専らコードから設定するスタイルなので本当に意図したデザインになっているかを確認するには実際に動かす必要があり効率が悪く感じました。
この問題を解決して快適にするには
-
IBDesignable
とIBInspectable
を整備する - kenmazさんがXcode Previewsを用いたUIKitベースのプロジェクトの開発効率化で紹介されている手法でSwiftUIのプレビュー機能を強引に使う)
が考えられます。
ひとまず新しく作る分にはIBDesignable
と IBInspectable
を整備しようとおもったんですが、下手に導入するとIBDesignable
をつけるとどこか1文字を触っただけでもInterface Builderによるビルドが走るようになってXcodeが激重になる問題に苦しめられます。
別の開発でこの問題を解決できずIBDesignableを削ってしまったことがあるので、同じ事にならないようにEmbedded frameworkをつかって
-
/FollowMe
- 既存のコード -
/FollowMeComponents
- カスタムView(.xib + .swift)、関係リソースだけを置く -
/FollowMeInfrastructure
- 上二つで使うコードやライブラリ置き場
の三つに分割をしてビルド効率化を高めた上で、新しく作った分はIBDesignable
とIBInspectable
を整備してInterface Builderだけで殆ど調整できるようにしました。 ビルドもプレビューも今のところ高速です。
既存コンポーネントも/FollowMeComponents
に移植して快適性を上げつつ、
今後はSwiftUIに移行していく予定なのでXcode Previewsも活用できるようになる見込みです。
Framework分割後にハマった罠①
AppStoreに上げようとするとERROR ITMS-90205: Invalid Bundle. The bundle at 'アプリ名.app/Frameworks/FollowMeComponents.framework' contains disallowed nested bundles.
と ERROR ITMS-90206: Invalid Bundle. The bundle at 'アプリ名.app/Frameworks/FollowMeComponents.framework' contains disallowed file 'Frameworks'.
で蹴られる問題に遭遇しました。
原因はエラーメッセージの通り
/Frameworks
/Lib1
/Frameworks
/Lib2
という構造になってしまうのが問題なのでこちらを参考に
/Frameworks
/Lib1
/Lib2
という構造にするスクリプトをいれました
Framework分割後にハマった罠②
Undefined symbols for architecture arm64:
"_OBJC_CLASS_$_FIRAnalytics", referenced from:
objc-class-ref in AnalyticsService.o
ld: symbol(s) not found for architecture arm64
clang: error: linker command failed with exit code 1 (use -v to see invocation)
こんなエラーがでるようになって苦しんだのですが、リンカの引数(OTHER_LDFLAGS
)に -ObjC
を足したら直りました。
akisuteさんの-ObjC とか -all_load って何をやってるのか調べてみたによると -ObjC
とはObjective-Cのクラスまたはカテゴリを定義しているライブラリに含まれる、全てのオブジェクトファイルをロードするようリンカに促します
だそうなので、あーそうね細かいことは分からないけど全部入れるってしないと含まれないシンボルがでちゃうんだね、と理解しました。
GithubActionsによるテスト実行やデプロイの整備
CIも整備されていなかったので直ぐに使えるGithub Actionsで整えることにしました。
素直にデプロイやテスト実行を整えればいいだけ、と思いきや結構な苦労がありました。
苦労① functionsの数が多すぎてデプロイが安定しない
Cloud Functionsを扱うのは本プロジェクトが初めてなので異常なのか正常なのか分かりませんが、デプロイが遅く不安定で(Quotaに引っかかったりする)firebase deploy
を2回か3回実行しないとエラー無しで終わらない状況でした。
CIにデプロイさせるには不安定だと困ってしまうので、特定の関数をデプロイするオプションを使って10関数毎に3並列、失敗しても適宜リトライするスクリプトを書きました。
苦労② 動作確認でローカルで動かそうとactを使ったが互換性が辛い
ローカルで動作確認をするためにGithub Actionsをローカルで動かすnektos/actを利用させていただきました。簡単なことであれば問題無く使えたのですが互換性は高くなく(たとえばifで検査する値にundefined
がやってくるとクラッシュする)苦労がありました。結局はGitHubにアップロードして実験する事が多かったです。
めちゃくちゃ辛かったのでCircleCIのほうがお勧めできると思いました(公式のローカルCLIがある / 無料時間がActionsの倍の6000分もある、ただし無料プランはMacがない)
苦労③ 失敗したときのSlack通知させるのに苦労した
ビルドが失敗したらSlack通知をさせたかったのでSlack所属のseratchさんが書かれたSlack が提供する GitHub Action "slack-send" を使って GitHub から Slack に通知するを参考に通知を組み込んでみました。
単純な通知だけなら比較的簡単にできたんですがコミットした人にメンションを飛ばしたかったので
- Google Spread SheetにGitHubID ⇒ SlackIDの対応表を作る
- OAuthでAPIをつかってシートを読みこんで GitHubIDを渡すとSlackIDを渡すコマンドを書く
- Actions Workflowsの中でコマンドを呼んで
github.event.sender.login
⇒SlackID
をする - 失敗したらslack-sendを使って通知
としました。
あとで気がついたんですが、こんな大仰なことせずともSlackでGitHub連携させていればPRやCommitに対してコメントをつければ多分通知が来ます。
苦労④ Macは10倍料金
これは苦労というか注意ですし、しょうがないんですがMacインスタンスは10倍時間を消費します。3000分の無料時間があっても300分Macインスタンスでビルドすると溶けます。フルビルドに30分かかるとすると10回ビルドすると終了して、それ以降は $0.08/分になります
特別高いわけではなくBitriseも似たような価格です($35で500クレジット, Standardインスタンスで2クレジット/分 = 250分=$0.14/)
個人的にMac周りはBitriseのほうがXcode対応が早いのでお勧めです(というかGitHubの対応が遅い)
Androidのてこ入れ
Androidも問題たっぷりです。おそらくKotlinの経験がほとんど無い状態で作られたのだと思うのですが
- !!が乱打されている (Nullableが来ない設計にするかスマートキャストで外しましょう)
- findViewByIdが飛び交っている(Binding使いましょう)
- 当然のようにBaseActivityがいる(やめて)
- 非同期処理はコールバックが普通(Coroutine使いましょう)
- AndroidStudioのスクロールバーが警告でカラフル(早めに警告は解消していきましょう)
という状況でした。
体感としては10年くらい昔の水準で作られているので、まずは1年ぐらい前の水準(Jetpack Compose手前)の水準をターゲットにしようとView操作のBinding経由への切り替えとCoroutine化が少しだけ進んでいました。
最近 Android開発者で見たことがない人はいないと思われる Y.A.M の 雑記帳のyanzmさんに少しだけ監督ではいっていただき、改善が加速しています。
これから目標を上げ最新の水準(Jetpack Compose等)にどんどん切り替えていく予定です。
伝えたいこと
冒頭で言及した「スタートアップがまともなわけ無いから入るな」で「大体やばいからやめとけ」と書いていますが「マッチョならあえてスタートアップもあり」と話させていただきました。
その理由としてインタビュー中では「最初から良い環境作ってあげられるよう突っ込んでいって幸せを増やしましょう」という高尚な事を申し上げさせていただきましたが、実際には「環境を良くしていくというのはものすごい経験値の狩り場」だな、とも思いました。
実際に僕が得た経験として
- 使ったことがないFirebaseプロダクトを使う経験と失敗の体験
- 比較的大規模なTypeScriptプロダクトの動かしながらのリファクタリング方法
- ES modulesとCommonJSに関する知識
- ES2016以降のJavaScriptへのキャッチアップ(await/async等。大規模開発はES6が最後だった)
- TypeScriptにおけるアーキテクチャ構築の経験
- XcodeGenやSwiftPMの知識
- GitHubActionsの経験
が短期間で得られたわけで、体感としては新規開発に迫る経験値だとおもいます。現状が良くなくても"よくするぞ"という決意を持った現場は意外とレベルアップに適した環境なのかもしれません。
とはいえ本記事でやった殆どの作業は最初から検討されていればやらなくて良い作業だったか、あるいは極めて低コストでできた作業です。最初からやっていればこんな苦労は要りません。ビジネス的には初速だけを早くした代わりに数年分のリソースが犠牲になったことになります。もう少し良いバランスはあるんじゃないでしょうか?
新しく何かをやる場合、エンジニアであれば良いとされることを学習して取り込んで整える努力は必要だとおもいますが、それ以上にマネジメント側が正しくエンジニアリングできるように整えるのが重要なように思います。(一分野を経験値の少ない1人に丸投げして監督無し、とか止めましょうね)
もちろん最初から努力をしたけども、力及ばず快適にはならなかった、というのは普通にあると思います(というか陳腐化に勝つのは難しいので高確率であります)。それでも良い状態を保ちたい、という心で作られた環境を手入れするのは、野放図で作られた環境に行うよりずっと簡単でしょう。
できるだけ最初の方から頑張っていきましょう。どの立場でもどの規模でも。
本記事がどなたかのお役に立てれば幸いです。
《余談》NFTについて
NFTに付いてはokapiesさんのソフトウェアエンジニアなら3秒で理解できる NFT 入門などをご覧になって「えっ、NFTってただの保有者アドレス⇒(トークンID+URI等)を頑張ってやりくりしてるだけ?」と思われた方も多いと思います(自分もです)
実際にHABETもそうなんですが画像とかの実データを提供するのはサービス主体になるので、いろいろ宇宙猫の気持ちになります。とはいえ土地とその権利書みたいに実物と所有権を分離した概念は存在しますし、いま提供できている機能だけだとイメージしづらいんですが、今後の計画を含めて説明すると殆どの人は「なるほど確かに理屈は納得できる」と言ってくださるんじゃなかろうか?というくらいには腹落ちしています。
機密なので計画を説明することはできませんが、早い内にお見せできるように頑張ります。
積極採用しています!
FOLLOW MEとHABETと、まだ積まれているやりたいあれやこれやの企画を進めるためにNUNW社では採用活動を大幅強化しています。フロントエンド/バックエンド/iOS/Androidのどの分野でもご応募をお待ちしています!
UUUMのWantedlyでは組織面・環境面での改善や魅力をご紹介しています。
例えば
- 人事制度をエンジニア向けにがっつり変えた
- なんかめっちゃ福利厚生がある(本でも外国語学習とかに月1万円好きに使えるとか、企業型確定拠出年金があるとか)
などをご紹介しています。よろしければごらんください
Discussion
たぶん当時のエンジニアがこれを読んでも「
そんなことは分かっている。それでも目の前のリリースだけを考えるべきだったんだ」となるのではないかと思いました。
さらに、その程度の労力で負債を返せるのであれば、ビジネス的には正しかったのでしょう。
もちろん、きれいではない設計やコードを直していくのはストレスフルなのは分かりますが、そんなことで「ヤバい」と言って騒いで、経営側に過剰にリスク評価させるのはワガママすぎると思いますよ。
結局このサービスは経営判断により終了してしまっています。
この記事のような大工事をしなくても良かった、あるいはしなかったのであれば違う未来に行く可能性もあったかも知れません。そうでもないかもしれません。
実装的な負債に関係なくビジネス的に正しくなかったか、負債に負けてビジネスとして失敗したか、負債を返すタイミングが悪かったのか、負債の返済を考えたら死ぬラインにいるため無限に苦しむべきだったのか…どれかなのでしょう。
現在、私は会社を立ち上げており経営者でありエンジニアでもある状態ですが
奇麗でない設計やコードというのは最悪レベルの経営リスクであると継続して感じています。
そもそも汚ければ早い、という関係ではありません
何も学ばずに手探りで書くコードより、様々な方法論を学び参考にして保守性が高そうに思われるコードを書く方が、同じ時間をかけたとしても高い品質や保守性が得られることを期待できるでしょう。
エンジニアとしても経営者としても、設計や開発体験の良さを軽視せず追求するのは当然に思います。
この記事は、奇麗な設計で奇麗なコードを早く書けるエンジニアを目指し研鑽を積んでいきましょう、研鑽して頑張ったとしても良い結果が得られないかもしれないが、それでも諦めずに学び続けましょう、という思いを込めた記事でした。
リスク判定は経営者がやるべきですし、エンジニアが「そんなことは分かっている。それでも目の前のリリースだけを考えるべきだ」と経営者に言わせることはあっても、自ら言い出すことは無い世界になることを願っています。