Angularのプロジェクトでチームに人が増えるときにやりたいこと


この記事は Angular #2 Advent Calendar 2019 の21日目の記事です。

こんにちは、Angularをはじめて2年目になるフロントエンドエンジニアの北川です。
日頃はAngular+Node.js(Lambda Serverless)での開発を行なっています。

新規開発でng newから作ったAngularアプリを1人エンジニアプロジェクトとしてコツコツ開発して、1年ほど経ったところでメンバーが増えることになったので、1人プロジェクトからチーム開発にシフトさせるために負債返却として行なったTips的なことについて紹介します。

ファイルを分割する

一つのファイルに多くの処理を書いていると、他の人が機能の追加をする際に同じファイルに処理を書き加えることをしてしまい、放っておくと巨大なファイルができあがってしまいます。
その結果、ファイル内の見通しが悪く、コンフリクトも多くなります。

×悪い例
utils.tsというファイル名から治安が悪いです。雑多な処理が一つのファイルにまとめられてしまっている例です。

utils.ts
export function formatDate(dateStr: string): Date{
....
}
export function formatCurrency(currencyStr: string): Currency{
....
}

◯良い例
各関数ごとにファイルを分割しました。目安としては、1ファイルに1exportが良いと思います。

format-date.ts
export function formatDate(dateStr: string): Date{
....
}
format-currency.ts
export function formatCurrency(currencyStr: string): Currency{
....
}

モデルに関してもファイル分割を行なった例です。RestaurantKeyクラスは他のクラスが持つことがないとしても、restaurant.modelのファイルには書かずに、restaurant-key.modelファイルに分けます。

restaurant.model.ts
export class Restaurant{
  restaurantKey: RestaurantKey;
  tel: Tel;
}
restaurant-key.model.ts
export class RestaurantKey{
  key: string;
}
tel.model.ts
export class Tel{
  phoneNumber: string;
}

ディレクトリを整理する

ファイルを分割し終えたら、正しいディレクトリに格納されるように整理します。
ディレクトリが正しく整理されていることで、フォルダ内検索がしやすくなり、同様のものを作って車輪の再発明となってしまうことが防げます。

弊社で推奨されているAngularのsrc/app以下のディレクトリ構成です。

-- src
|-- app
  |-- pages ... componentなど
    |-- shared ... 共通コンポーネントなど
  |-- models ... ドメインモデルクラス、値オブジェクトクラス
  |-- shared ... サービスクラス、共通処理など
  |-- utils ... アプリ非依存の汎用処理
  |-- libs ... apiやサードパーティライブラリに関するもの
    |-- api

extendsをやめる

extendsを一概には否定しませんが、2重〜3重とextendsしてしまうと処理が見えづらくなります。
1人ですべての親子関係を把握している内は良いですが、他者が継承しているクラス側を用いたときに思わぬ親クラスの挙動で事故が発生するリスクがあります。

× 悪い例
各クラスが何のプロパティを持っていて、何のメソッドを持っているのかが一目で把握しづらい

// 基底のクラス
export abstract class BaseRestaurant{
  id: RestaurantId;
}

// 標準クラス
export class Restaurant extends BaseRestaurant{
  name: string;
  tel: PhoneNumber | null;
  email: Email | null;

  getContact(): PhoneNumber | Email | null{
    if(this.tel){ return this.tel;}
    if(this.email){ return this.email;}
    return null;
  }
}

// リストで取得したときの一部省いたクラス
export class RestaurantMini extends BaseRestaurant{
  name: string;
}

// 詳細情報を含んだクラス
export class RestaurantDetail extends Restaurant{
 tables: Table[];
 courses: Course[] 
}

○ 良い例
基底の必ず持つべきプロパティやメソッドはinterfaceにし、extendsではなくimplementsを用いています。また、extendsによって継承されていたメソッドは各クラスごとに書くことになりますが、ここではContactクラスを作ることで共通されるべきビジネスロジックを1箇所にまとめました。

// 基底のクラス
export interface BaseRestaurant{
  id: RestaurantId;
}

class Contact{
  tel: PhoneNumber;
  email: Email;

  getItem(): PhoneNumber | Email | null{
    if(this.tel){ return this.tel;}
    if(this.email){ return this.email;}
    return null;
  }
}

// 標準クラス
export class Restaurant implements BaseRestaurant{
  id: RestaurantId;
  name: string;
  contact: Contact;

  getContact(): PhoneNumber | Email | null{
    return this.contact.getItem();
  }
}

// リストで取得したときの一部省いたクラス
export class RestaurantMini implements BaseRestaurant{
  id: RestaurantId;
  name: string;
}

// 詳細情報を含んだクラス
export class RestaurantDetail implements BaseRestaurant{
 id: RestaurantId;
 name: string;
 contact: Contact;
 tables: Table[];
 courses: Course[];

 getContact(): PhoneNumber | Email | null{
    return this.contact.getItem();
  }
}

effectsをやめる

Angularの場合はngrxでのeffectsのことを指しています。effectsは副作用を扱うためのものですが、コードの処理の流れを追っても途中で途切れてしまい、該当のeffectを探さないといけないので初見の人には難易度が高くなります。

x 悪い例
まずloadアクションをdispatchしてstoreloadingフラグを立てます。effect内でAPIから値を取得して、結果をloadSuccessのアクションをdispatchしてstoreへ格納するという例です。dispatchされてreducerへ渡されるまでは把握しやすいですが、effectが途中で呼ばれることが把握しづらいです。

page.ts
load(){
  this.store.dispatch(load());
}
reducer.ts
export function reducer(
  state = initialState,
  action: Action,
): State {
  switch (action.type) {
    case load.type:
      return { ...state, loading: true };
    }
    case loadSuccess.type:
      return {...state, list: action.payload, loading: false };
  }
}

effect.ts
@Effect()
load$: Observable<Action> = this.actions$.pipe(
    ofType<ReturnType<typeof load>>(load.type),
    switchMap((action) => {
      return this.service.load(action.payload);
    }),
    map(list => loadSuccess(list))
  );

○ 良い例
effectを介さずにpage内でasync-awaitを使い、一連の処理が書かれているので処理の流れがわかりやすくなっています。

page.ts
async load(){
  this.store.dispatch(load());
  const list = await this.service.load(action.payload);
  this.store.dispatch(loadSuccess(list))
}

TODOコメントを書く

修正したい箇所はあるが、リファクタリングが間に合わない場合の最終手段です。
負債であることを明言し、これを見た人はここを真似しないでください、という注意書きをすることで負債の拡大を防ぎます。理由や正しい書き方をissueとして書いておくと尚良いです。

/**
 * @TODO RestaurantEffectsを廃止する #101
 * https://github.com/me/sample-repo/issues/101
 */
@Injectable()
export class RestaurantEffects {

おわりに

Angular限った話ではないので、1人プロジェクトから脱してチーム開発をするときの参考になれば幸いです。

お次はAngular #2 Advent Calender 22日目、@aquila101さんです!