もやもやエンジニア

IT系のネタで思ったことや技術系のネタを備忘録的に綴っていきます。フロント率高め。

実装から考えてしまう癖に気をつけようという話

これはサービス開発してる人にとっては当たり前かもしれないのですが、自分に対しての戒めでもあるので、なんとなく文章として残しときます。

どんなことかというと、自社サービスを開発・運営していると、不定期に○○をこうしてほしい、△△はユーザーが使いづらいと言っていたので直したほうがいいのではーなどの要望が上がってくるわけですが、その時の思考の入り口として、それはユーザーにとってどのようなメリットが有るのか、体験が改善されるのかというところから入るのではなくて、その言われた要望は実装的に出来そうか出来なさそうか、というところから考えてしまうという話です。

これは割と癖になりやすいかなーと思っていて、早いうちにプログラマーの仕事は言われたことを作る仕事であるみたいな感じで染み付いてしまうと、なかなか変えづらいのかなと感じてます。特に今は僕は2Bのサービスを作っていてクライアントが明確なので2Cのサービスよりダイレクトにユーザーの声を聞きやすく、故に「あ、それは実装できますね、やりましょうか」みたいな感じでほいほい言われたものを作るだけだと、容易に統一感がなく使いづらいプロダクトが出来上がってしまいます。

ユーザーのオペレーションも含めて検討した結果、その要望は実は既存の他の機能で満たせることかもしれないし、見せ方を変えるだけで解決するかもしれない。僕らの仕事はモノを作ることではなく、課題を解決することにあって、何かを開発することはその選択肢のひとつにすぎないのですよね。

というようなことをインターンの子に↓の話をしてるときに改めて思ったので、僕も意識しようと思ったという話でした。おしまい。

イシューからはじめよ―知的生産の「シンプルな本質」

イシューからはじめよ―知的生産の「シンプルな本質」

ちはやふるにはまったので百人一首を暗記するサイトをSPAで作った

夜なべしてコツコツ作っていたのがまあまあ形になったので公開しました。ちはやふる ~結び~の公開には間に合わなかった。。。

https://hyakuninanki.net/

去年から仕事でB向けのサービスをSPAで開発してて、ある程度自分の中で知見がたまって自分でも何か作ってみようと思って、Androidアプリで出したやつ(下記参考)の焼き直しをSPAで公開することにしました。見た目はほぼ一緒。

rei19.hatenablog.com

雑なコメント

  • React/Reduxで取り立てて珍しいものはないです。言語はTypeScript。型最高。ReactはできるだけFSCベースでPropsをViewに変換するようなイメージでComponentを組んでます。
  • styleはstyled-componentsで管理。デザインフレームワークのサポートにblueprintjsというのを使ってみたけど、使わなくても良かったかも(material-uiは仕事で使ってるので避けた)
  • テストもぼちぼち書いてます。jestのsnapshot機能は最高なので、テスト書かないプロジェクトでもsnapshotだけでも取るようにすると意図しない変更が検知できていいのかなと思いました。
  • SSRまでは頑張っていなくて、単にbundleしたjsとcssを撒いているだけという手抜き構成。ちゃんとクローラが見てくれるのかはよくわかってない。。。クロールされた後の検索結果見てから考えます。
  • webpack4に上げたらawsome-typescript-loaderが対応してないのか、ファイル修正するたびにエラーが起きてリロードしなくなってしまった。。。多分ts-loaderにしたら直る(気がする)

おしまい

  • ポートフォリオ代わりにコードは公開してます。もうちょいコンテンツ充実させるのと、インタラクションを調整する予定。

GitHub - rei-m/web_hyakuninisshu

ArchitectureComponentsのViewModelとDataBindingを使う

触るがてら既存のDataBinding + MVVMなアプリにAACのViewModelを入れてみました。対象は結構前にKotlinで書いたアプリ。

GitHub - rei-m/HBFav_material: HBFav for Android with kotlin

2018/07/26 更新 こっちが自分なりの最新の設計なのでこっち見たほうがいいです。 GitHub - rei-m/android_hyakuninisshu

AACのViewModel

developer.android.com

  • Activity/Fragmentとは別のライフサイクルをもったDataHolderの模様
  • 説明ざざっと読んだ感じ onSavedInstanceState いらないのか!と思ったもののそうではなく、普通にActivity保持しないとかにすると消し飛ぶので、自分が死ぬタイミングがわかっていて、Activity/Fragmentとは別のライフサイクルをもったModelであると解釈

書いてみる

Model層

  • 既存の作りと変わらずにObservableなプロパティを公開しているアプリケーションスコープなModelとして作っています。

ViewModel層

  • 既存のDataBindingに食わせていたViewModelは基底クラスにライフサイクルメソッドを生やして、Activity/Fragmenntからそれを呼んでModelの購読/解除をしていました。ここを変えてAACのViewModelを継承するようにします。DataBindingに公開するObservableFieldは変えていません。

自分のブックマーク表示する画面のVM / UserBookmarkFragmentViewModel.kt

class UserBookmarkFragmentViewModel(private val userBookmarkModel: UserBookmarkModel,
                                    userModel: UserModel,
                                    readAfterFilter: ReadAfterFilter) : ViewModel() {

略)

   init {
        userId.addOnPropertyChangedCallback(userIdChangedCallback)
        hasNextPage.addOnPropertyChangedCallback(hasNextPageChangedCallback)
        this.readAfterFilter.addOnPropertyChangedCallback(readAfterFilterChangedCallback)

        disposable.addAll(userBookmarkModel.bookmarkList.subscribe {
            if (it.isEmpty()) {
                bookmarkList.clear()
            } else {
                bookmarkList.addAll(it - bookmarkList)
            }
            isVisibleEmpty.set(bookmarkList.isEmpty())
        }, userBookmarkModel.hasNextPage.subscribe {
            hasNextPage.set(it)
        }, userBookmarkModel.isLoading.subscribe {
            isVisibleProgress.set(it)
        }, userBookmarkModel.isRefreshing.subscribe {
            isRefreshing.set(it)
        }, userBookmarkModel.isRaisedError.subscribe {
            isVisibleError.set(it)
        }, userModel.user.subscribe {
            userId.set(it.id)
        })
    }

    override fun onCleared() {
        userId.removeOnPropertyChangedCallback(userIdChangedCallback)
        hasNextPage.removeOnPropertyChangedCallback(hasNextPageChangedCallback)
        readAfterFilter.removeOnPropertyChangedCallback(readAfterFilterChangedCallback)
        disposable.dispose()
        super.onCleared()
    }

略)
  • 自分が破棄されるタイミングがわかるようになったので、生成時にModelのプロパティの購読開始。onClearedで購読解除するようにしました。ここではuserModel.userの購読開始するとBehaviorSubjectから値が流れてくる -> ViewModelがUserIdの変更を検知してブックマークとる命令を出す、という感じでViewModel生成時にバックグラウンドでデータを取りに行っています。

ViewModelとFactory

  • ViewModelにBundleから取り出した引数を与えたいというような場合、ViewModelProvidersにFactoryを与えることで、ViewModel生成時の処理に介入できるようになります。FactoryはDagger経由でDIするようにしてます。
viewModel = ViewModelProviders.of(this, viewModelFactory).get(OthersBookmarkFragmentViewModel::class.java)

他人のブックマーク表示する画面のVM / OthersBookmarkFragmentViewModel.kt

略)
    // ViewModelProvidersに渡すViewModelを提供するFactory
    class Factory(private val userBookmarkModel: UserBookmarkModel,
                  private val userId: String) : ViewModelProvider.Factory {
        @Suppress("UNCHECKED_CAST")
        override fun <T : ViewModel?> create(modelClass: Class<T>): T {
            if (modelClass.isAssignableFrom(OthersBookmarkFragmentViewModel::class.java)) {
                return OthersBookmarkFragmentViewModel(userBookmarkModel, userId) as T
            }
            throw IllegalArgumentException("Unknown class name")
        }
    }

Factoryを提供するModule / OthersBookmarkFragmentViewModelModule.kt

@Module
class OthersBookmarkFragmentViewModelModule(private val userId: String) {
    @Provides
    @ForFragment
    fun provideViewModelFactory(@Named("othersUserBookmarkModel") userBookmarkModel: UserBookmarkModel): OthersBookmarkFragmentViewModel.Factory =
            OthersBookmarkFragmentViewModel.Factory(userBookmarkModel, userId)
}
  • userBookmarkModelはDaggerからDIされますが対象のユーザーIDはModule生成時に設定してあげる必要があります。

他人のブックマーク表示する画面 / OthersBookmarkFragment.kt

略)
    @dagger.Subcomponent(modules = arrayOf(
            OthersBookmarkFragmentViewModelModule::class,
            BookmarkListItemViewModelModule::class))
    interface Subcomponent : AndroidInjector<OthersBookmarkFragment> {
        @dagger.Subcomponent.Builder
        abstract class Builder : AndroidInjector.Builder<OthersBookmarkFragment>() {

            abstract fun viewModelModule(module: OthersBookmarkFragmentViewModelModule): Builder

            override fun seedInstance(instance: OthersBookmarkFragment) {
                viewModelModule(OthersBookmarkFragmentViewModelModule(instance.userId))
            }
        }
    }

    @dagger.Module(subcomponents = arrayOf(Subcomponent::class))
    abstract inner class Module {
        @Binds
        @IntoMap
        @FragmentKey(OthersBookmarkFragment::class)
        internal abstract fun bind(builder: Subcomponent.Builder): AndroidInjector.Factory<out Fragment>
    }
}
  • Fragment内のinner classとしてSubComponentを定義してその中のseedInstance内でFragmentのインスタンスからuserIdを取り出してModuleを作成しています。onSavedInstanceStateから値を復元したい場合はDIされたviewModelFactoryに取り出した値をセットしてからViewModelProviderに渡すようにしました。

書いてみて

  • 毎回Activity/FragmentのライフサイクルでviewModel.onXXXX を呼んでたのでそれがなくなったのはだいぶ楽
  • こういう使い方でいいのか、あるいはDataBindingのViewModelとは別のレイヤーとして考えるべきなのか?とか一人で書いてるとどっちがいいのかよくわからんという感じになったのでトークしたい。

enzymeでReact RouterのwithRouterで包んだRedux Containerのテストする

  • 今の仕事はReact/ReduxなSPAをTypeScriptで書いてて、HOCのテストの書き方ちょっと迷ったのでメモとして残しておきます。

関連する主なライブラリ

  • "react": "~16.0.0",
  • "react-dom": "~16.0.0"
  • "react-redux": "~5.0.6"
  • "react-router-dom": "~4.2.2"
  • "redux": "~3.7.2"
  • "enzyme": "~3.1.0"
  • "enzyme-adapter-react-16": "~1.0.1"
  • "redux-mock-store": "~1.3.0"

こんな感じ

  • ラップするComponent。コードは適当にURIから受け取ったIDが表示 + ボタンを一つ持つだけのやつ。

components/HogeContent/index.tsx

export interface HogeContentOwnProps extends React.ClassAttributes<HogeContent>;

export interface HogeContentConnectedProps {
  readonly disabled: boolean;
}

export interface HogeContentDispatchProps {
  readonly onClickButton: () => void;
}

export interface HogeContentRouterProps = RouteComponentProps<{id: number}>;

export type HogeContentProps = HogeContentOwnProps & 
  HogeContentConnectedProps & 
  HogeContentDispatchProps & 
  HogeContentRouterProps;

export default class HogeContent extends React.Component<HogeContentProps> {

  constructor(props: HogeContentProps) {
    super(props);
    this.onClickButton = this.onClickButton.bind(this);
  }

  public render() {
    return (this.props.disabled) ? (
      <div>
        {this.props.match.params.id}
      </div>
    ) : (
      <div>
        <button
          type="submit"
          onClick={this.props.onClickButton}
        >
          {this.props.match.params.id}
        </button>
      </div>
    );
  }
}
  • テスト対象となるComponentを包むContainer

containers/Hoge/index.tsx

const mapStateToProps = (state: GlobalState, _: HogeRouterProps): HogeContentOwnProps & HogeContentConnectedProps {
  const { disabled } = state.hoge;
  return {
    disabled
  }
};


const mapDispatchToProps = (dispatch: Dispatch<GlobalState>, props: HogeRouterProps): HogeContentDispatchProps {
  return {
    onClickButton: () => {
        dispatch(sendHoge(););
        props.history.push('/fuga');
    }
  };
};

export default withRouter(connect(mapStateToProps, mapDispatchToProps)(HogeContent));
  • こんな感じで '/hoge' で来たらHogeContentを表示するみたいなやつに対して以下の項目をテストしたいとします。
    • Reduxのstoreからdisabledを渡しているか
    • onClickButtonが押されたらsendHogeをdispatchしているか
    • onClickButtonが押されたら /fuga に遷移しているか
  • なおテストはmochaで走らせてます。
describe('containers', () => {
  describe('<HogeIndex />', () => {
    let wrapper: ShallowWrapper;
    let mockStore: MockStore<{}>;
    let mockRouter: any;

    beforeEach(() => {
      mockStore = configureMockStore()({
        hoge: {
          disabled: false
        }
      });
      mockRouter = {
        route: {
          match: { params: { id: 1 } },
          location: {
            pathname: '/hoge'
          }
        },
        history: {
          push: sinon.stub()
        }
      };

      wrapper = shallow(
        <Hoge
          match={mockRouter.route.match as any}
          location={mockRouter.route.location as any}
          history={mockRouter.history as any}
        />
      ).dive({
        context: {
          router: mockRouter
        }
      }).dive({
        context: {
          store: mockStore
        }
      });
    });

    it('should component received props', () => {
      const actual = wrapper.find(HogeContent).props().disabled;
      assert.equal(actual, false);
    });

    it('should dispatch sendHoge when button clicked', () => {
      const actual = wrapper.find(HogeContent).props().onClickButton();
      const actions = mockStore.getActions();  // configureMockStoreを使うとこのようにDispatchされたActionが取れる
      assert.equal(actions[0].type, 'SEND_HOGE_ACTION');
    });

    it('should go to fuga when button clicked', () => {
      const actual = wrapper.find(HogeContent).props().onClickButton();
      assert(mockRouter.history.push.calledWith('/fuga'));
    });
  });
});
  • Enzymeのdiveを使うとラップされたComponentをshallow renderしたのが返ってくるので、その時にHOCから注入されるContextを設定してあげます。Reduxだけの場合はshallowの2番目の引数にContextを設定できるのですが、withRouterで更に包んだ場合は withRouter -> Router -> connect -> componentという階層になってるので、diveしつつ各階層で必要な情報を注入して上げる感じでテスト対象のComponentをrenderできました。

参考

dive() · Enzyme

Dagger 2.11 でAndroid Supportを使ってDIする

個人で出してるアプリでDagger使ってるところでDaggerが提供しているAndroid向けのモジュールを使うようにしたときのめも。

公式

google.github.io

  • 基本的には公式のドキュメントの通りなのですが、詰まったポイントがあったので、自分の手順としてまとめておきます。

準備

 compile 'com.google.dagger:dagger:2.11'
 compile 'com.google.dagger:dagger-android:2.11'
 compile 'com.google.dagger:dagger-android-support:2.11'
  • ApplicationComponentにAndroidInjectionModule.classを追加します。SupportLibraryを使う場合はAndroidSupportInjectionModule.classになります。
@Singleton
@Component(modules = {
        AndroidSupportInjectionModule.class,
        ApplicationModule.class
})
public interface ApplicationComponent {
    void inject(App application);
}
  • Applicationクラスを継承してHasActivityInjectorを実装しておきます
public class App extends MultiDexApplication implements HasActivityInjector {

    @Inject
    DispatchingAndroidInjector<Activity> dispatchingActivityInjector;

    @Override
    public void onCreate() {
        // ここは諸々の準備が終わってビルドしてDaggerApplicationComponentが作られた後に書く。
        DaggerApplicationComponent.builder()
            .applicationModule(new ApplicationModule(this))
            .build()
            .inject(this);
    }

    @Override
    public AndroidInjector<Activity> activityInjector() {
        return dispatchingActivityInjector;
    }
}

ActivityにDIする

  • HogeActivityにDIしたい場合

  • AndroidInjectorを継承したSubcomponentを作成します。ここではそのActivityに必要なModuleの定義を行います。

@Subcomponent(modules = {
        ActivityModule.class, // Activity内でDIするインスタンスを提供するモジュール
        HogeActivityViewModelModule.class, // Activity内でDIするインスタンスを提供するモジュール
        FugaFragmentModule.class //  Activityの下にFragmentがいる場合。後述。
})
public interface HogeActivitySubcomponent extends AndroidInjector<HogeActivity> {
    @Subcomponent.Builder
    abstract class Builder extends AndroidInjector.Builder<HogeActivity> {
        // Moduleがコンストラクタで引数を取る場合、abstractでBuilderを返すメソッドを定義して
        // seedInstanceでModuleのインスタンスを作って生やしたメソッドに渡してあげます。
        public abstract Builder activityModule(ActivityModule module);
            
        @Override
        public void seedInstance(HogeActivity instance) {
            activityModule(new ActivityModule(instance));
        }
    }
}
  • 次にSubcomponentに対応するModuleを作成します。
@Module(subcomponents = HogeActivitySubcomponent.class) // 上で定義したSubcomponentを指定
public abstract class HogeActivityModule {
    @Binds
    @IntoMap
    @ActivityKey(HogeActivity.class) 
    abstract AndroidInjector.Factory<? extends Activity> bindHogeActivityInjectorFactory(HogeActivitySubcomponent.Builder builder);
 }
  • もしSubcomponentが単に依存関係を定義するだけだったら、Subcomponentを作らずにContributesAndroidInjectorアノテーションを使うこともできます。
@Module
public abstract class HogeActivityModule {
    @ContributesAndroidInjector(modules = {
        ActivityModule.class,
        HogeActivityViewModelModule.class,
        FugaFragmentModule.class
    })
    abstract HogeActivity contributeHogeActivityInjector();
}
  • ModuleまでできたらApplicationComponentにModuleを追加します。
@Singleton
@Component(modules = {
        AndroidSupportInjectionModule.class,
        ApplicationModule.class,   // アプリケーションスコープでDIするModule
        HogeActivityModule.class // 作成したActivityのModule
})
public interface ApplicationComponent {
    void inject(App application);
}
  • 最後にActivityのonCreateでInjectしてあげれば、Activity内のInjectアノテーションがついたフィールドにModuleからDIされます。
public class HogeActivity extends AppCompatActivity {

    @Inject
    HogeActivityViewModel viewModel;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        AndroidInjection.inject(this);
        super.onCreate(savedInstanceState);
        ...
    }
}

FragmentにDIする

  • さきほどのHogeActivityにFugaFragmentをぶら下げてFragmentにDIします。

  • HogeActivityにHasSupportFragmentInjectorを実装します。(普通のFragmentを使う場合はHasFragmentInjector)

public class HogeActivity extends AppCompatActivity implements HasSupportFragmentInjector {

    @Inject
    DispatchingAndroidInjector<Fragment> fragmentInjector;
    
    // 略
    
    @Override
    public AndroidInjector<Fragment> supportFragmentInjector() {
        return fragmentInjector;
    }
}
  • Activityと同様にSubcomponentとModuleを定義していきます。
@Subcomponent(modules = {
        FugaFragmentViewModelModule.class
})
public interface FugaFragmentSubcomponent extends AndroidInjector<FugaFragment> {
    @Subcomponent.Builder
    abstract class Builder extends AndroidInjector.Builder<FugaFragment> {
    }
@Module(subcomponents = FugaFragmentSubcomponent.class)
public abstract class FugaFragmentModule {
    @Binds
    @IntoMap
    @FragmentKey(FugaFragment.class)
    abstract AndroidInjector.Factory<? extends Fragment> 
 bindFugaFragmentInjectorFactory(FugaFragmentSubcomponent.Builder builder);
}
  • Activityと同様に ContributesAndroidInjector を使うこともできます。

  • ModuleまでできたらHogeActivityComponentにModuleを追加して依存関係を繋げます。

  • 最後にFragmentにInjectしてあげます。Fragmentの場合はonAttachでDIします。

public class FugaFragment extends Fragment {

    @Inject
    FugaFragmentViewModel viewModel;

    @Override
    public void onAttach(Context context) {
        // SupportライブラリのFragmentの場合はAndroidSupportInjection
        // 普通のFragmentの場合はAndroidInjection
        AndroidSupportInjection.inject(this);
        super.onAttach(context);
        ...
    }
}
  • これでbuildするとDaggerApplicationComponentが生成されるので、上記のAppクラスのonCreateでinjectしてあげればOKです。

追記: DaggerApplication・Activity・Fragmentを使ってもっと楽に書く

  • Activity/Fragmentを作るたびにAndroidInjection.injectをライフサイクルの中で書くのはちょっとだるいのですが、その辺が定義済みのクラスが用意されています。これを使うと以下のように書き換えることができます。
// ApplicationComponent にAndroidInjectorを継承させる
@Singleton
@Component(modules = {
        AndroidSupportInjectionModule.class,
        ApplicationModule.class,
        HogeActivityModule.class
})
public interface ApplicationComponent extends AndroidInjector<App> {}
// HasActivityInjectorを外してDaggerApplicationを継承するようにする
public class App extends DaggerApplication {

    @Override
    public void onCreate() {
        super.onCreate();
        // 自前でinjectしてたところは消す
    }

    @Override
    protected AndroidInjector<App> applicationInjector() {
        // DaggerApplicationに生えているapplicationInjectorを実装する。
        // Injectは親クラスでやってくれる
        return DaggerApplicationComponent.builder()
                .applicationModule(new ApplicationModule(this))
                .build();
    }
}
// HasSupportFragmentInjectorを外してDaggerAppCompatActivityを継承するようにする
public class HogeActivity extends DaggerAppCompatActivity {

       // fragmentInjectorは親クラスが持ってるので消す
//    @Inject
//    DispatchingAndroidInjector<Fragment> fragmentInjector;
    
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        // Injectは親クラスでやってくれるので消す
//        AndroidInjection.inject(this);
        super.onCreate(savedInstanceState);
        ...
    }
    
//    @Override
//    public AndroidInjector<Fragment> supportFragmentInjector() {
//        return fragmentInjector;
//    }
}
// DaggerFragmentを継承するようにする
public class FugaFragment extends DaggerFragment {

    @Override
    public void onAttach(Context context) {
        // Injectは親クラスでやってくれるので消す
//        AndroidSupportInjection.inject(this);
        super.onAttach(context);
        ...
    }
}

おわり

  • 前は結構頑張ってApplicaiton -> Activity -> Fragmentの依存関係を繋げてたのですが、その辺は楽に書けるようになったのかなーと思います。あとはもうちょい生成されたコードを眺めてどんな感じになったのか把握した方がよいですね。
  • 実際に自分のアプリで書いたのはこちら。テストちゃんと書けてないのでDIの恩恵を完全に享受してるとはいえないのですが、ちょいちょい追加していきます。

https://github.com/rei-m/android_hyakuninisshu/pull/86

  • 余談ですが、新しいライブラリとか設計を試すときに簡単なTODOアプリとかだといまいち使用感がつかめないので、実際にリリースしたそこそこのアプリで試すようにしてます。次はArchitecture Components使ってオレオレViewModelを置き換える予定。仕事でAndroidから離れてだいぶキャッチアップが遅れてるのでほどほどに追いかけていく気持ち。

Androidアプリ開発 74のアンチパターン

Androidアプリ開発 74のアンチパターン

現場で役立つシステム設計の原則読んだ

「現場で役立つシステム設計の原則」をちびちび読んでたのを読み終えたので雑記。

一言感想

  • DDDといえばエヴァンス氏のドメイン駆動設計がありますが、そちらを読んでなくても十分理解できる内容でした。本書はガチなDDD本ではなく、現場目線からの変更に強いコードをどう設計して書くかという内容を具体例を交えて説明しています。個人的には3章「業務ロジックをわかりやすく整理する」4章「ドメインモデルの考え方で設計する」辺りはとてもわかりやすいし実践しやすい内容で、新人氏にはリーダブルコードと合わせてお勧めしたいなというところで良書でした。
  • 願わくば本書に出てきたサンプルを実践した形の動くアプリケーションのコードが公開されてると全体が見通せてありがたかったです。

フロントの設計とか

  • 本の内容とは直接の関係は無いのですが、フロント(アプリ/Web問わず)作ってると設計の話はそれなりによく出てくる話題で、スマートUI/MVC/MVP/MVVM/CleanArchitecture/etc。。。と色々な思想が挙げられますが、たまにここにDDDが並ぶのを見かけるのですよね。個人的にはそれはちょっとどうかなと思っていて、戦術的DDD(エンティティとかリポジトリとか技術的な手法)にのみフォーカスしていて本質を忘れていると感じてます。本来は戦略的DDD(ドメインの関心の見つけ方とかコンテキストの切り分け方とかドメインモデルをどう探し出すかとか)がまず先にあって、それをどうコードに表現するかという話が戦術的DDDになるはずです。なので別にエンティティとかリポジトリがいなくてもドメインを綺麗に表現できていればDDDをうまく取り入れられてるよねというところで、設計のコンテキストでDDDが出てくるとちょっともやっとします。

責務を意識してReact/Reduxを使う

rei19.hatenablog.com

  • ↑の続き。仕事でReact/ReduxなSPAを書き始めているのですが、引き継いだコードを読んだり書き直したりして思ったことなど。

TL;DR

  • Componentの仕事はもらってきたpropsを素直に表示するのとEventの発火だけにしような。

FatなComponent

  • なかなかに巨大でやたらRender以外の処理が太ってるReactのComponentだな。。。と思いながらコードを読むと、以下のようなロジックになっていました。具体的なコードは書けないので雑なイメージはこんな感じ

containers/Sample.tsx

const mapStateToProps = (state: GlobalState) => {
    const { hogeList, fugaList } = state;
    return {
        hogeList,
        fugaList
    };
};

const mapDispatchToProps = {
    callGetHogeApi,
    callGetFugaApi,
    callPostHogeFugaApi
}

export default connect(mapStateToProps, mapDispatchToProps)(Sample)

components/Sample.tsx

interface Props {
    hogeList?: Hoge[]; // HogeAPIのレスポンス
    fugaList?: Fuga[]; // FugaAPIのレスポンス
    callGetHogeApi: () => void; // 表示に必要な情報をGetするActionをDispatchするやつ
    callGetFugaApi: () => void; // 表示に必要な情報をGetするActionをDispatchするやつ
    callPostHogeFugaApi: (param: HogeFugaRequestParam) => void; // 更新をPostするActionをDispatchするやつ
}

// 略

export default class Sample extends React.Component<Props, State> {

// 略

    public componentDidMount() {
        this.props.callHogeApiAction();
        this.props.callFugaApiAction();
    }

// 略

    public render() {
        // 各APIのレスポンスが全部揃っているかpropsを見て判定

        // レスポンスが全部揃ってたらrender開始。それ以外はローディング表示
        const piyoList = createPiyoList();
        return (
            // 略
        );
    }

    // Componentの状態をサーバー側に送信する的な処理 
    private onSubmit() {
        // Props/StateからAPIにリクエストするための情報を作成する
        const params: HogeFugaRequestParam = {
            // 略
        };
        this.props.callPostHogeFugaApi(params);
    }

    private createPiyoList() : Piyo[] {
        const { hogeList, fugaList } = this.props;
         // 表示するために必要なpiyoListをhogeListとfugaListをいい感じにマージして作る
        return piyoList;
    }
}

上の例だと表示するために呼んでるAPIは2本ですが、実際には4本とか5本とかあったりします。今の作りでのReact/Reduxなアプリの各レイヤーの責務を見てみると以下の感じになります。

  • Action
  • Reducer
    • APIのレスポンスをそのまま新しいStateとしてReduxStoreに保存する
  • Container
    • Storeから画面で使うレスポンスとdispatchするActionをcomponentに渡す
  • Component
    • APIを呼ぶActionをdispatchする
    • レスポンスから必要な値を抜いて自分が必要な形に加工する。またcomponentによってはそれをstateに保存していたりしている。
      • 加工のためのutilityあるいはhelperという名で切り出されたロジックが大量にある。または各component内のprivate関数で重複して存在しているものもある
    • APIに投げるパラメータを作る(ActionのIF === APIのIFになってる)

という感じで、API呼ぶ->storeにレスポンスが保存される->containerはそれをcomponentにスルーパス->componentが色々頑張るといった形になっていて、componentにロジックが集中する作りになってます。

どうやってcomponentを薄くするか

今の作りの問題は以下の点です。

  • Propsとして生のAPIのレスポンスを受け取って自分で加工しているため、componentはAPIのレスポンスを意識しなければいけなく、そのための余計なロジックを実装しなければいけない。
  • Actionを通り越してAPIの詳細な実装をcomponentが意識しなければいけない。

本来、Reduxに状態の管理を移譲することでReactのComponentは渡ってきたPropsを素直に表示するだけという作りになるはずなので、componentではなくてcontainerに仕事をさせてcomponentが望む形に加工したものを渡すようにします。また、Action周りも実装を意識させないようなIFにしてAPIの存在をAction内に閉じ込めます。修正後はこのような形になりました。

containers/Sample.tsx

const mapStateToProps = (state: GlobalState) => {
    const { isLoading, hogeList, fugaList } = state;
    const piyoList = createPiyoList(this.props.hogeList, this.props.fugaList);
    return {
        isLoading,
        piyoList
    };
};

const mapDispatchToProps = (dispatch: Dispatch<GlobalState>) => {
    return {
        onStart: () => {
            dispatch(fetchHogeFugaList());
        },
        onSubmit: (name: string) => {
            dispatch(createPiyo(name));
        }
    }
};

export default connect(mapStateToProps, mapDispatchToProps)(Sample)

components/Sample.tsx

interface Props {
    isLoading: boolean;  // 読み込み中か判定
    piyoList: Piyo[]; // 画面で表示するPiyoの一覧
    onStart: () => void; // 画面レンダリング開始時のイベント
    onSubmit: (name: string) => void; // 新しいPiyo作成時のイベント
}

// 略

export default class Sample extends React.Component<Props, State> {

// 略

    public componentDidMount() {
        this.props.onStart();
    }

// 略

    public render() {
        if (this.props.isLoading) {
            return (
                // くるくる表示する
            );
        }

        return (
            // PiyoList表示する
        );
    }

    private onSubmit() {
        // 実際にはこのへんもonChangeで全部reduxに流すのでstateは無くなる。
        this.props.onSubmit(this.state.name);
    }
}

という感じになりました。変更後の責務は以下の通り。

  • Container
    • Storeから画面で使うレスポンスとdispatchするActionをcomponentに渡す
    • Storeから必要なレスポンスを加工してcomponentに渡す
    • componentで発火するイベントに対応するActionをmappingする(mapDispatchToPropsの通りですね)
  • Component
    • APIを呼ぶActionをdispatchする
    • レスポンスから必要な値を抜いて自分が必要な形に加工する。またcomponentによってはそれをstateに保存していたりしている。
      • 加工のためのutilityあるいはhelperという名で切り出されたロジックが大量にある。または各component内のprivate関数で重複して存在しているものもある
    • APIに投げるパラメータを作る(ActionのIF === APIのIFになってる)
    • containerから受け取ったpropsを表示する
    • 画面で発生するライフサイクル/ユーザーのアクションのイベントをcontainerに通知する

また、複数API呼んでいたところは1つのアクションとして再定義しています。非同期部分はもともと redux-promise を使ってたのでそのまま使って、Promiseでまとめて2本走らせて両方取れたらreducerに回るようにしています。

actions/fetchHogeFugaList.ts

export function fetchHogeFugaList(): FetchHogeFugaListAction {
    return {
        type: FETCH_HOGE_FUGA_LIST,
        payload:  Promise.all([
            axios.get(GET_HOGE_LIST),
            axios.get(GET_FUGA_LIST),
        ]
    };
} 

おわり

  • このように対応を入れたことでcomponentの見通しが良くなり、componentがAPIを意識しなくなりました。また、メリットとしてテストが格段に書きやすくなりました。この記事の例だと大したことなさそうなのですが、実際には大量のPropsが定義されていてテストデータを作ろうにも本当に必要な情報はどれなんだーという感じだし、イベントの確認もAPIと密結合しているので大変でした。
  • reduxをちゃんと使えばreactのcomponentは薄く保つことができ、Viewとロジックを分離できるようになるので意識して書いていきたいですね。
    • まだredux触り始めな感じなので理解が間違ってたらコメントください m( )m