読者です 読者をやめる 読者になる 読者になる

速さが足りない

プログラミングとか、設計とか、ゲームとかいろいろ雑記。

ドメイン駆動 - コンテンツ管理 (2)

ドメイン駆動

前回の記事はこちら(リンク先)からどうぞ。
さて、参照しやすいように前回のデータベース設計を置いておきましょう。

f:id:geane:20160304182141p:plain

このうち、記事検索APIをコードにしてみましょう。

public class ArticleList extends DomainObject{
  private List<Article> articles;
  private ArticleListCondition condition;
  public ArticleList(List<Article> articles, ArticleListCondition condition){
    articles = articles;
    condition = condition;
    condition.setAll(articles.size());
  }
  public String getBlogId(){
    if(articles.size() > 0)
      return articles.get(0).getBlogId();
    return null;
  }
}

public class PagingObject{
  private Integer start;
  private Integer end;
  private Integer all;
  //getter,setterは省略
}

public class ArticleListCondition extends PagingObject{
  private Integer tagId;
  private Integer blogId;
  private String term;
  //getter,setterは省略
}

public class ArticleAPIServiceImpl implements ArticleAPIService{
  @DependencyInjection
  private ArticleRepository articleRepository;
  @Override
  public ArticleList find(ArticleListCondition message){
     List<Article> articles = articleRepository.find(message);
     ArticleList result = new ArticleList(articles,message);
     return result;
  }
}

public class ArticleListAPIController extends JSONController<ArticleList>{
  @DependencyInjection
  private Converter jsonConverter;
  @DependencyInjection
  private ArticleAPIService articleAPIService;
  @Override
  public String service(ArticleListCondition condition){
    ArticleList result = articleAPIService.find(parameters);
    return jsonConverter.convert(result);
  }
}

public class ArticleRepositoryImpl extends JDBCRepositoryImpl implements ArticleRepository{
  @Override
  public ArticleList find(ArticleList parameter){
     StringBuilder query = new StringBuilder("select * from article where ");
     List<Object> param = new ArrayList<>();
     if(parameter.getTagId() != null){
       param.add(parameter.getTagId());
       query.append("tag_id = ?");
     }
     //他のパラメータは省略
     return update(query.toString(), (Object[])param.toArray());
  }
}

こんな感じでしょうか。DIしているところはアノテーションを付けてます。ここではDI自体どのように行うかには言及しませんのでご了承ください。あとSQLの「select * 」は見やすいように省略しているだけなので、実際は必要な項目のみを記述してくださいね。

さて、ぱっと見た感じであれば問題ないですし、実際このコードに変更が入らなければまぁ問題ないかな、と思います。
とは言え、変更がないなんて事は滅多にありません。
有り得そうな変更が起こった時にどう変化していくか見ていきましょう。

まずは・・・ユーザがログインしていて、かつ、プレミアムユーザであれば、プレミアム対象の記事を表示し、それ以外はプレミアム対象の記事を表示しない、という要件が発生したとしましょう。
コードで変更すると以下のようになりますよね。

public class ArticleListCondition extends PagingObject{
  private Integer tagId;
  private Integer blogId;
  private String term;
  //追加された箇所
  private String token;
  //getter,setterは省略
}
public class ArticleRepositoryImpl extends JDBCRepositoryImpl implements ArticleRepository{
  //追加された箇所
  @DependencyInjection
  private UserRepository userRepository;
  
  @Override
  public ArticleList find(ArticleListCondition parameter){
     StringBuilder query = new StringBuilder("select * from article where ");
     List<Object> param = new ArrayList<>();
     if(parameter.getTagId() != null){
       param.add(parameter.getTagId());
       query.append("tag_id = ?");
     }
     //追加された箇所
     User user = userRepository.getByToken(parameter.getToken();
     Function<User,Boolean> isPremium = (u) -> {
       if(user == null)
         return false;
       //user.userType == 0 is free user
       if(user.getUserType() ==  0)
         return false;
       //user.userType == 1 is normal user
       //user.userType == 2 is premium user
       //user.userType == 3 is customer support user
       //user.userType == 4 is administrator
       return true;
     };
     if(isPremium(user))
       query.append(" and is_premium = 1 ");
     else
       query.append(" and is_premium = 0 ");

     //他のパラメータは省略
     return update(query.toString(), (Object[])param.toArray());
  }
}

こんな感じでしょうか。まぁ実際にログインしいてるユーザかを判定するトークン発行機能が既にあれば大した事のない機能ですね。
さて、実際は開発中もしくは2次フェーズといいますか、リリース後に追加フェーズもしくは運用開発として更にたくさんの要件が上がってくると思います。

ということで要件をどんどん増やしていってみましょう。
まずは、記事・アプリケーション以外に、ニュースも配信したいという要件が出てきたとしましょう。
ドメイン的には「article」に合致しそうですが、「news」というテーブルをまったく同じ構造で作って、ほぼ同じコードになっていると想定できますね?
実際に書いてみましょう。

public class NewsAPIServiceImpl implements NewsAPIService{
  @DependencyInjection
  private NewsRepository newsRepository;
  @Override
  public NewsList find(NewsListCondition message){
     List<News> news = newsRepository.find(message);
     NewsList result = new NewsList(articles,message);
     return result;
  }
}

public class NewsAPIController extends JSONController<NewsList>{
  @DependencyInjection
  private Converter jsonConverter;
  @DependencyInjection
  private NewsAPIService newsAPIService;
  @Override
  public String service(NewsListCondition condition){
    NewsList result = newsAPIService.find(condition);
    return jsonConverter.convert(result);
  }
}

public class NewsRepositoryImpl extends JDBCRepositoryImpl implements NewsRepository{
  @Override
  public NewsList find(NewsListCondition parameter){
     StringBuilder query = new StringBuilder("select * from news where ");
     List<Object> param = new ArrayList<>();
     if(parameter.getTagId() != null){
       param.add(parameter.getTagId());
       query.append("tag_id = ?");
     }
     if(isPremium(user))
       query.append(" and is_premium = 1 ");
     else
       query.append(" and is_premium = 0 ");

     //他のパラメータは省略
     return update(query.toString(), (Object[])param.toArray());
  }
}

はい、こんな感じですね。「ArticleRepositoryImpl::find」の「isPremium」クロージャはJDBCRepositoryImplへと実装を委譲することができそうなので、そのまま委譲しました。

どんどんいきましょう。更に要件を増やします。今回は配信対象となる機種条件をアプリケーションだけではなく、記事とニュースにも適用できるようにしましょう。
以下のコードでは省略しますが・・・
「device」テーブルに「user_agent」なるカラムを追加して、送信されてきたユーザエージェントを見て機種を判別するものとしましょう。実施はiccidやら何やらでもっと複雑な機種判定になるとは思いますけどね。
実際に記事やニュースを表示できる機種・OSの組み合わせ一覧はアプリケーションの「application_device_os」のような中間テーブルを作って判定するものとしましょう。

public class NewsListCondition extends PagingObject{
  private Integer tagId;
  private Integer blogId;
  private String term;
  private String userAgent;
  //getter,setterは省略
}

public class NewsAPIServiceImpl implements NewsAPIService{
  @DependencyInjection
  private NewsRepository newsRepository;
  @Override
  public NewsList find(NewsListCondition message){
     List<News> news = newsRepository.find(message);
     NewsList result = new NewsList(articles,message);
     return result;
  }
}

public class NewsAPIController extends JSONController<NewsList>{
  @DependencyInjection
  private Converter jsonConverter;
  @DependencyInjection
  private NewsAPIService newsAPIService;
  @Override
  public String service(NewsListCondition condition){
    NewsList result = newsAPIService.find(condition);
    return jsonConverter.convert(result);
  }
}

public class NewsRepositoryImpl extends JDBCRepositoryImpl implements NewsRepository{
  @Override
  public NewsList find(NewsListCondition parameter){
     StringBuilder query = new StringBuilder(
       "select distinct * from news ns "+
       "left join user_device_os udo on udo.news_id = ns.news_id "+
       "left join device_os_version ddo on ddo.device_id = udo.device_id "+
       "left join device dc on dc.device_id = ddo.device_id "+
       "where ");
     List<Object> param = new ArrayList<>();
     if(parameter.getTagId() != null){
       param.add(parameter.getTagId());
       query.append("tag_id = ?");
     }
     //他のパラメータは省略
     Device userDevice = getDeviceByUserAgent(parameter.getUserAgent());
     query.append("device_id = ? ");
     param.add(userDevice.getDeviceId());

     if(isPremium(user))
       query.append(" and is_premium = 1 ");
     else
       query.append(" and is_premium = 0 ");

     //他のパラメータは省略
     return update(query.toString(), (Object[])param.toArray());
  }
}

こんな感じでしょうか。ユーザエージェントから端末のユニークIDを取得するところは親クラスに処理を委譲して共通化を図ってみました。それと、コード量が多くなるので記事以外は省略していますし、記事も大まかなところだけ想定して書いています。



段々と複雑になってきて、更に同じようなコードが重複してきましたね?
このくらいであれば色々な箇所を共通化できるようリファクタリングしていけば、まあまあ変更に耐えうるとは思います。
しかし、時にはもっと激しくドメインの構造を覆すような変更もあるので、もう少し掘り下げていきましょう。

次のお題は、「指定した端末で閲覧できる」以外に「指定した端末以外で閲覧できる」機能を追加したとしましょう。
今のままでは合致するドメインの構造はありませんから新しく追加しなければなりません。
一番簡単でぱっと思いつくのは、「application_device_os、news_device_os、article_device_os」を「whilelist_application_device_os、whilelist_news_device_os、whilelist_article_device_os」に変更し、「blacklist_application_device_os、blacklist_news_device_os、blacklist_article_device_os」を追加する、ですかね。

しかし、よくよく考えると「iOS 4.1」以外で表示する、と「iOS 8.1~4」で表示する、は共存できませんから、「news、application、article」に「is_blacklist_devices」みたいなカラムを追加して、中の値によって関連している中間テーブルはホワイトリストとして扱うか、ブラックリストとして扱うか、切り替えしてもいいわけですね。
これをコードにしてみましょう。

public class NewsRepositoryImpl extends JDBCRepositoryImpl implements NewsRepository{
  @Override
  public NewsList find(NewsListCondition parameter){
     StringBuilder query = new StringBuilder(
       "select distinct * from news ns "+
       "left join user_device_os udo on udo.news_id = ns.news_id "+
       "left join device_os_version ddo on ddo.device_id = udo.device_id "+
       "left join device dc on dc.device_id = ddo.device_id "+
       "where ");
     List<Object> param = new ArrayList<>();
     if(parameter.getTagId() != null){
       param.add(parameter.getTagId());
       query.append("tag_id = ?");
     }
     //他のパラメータは省略
     Device userDevice = getDeviceByUserAgent(parameter.getUserAgent());
     query.append(
       "when ns.is_blacklist_device = 1 "+
       "then device_id != ? "+
       "when ns.is_blacklist_device = 0 "+
       "then device_id = ? "+
       "end "+
     );
     param.add(userDevice.getDeviceId());

     if(isPremium(user))
       query.append(" and is_premium = 1 ");
     else
       query.append(" and is_premium = 0 ");

     //他のパラメータは省略
     return update(query.toString(), (Object[])param.toArray());
  }
}

まあコードに起こすと簡単でしたね?しかし忘れてはならないのは、配信するサービスの数だけこの修正がいるということです。


どんどん進みましょう、と言いたいところですが要件は無限にありますしもうちょっと言及して終わりにしましょう。
今のブラックリストホワイトリストの実装後に、

  • やっぱり条件なしで記事見せたい
  • PCのみ見せたいけどデバイスの登録が多すぎて大変なので、OSやユーザエージェントはあいまい指定したい
等出てくる訳ですね。

そういった要望を実装していくと

  • リリースの度にサービスは停止
  • 元々のドメインの構造も歪に
  • 単体・結合テストの複雑化
  • テスト考慮漏れによるバグの増加
  • 諸々合わせたコストの増加
等々が起こり、最終的に「リプレースしよう!!」ってなりますよね。
そう、保守開発している方なら経験があると思います。


何が悪かったのでしょうか?
例え最初の要件定義~リリースまでもれなく実装したとしても起こり得る問題ですよね。
最初の要件定義で未来の変更というかビジネスビジョンについて深く探っていけばよかったのでしょうか?
まあそんな事やったところで実際とは違うかも知れませんよね。
当初は5年後までに記事・アプリケーション・広告・着うた・待ち受け壁紙を配信する、という要望で、実際に運用してみてニュースやブラックリストホワイトリストが出てくるかも知れませんし。

ということで、次回は変更に強いドメインモデルを以下に作るかについて考えていきましょう。