こんにちは、Appifyでソフトウェアエンジニアとして働いているYamashouです。
弊社でのWebの試行錯誤について書いていこう思います。少し長くなるかもしれませんがお付き合いいただけたら幸いです。
AppifyではECをアプリにすることをができますが、それをお客さんが実現するのはWebの管理画面で行っております。そして弊社の製品は自社で開発をしているので、もちろんWebの開発も内製で行っております。しかし現在の正社員のメンバー構成は以下のようになっております。
- iOS エンジニア x 1
- サーバーエンジニア x 4
となっております。つまり現状Webのスペシャリストと呼べる人は正直正社員の中にはいません。
これまでの弊社の歴史の中でそういう人がいたかと言われると、外部で助けてくれる人はいましたが、メンバーが3人であった頃からいませんでした。
そんなメンバーの中どうやってWebを開発してきたのか?についてお話して行こうと思います。
1. Web開発スタート
主にWeb開発はCTOである、そなたさんと僕でこれまで行ってきました。Web開発が必要になったのは、”Appify”を作りはじめるよりも、いくつか前のサービスまで遡ります。
当時、サーバーは以下のような構成で組まれておりました。
このAPIからデータを引っ張ってきたり、外部の決済基盤と連携しつつWebアプリケーションの構築をする必要がありました。
しかし、そなたさんと僕は、基本的にはGoをこれまで使ってサーバーを書くことをしてきていたため、Webに関する知識はそのころは殆どありませんでした。僕個人としても、趣味で触ったことあったり、バイト先で、数年前にVueとReactを使ったことがある程度でした。
とりあえず、Webに詳しい周りの人や記事などを読んで、構成を決めました。
- React
- Typescript
- GAE
これでいくことにした理由は以下の通りです
- React: 最も宣言的にかけて、かつ最もその歴史を先進しているのと、判断したからです。なので、まずReactでWebを構築することがこれからの宣言的にUIを構築していく流れの中では間違いはないだろうという仮説のもとReactにしました。
- Typescript: 元々Goの畑からやってきた二人だったので、最低でも型がないとしんどいということでTypescriptを入れることを決めました
- GAE: サーバーをGAEで組んでいたので、そのままGAEで組んでしまった方がその知見を活かせるだろうということで決めました。
そして、Reactに関してはそのころはまだ発表されてまもなかったhooksを導入することも大きな決め手となりました。これによって、コンポーネントへの状態管理や副作用を宣言的にスッキリかけるだろうという大きな期待がありました。
当時のアプリケーションの構成をざっくり書くと以下のようでした
src
├── assets
│ ├── index.html
│ ├── protobuf.d.ts // サーバーで定義されたProtocol Buffersから生成されたクライアント用の型定義
│ └── protobuf.js // サーバーで定義されたProtocol Buffersから生成されたjsクライアント
├── client
│ ├── Config.ts // 環境の設定ファイル
│ └── main.tsx // mainファイル
├── components // Reactのコンポーネント群
│ ├── App.tsx // main.tsxで呼ばれる大元のコンポーネント
│ ├── layouts // レイアウトを管理するコンポーネント群
│ ├── modules // HeaderやFooterなど、どのページでも呼ばれる決まりきったコンポーネント群
│ ├── pages // 各ページのコンポーネント群
│ ├── routing // ページのルーティング定義ようコンポーネント群
│ ├── styles // スタイル定義群
│ ├── ui // ページの部品となるコンポーネント群
│ └── utils // ui用便利関数&hooks群
├── infra // 外部とアクセスよう実装群
├── lib // アプリケーション用便利関数群
├── server // nodeサーバー用実装群
└── state // Reduxによる状態管理用実装群
こうして開発がはじまっていったのですが、Web, サーバー, iOSを開発していく中で、どうしてもWebの開発が最も時間がかかってしまいました。その中で大きな問題となった要因は以下の二つです。
- CSS
- Redux
この二つがどうしてもコードを難しくしてしまっていました。
CSS
CSSは非常に僕らを悩ませました。主にあげるとすればこのころは、以下の項目が僕らを悩ませていました。
- モバイル対応
- 特殊なUI
- スタイルの統一とUIの良さ
モバイル対応はWebアプリケーションを構築したことのある方であれば誰しもが一度は通る難関ではないでしょうか、僕らのようなこれまでWebフロントに関わってこなかった人間からするととてつもなく高い山でした。「どこまでのサイズを対応すればいいんだ」「小さくするとここが使いにくいな」「ちょっとはみ出てないですかね?」なんか日常茶飯事でした。
極力デザインのレベルからコンポーネント単位で統一をした方がデザインからあとは組むだけになるはずなので、統一をしていきたかったのですが、デザイン的にこっちのがいい!と言われるとエンジニアとしては実装しなければならないので、そのために専用の実装が増えてしまいました。
上のような特殊なパターンではなく、スタイルを統一したいのですが、そのためにはデザインがコーディングを想定して作られていなければならず、もし新たに追加する場合もそれを、決められた期間にうちのアプリケーション内に適応できる形のものにしなければならないので簡単そうなページを複雑化する原因になっていました。
コード例
スタイルを当てることに関してはemotionを採用していたため以下のようなコンポーネントを書いていました。
export const ProductDescription = styled(
({ className, children }: ClassNameProps & ChildrenProps) => (
<div className={className}>
<ProductSubText>{children}</ProductSubText> {/*部品でスタイルの決まっているのものはその専用のコンポーネントを使いこの階層のCSSには書かない*/}
</div>
)
)`
width: ${CSSWidth.M0}; // 共通化されたスタイルや値に関しては別途定義されている変数を参照
text-align: center; // 明示的に書かれていた方がわかりやすいものや例外的なものに関しては直接書く
`;
しかし、このような決め事をしていたとしても、上の三つのことが重なっていくと以下のようなコンポーネントが増えていきます
export const CampaignProductContentContainer = styled(
({
className,
children,
colorDefaultValue,
sizeDefaultValue
}: CampaignProductSelectProps & ClassNameProps & ChildrenProps) => (
<Column className={className}>
<CampaignProductSelectContainer
colorDefaultValue={colorDefaultValue}
sizeDefaultValue={sizeDefaultValue}
>
{children}
</CampaignProductSelectContainer>
</Column>
)
)`
align-items: ${CSSAlign.Center};
justify-content: ${CSSAlign.Center};
${ColumnMargin(CSSMargin.S3)}
${Heading3Bold} { // このコンポーネント上に現れないが下位のコンポーネントは共通で使われているため、変更できないので、この階層であてざるおえない
text-align: ${CSSAlign.Center};
}
width: ${CSSWidth.Max};
${CampaignProductSelectContainer} {
width: ${CSSWidth.M4};
}
${isMobile()} {
${CampaignProductSelectContainer} {
width: ${CSSWidth.Max};
}
}
`;
この例はまだマシな方で、もっと複雑なものも多々ありましたし、そのせいで上で決めた、この粒度!というものがかなり曖昧であることに気付かされましたし、デザインからコンポーネントまでを統一して管理できなければこれは解決しないということを実感しました。
Redux
状態管理を弊社ではReduxを使っておりました。しかし、これもまた僕らの開発で大きな問題となりました。
- 冗長な状態管理用のコードが増えてしまう
- Reducxでの状態とコンポーネントのスコープが別れており理解困難になっていた
Reduxや状態管理のコードを書いたことがあれば、共感いただけるかとは思いますが、同じような状態更新のコードを書かなければなりません、それがどれだけ簡単な状態遷移であったとしても決められたコード量が存在してしまいコードベースが肥大化してしまっていました。
アプリケーション全体で保存している状態がViewを表示するために使っているコンポーネントの上位からPropsとして渡されてきて、その状態の更新に関してはまた別のところでやっているため、状態遷移を自分達で定義している以上それを理解して使うことが非常にアプリケーションを複雑化させていました。
コード例
- Stateの例
あらかじめProtocol Buffersから生成された型から実際Webアプリケーション側で使う状態を定義していきます。
export interface UserState {
user: User;
error?: Error;
}
Userは生成された型から僕らが使いやすいように変換した型となっております。
- Actionの例
僕らのアプリケーションの構成に関してはこちらで説明した通りです。まず、typesafe-actionsを使ってActionの定義を行います。
export const UserAction = {
GET_USER: createAsyncAction(
"GET_USER_REQUEST",
"GET_USER_SUCCESS",
"GET_USER_FAILURE"
)<undefined, GetUserSuccessPayload, FailedPayload>(),
UPDATE_ADDRESS: createAsyncAction(
"UPDATE_ADDRESS_REQUEST",
"UPDATE_ADDRESS_SUCCESS",
"UPDATE_ADDRESS_FAILURE"
)<undefined, UpdateAddressSuccessPayload, FailedPayload>(),
}
export interface GetUserSuccessPayload {
user: User;
}
export interface UpdateAddressSuccessPayload {
user: User;
}
export interface FailedPayload {
error: Error;
}
typesafe-actionsを使うことでActionの定義はかなりスッキリと定義することができます。しかし、ここには例で二つしかあげませんでしたが、増えてくると同じようなコードがたくさん作られてしまいます。
- Reducerの例
ここで先ほど定義したActionとStateを利用してどのようなActionがきた時にStateをどう変更するかを定義していきます。
export const userReducer = (
state: UserState = initialState,
action: UserActionType
): UserState => {
switch (action.type) {
case getType(UserAction.GET_USER.request):
case getType(UserAction.UPDATE_ADDRESS.request):
return {
...state,
isFetching: true
};
case getType(UserAction.GET_USER.success):
case getType(UserAction.UPDATE_ADDRESS.success):
return {
...state,
user: action.payload.user,
isFetching: false,
error: undefined
};
case getType(UserAction.GET_USER.failure):
case getType(UserAction.GET_USER_SUBSCRIPTIONS.failure):
return {
...state,
isFetching: false,
error: action.payload.error
};
}
}
今回の例ではuserを更新するものしかないですが、Stateの状態が複雑化していくと、ここもどんどん複雑化していきます。
- middlewareの例
最後にデータを取得して、実際状態を更新するタイミングを定義していきます。
export interface UserOperations {
getUser(): Promise<void>;
updateAddress(
postal: string,
address: string,
name: string,
successFunc: () => void
): Promise<void>;
}
export const createUserOperations = (
dispatch: Dispatch,
firebase: Firebase,
userRepository: UserRepository,
payjpRepository: PayjpRepository
): UserOperations => ({
async getUser() {
dispatch(UserAction.GET_USER.request());
try {
const { user } = await userRepository.getUser({});
dispatch(UserAction.GET_USER.success({ user }));
} catch (err) {
if (isErrorResponse(err)) {
const e: api.v1.ErrorResponse = err;
if (e.code === api.v1.ErrorResponse.Code.CODE_NOT_FOUND) {
dispatch(UserAction.GET_USER.success({ user: initialUserState }));
return;
}
}
const error = ErrorHandler(err);
dispatch(UserAction.GET_USER.failure({ error }));
}
},
async updateAddress(
postal: string,
address: string,
name: string,
successFunc: () => void
) {
dispatch(UserAction.UPDATE_ADDRESS.request());
try {
const { user } = await userRepository.updateAddress({
postCode: postal,
address,
name
});
dispatch(UserAction.UPDATE_ADDRESS.success({ user }));
successFunc();
} catch (err) {
const error = ErrorHandler(err);
dispatch(UserAction.UPDATE_ADDRESS.failure({ error }));
}
}
})
実際に状態やデータの加工などのロジックはここに集約されていきます。Webフロント側でやらなければならないことが増えてゆくとここのロジックがどんどん増えていきます。
これであらかた状態を更新するまでの定義に関しては終わりで、このあとこれをView側に渡していきます。
書くPageコンポーネントの定義は以下のようになっています。
pages
├── hoge
│ ├── HogePage.tsx // 実際のページ用のコンポーネント
│ ├── HogePageContainer.tsx // 依存関係をPageコンポーネントに渡すためのコンポーネント
│ └── index.ts
└── foo
├── FooPage.tsx
├── FooPageContainer.tsx
└── index.ts
- XXXXContainaerの例
const selector = (state: RootState) => {
return {
user: state.user.user
};
};
export const HogePageContainer: React.FC = () => {
const operations = useOperations(); // RootコンポーネントでインジェクトしているOperation群をとってくる
const { user } = useSelector(selector); //react-reduxのhooksでしてした状態をとってきてくれる
return (
<HogePage
operations={operations}
user={user}
/>
);
};
export type HogePageStateProps = ReturnType<typeof selector>;
このようにしてReduxで定義した状態と状態更新用のmiddlewareをView側のコンポーネントまで渡すことができます。そしてこれは状態が増えるごとに倍倍ゲームのように増えていきます。
(※ 実際現状のRedux環境ではもっと綺麗に書けるのかも知れませんが、このころの僕らの中での最適解でした。)
現在のAppify Web開発
さて、先ほどまではスタートからReduxで構築されていた環境について簡単に説明させていただきましたが、ここからはその反省から現在のWebはどうなっているのか、についてお話していきたいと思います。
まず、現在のサーバーは以下のようなものが使われております。
- Go
- GAE
- GraphQL
先ほどと違うのは、Protocol BuffersがGraphQLになっております。この変更は僕らのアプリケーション全体に大きな恩恵を与えてくれております。
(余談ですが、Protocol Buffers → GraphQLの変更をしたいと言った時に社長(ゆずしお)は猛反対してきて壮絶なバトルが繰り広げられました。)
Web側はそのままで、React, Typescript, GAEで動いておりますが、問題となったReduxとCSSに関しては大きく変更されております。
GraphQLの選択した理由に関しましては以下のCTOであるそなたさんの資料をご覧ください!
スタイル
CSSに関する悩みは以下のように修正していきました。
- Polaris導入
- CSS関数化
Shopifyへの対応のタイミングで、UIをPolarisというShopifyが提供してるデザインシステムに置き換えることにしました。PolarisはReactのコンポーネントからstorybook、Figmaも用意されており、デザインからコンポーネントまでが一貫して扱えるようになりました。
emoationのCSS-in-JSを使用していましたが、そうするとスタイルが結局バラバラに定義されてしまったり、CSSの細かいことや環境独自のルールを知る必要があったため難しくなっていたのを関数にしてPropsとして配列で渡す方式にして、Polarsでは表現しきれなかったスタイルも簡単にかけるようになりました。 (一部まだCSS-in-JSが入っているところもあります)
GraphQL(Apollo)
- Apollo Client Cache
GraphQLのクライアントであるApolloのClient Cacheに状態管理を任せられるようになり、先ほど見てもらった、Reduxのコードが全て取り払われました!そのおかげで、コンポーネント単体でスコープが完結しており、良い意味でjsonの色付けだけに集中することができるようになりました!
コード例
export const ShopifyPushNotifications: React.VFC<ApplicationIDProps> = ({
applicationID,
}) => {
const { data, loading, error } = useQuery(ShopifyPushNotificationsDocument, {
variables: {
applicationID,
},
});
useSetError(error);
return (
<Card>
<ResourceList<
PushNotificationsQuery["application"]["pushLogs"]["nodes"][number]
>
items={data?.application?.pushLogs?.nodes ?? []}
loading={loading}
emptyState={
<EmptyState
heading="顧客にプッシュ通知を配信しよう"
image=""
action={{
content: "プッシュ通知を配信",
url: routeWithApp(
AdminRoute.ROUTE_SHOPIFY_MOBILE_APP_MARKETING_PUSH_NOTIFICATION_CREATE,
applicationID,
),
}}
>
<p>新商品の公開やブランドの新着情報を顧客に届けることができます</p>
</EmptyState>
}
renderItem={(pushLog) => (
<ShopifyPushNotification applicationID={applicationID} {...pushLog} />
)}
/>
</Card>
);
};
Stateの管理はApolloのクライアントがやってくれているので、useQueryで呼び出してかえってくる値であるdata, loading, errorを使ってそれぞれの状態に合わせたコンポーネントを表示するだけです。そしてこのCardや、EmptyState、ResourceListはPolarisが標準で提供してくれているコンポーネントです。そしてShopifyPushNotificationsDocumentは以下のQueryから生成されたものです。
query ShopifyPushNotifications($applicationID: ID!) {
application(id: $applicationID) {
id
pushLogs(first: 30, orderBy: { direction: DESC, field: CREATED_AT }) {
nodes {
id
pushMessage
pushStatus
execTime
}
}
}
}
このコンポーネントはこれで完結しており、状態がどう更新されているかやモバイルの時の挙動なのど細かく気にする必要はなく、StateはGraphQLに、UIはPolarisに任せておけば、開発者はこのコンポーネントのtsxにのみ集中することができます。さらにここまで読んでいただいた型であればわかるようにReduxでの例でAPIと疎通してデータをコンポーネントに表示して色付けするまでには果てしない工程がありましたが、これにより相当スッキリしました。
この構成にして変化したこと
ここまで読んでいただいた方にはある程度わかっていただけるかも知れませんが、これにより弊社は多くの体験の変化がありました。
- Web開発をしたことがない人でも開発できるようになった
- Figmaからコンポーネントを生成できるようになった
- この「お知らせ作成する」と書かれたコンポーネントを生成します
- コンポーネントを選択して、「Appify Figma Polaris Plugin」を選択
- 少し待つと固定値が埋まった状態でReactコンポーネントが表示されます
CSSやReduxといったwebを書いたことがない人にとってはとおい存在のものたちがなくなったり軽減したことにより、コードを書いたことがある人であれば誰でも開発できるようになりました。iOSエンジニアであるCEOのゆずしおも最近ではWebを開発しています。
PolarisがFigmaとReactコンポーネントを対応してくれているおかげで、専用のFigamプラグインを用意して、コンポーネントを生成することができるようになり、開発者は生成されたコンポーネントとAPIを繋ぎこむためにhooksを埋め込む作業だけすれば、画面が出来上がるようになりました。
コンポーネント生成例
seya(@sekikazu01)さんが作ってくれました!ありがとうございます!
- コードの統一性の向上
- コード量の圧倒的に減少 ここまで読んでいただいた方にはわかると思いますし先ほどもどべましたが、圧倒的にコード量が減りました。状態管理に関するコードがなくなり、スタイルがコンポーネントにほとんど現れないので、かなりスッキリしました。
Web開発を行っているとコードを書く人によって細かい部分から大きな部分までさまざまな書き方ができてしまうため、ある程度ESLintで縛ったとしても、なかなか統一しにくく、サーバーの開発をメインでやっているため、その辺を管理しきれていませんでしたが、現在ではかなり統一性が向上しました。
まとめ
サーバーをメインで開発していたメンバーで作り上げてきたこのAppifyのWebは試行錯誤の塊です。かなりいい状態まできているのではないかと思っています!もちろんまだまだ、改善しなければならない点はたくさんありますが、もし、Webのコードに興味がある方、技術の選定理由をもう少し聞きたい方、一緒に改善をしたい方がいらっしゃいましたら是非、ご連絡ください!