Springからの要求に応じた付加応答を追加する(1)

概要

近年、特定のコンテンツに付加属性を加えて表示するUIが増加しています。付加属性の例を挙げると、賛成/反対、コメント、共有、リンク、関連記事、推薦投稿などがあります。これらの付加属性は企画要求やパフォーマンスの問題から、クライアント別に異なるUIを表示しなければならない場合があります。Webサーバーの開発者としてよく経験する要件です。このような要件をJavaとSpring Frameworkを利用して、どのようにすればOOPらしく解いていけるでしょうか。課題解決とリファクタリングを経て、少しずつより良いアプリケーションを作ってみよう。

要件整理

  • 掲示板の詳細API
  • Webでは、コメントと推薦スレッドリストを表示する必要がある
  • Mobileでは、コメントだけを表示する必要がある

サービスの構造
実際のドメインであるBoardをサービスするMicroServiceがあり、コメント、作成者などのメンバ情報などは他のMicroServiceに分離されています。BoardServiceはそれ自体で1つのサービスであり、付加属性を組み合わせる役割をします。(API Ochestration)

解決方法の考え方

if (resolveDevice(request) == Device.APP) {
  // ...
} else {
  // ...
}

ifには理解しやすいという良い点があります。しかし上記の例では、OCPを保つことができません。

原則

「特定のクライアントで、特定の付加情報を照会したい」というように、要件は今後も追加される可能性があります。うまく設計して今後に備え、ロジックを追加するときのコストを削減しよう。

  • 可能な限りOOPに:小さなclassが互いに協力して大きな問題を解決するように!
  • 装飾(decoration)を追加するイメージで動作させたい
  • クライアントが必要な付加情報を要請するように実装する

一度決められた設計は修正せずに拡張可能に

予想されるAPI形式

  • クライアントが必要な付加情報を要請するように実装しよう。

基本
Boardはid,title,content属性を持っています。
GET /boards/1

{
  "id": 1,
  "title": "title1",
  "content": "content1"
}

コメントを追加

クライアントがコメント(comments)を追加情報として要請できます。
GET /boards/1?attachment=comments

{
    "id": 1,
    "title": "title1",
    "content": "content1",
    "comments": [{
        "id": 1,
        "email": "Eliseo@gardner.biz",
        "body": "laudantium enim quasi est quidem magnam voluptate ipsam eos\ntempora quo necessitatibus\ndolor quam autem quasi\nreiciendis et nam sapiente accusantium"
    }]
}

コメントと作成者情報

クライアントがコメントと作成者情報(writer)を追加情報として要請できます。
GET /boards/1?attachment=comments,writer

{
    "id": 1,
    "title": "title1",
    "content": "content1",
    "comments": [{
        "id": 1,
        "email": "Eliseo@gardner.biz",
        "body": "laudantium enim quasi est quidem magnam voluptate ipsam eos\ntempora quo necessitatibus\ndolor quam autem quasi\nreiciendis et nam sapiente accusantium"
    }],
    "writer": {
        "id": 1,
        "username": "Bret"
    }
}

 

基本APIの作成

まずは基本的なAPIを作成しよう。

webモジュールの作成

依存性の設定
現在(2018-03-10)基準で最新バージョンのSpring-Boot 2.0.0.RELAESEを使用しました。

dependencies {
  compile('org.springframework.boot:spring-boot-starter-data-jpa')
  compile('org.springframework.boot:spring-boot-starter-web')  
  compileOnly('org.projectlombok:lombok')
  runtime('com.h2database:h2')
}

Entity

@Data
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@Table(name = "board")
@Entity
public class Board {
    @Id
    @GeneratedValue
    private Long id;
    private String title;
    private String content;

    public Board(@NonNull String title, @NonNull String content) {
        this.title = title;
        this.content = content;
    }
}

Controller

@RestController
@RequestMapping("/boards")
public class BoardController {
    @Autowired
    private final BoardRepository boardRepository;

    @GetMapping("/{id}")
    public Board getOne(@PathVariable("id") Board board) {
        return board;
    }
}

事前にデータを残す

@SpringBootApplication
public class SimpleAttachmentApplication implements CommandLineRunner {

    @Autowired
    private BoardRepository boardRepository;

    public static void main(String[] args) {
        SpringApplication.run(SimpleAttachmentApplication.class, args);
    }

    @Override
    public void run(String... args) throws Exception {

        boardRepository.save(new Board("title1", "content1"));
        boardRepository.save(new Board("title2", "content2"));
        boardRepository.save(new Board("title3", "content3"));
    }
}

サーバーを起動して実行
GET /borads/1

{
  "id": 1,
  "title": "title1",
  "content": "content1"
}

出典:https://github.com/supawer0728/simple-attachment/tree/base-api

基本APIにattachmentを実装する

attachmentの実装手順をSpringのMVCリクエスト処理フローに沿ってまとめてみました。

  1. 必要に応じてInterceptorからattachmentを解析して保存する
      1-1. 必要な場合がいつか定義する
      1-2. attachmentを解析するclassを定義する(AttachmentType)
  2. attachmentRequest Scopebeanに入れておいて、必要なときに取り出して使用(AttachmentTypeHolder class定義)
  3. Controllerからオブジェクトが返されたら、必要な属性を追加する
      3-1. Controllerのロジックは変更しない
      3-2. AOPを通じて、1-1必要な部分を把握し、attachmentのためのサービスロジックを実行
      3-3. Boardentityは作成、変更、削除の用途で残しておき、読み込みの要請にはcomments、writerなどを追加できるBoardDtoに変換して送ろう(CQRS適用

attachmentを解析して保存する

AttachmentType

サーバーで定義した値のみattachmentで解析されるでしょう。Enumが適しているようです。EnumにAttachmentTypeを定義しよう。

public enum AttachmentType {
    COMMENTS;
}

AttachmentTypeHolder

要請で解析したattachmentを保存する@RequestScopebeanが必要です。AttachmentTypeHolderに要請されたattachmentの内容を入れておきます。

@RequestScope
@Component
@Data
public class AttachmentTypeHolder {
    private Set<AttachmentType> types;
}

@Attach

どのような場合にattachmentを解析すべきか定義する必要があります。実行しようとするControllerのメソッドに@Attachがあれば、解析が必要と定義しました。

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Attach {
}

@RestController
@RequestMapping("/boards")
public class BoardController {

    // `/boards/{id}`で要請があれば、要請されたattachmentを解析する
    @Attach 
    @GetMapping("/{id}")
    public BoardDto getOne(@PathVariable("id") Board board) { return board;}
}

AttachInterceptor

要請されたattachmentを解析してAttachmentTypeHolderに保存しよう。便宜上パフォーマンスに関連するロジックは排除しました。

@Component
public class AttachInterceptor extends HandlerInterceptorAdapter {

    public static final String TARGET_PARAMETER_NAME = "attachment";
    @Autowired
    private AttachmentTypeHolder attachmentTypeHolder;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        if (!(handler instanceof HandlerMethod)) {
            return true;
        }

        HandlerMethod handlerMethod = (HandlerMethod) handler;
        // hasMethodAnnotation()の呼出スタックが結構長い。Map<handlermethod, boolean="">でキャッシングすると少し性能がよくなる。
        if (!key.hasMethodAnnotation(Attachable.class)) {  
            return true;
        }

        Set<AttachmentType> types = resolveAttachmentType(request);
        attachmentTypeHolder.setTypes(types);

        return true;
    }

    private Set<AttachmentType> resolveAttachmentType(HttpServletRequest request) {
        String attachments = request.getParameter(TARGET_PARAMETER_NAME);

        if (StringUtils.isBlank(attachments)) {
            return Collections.emptySet();
        }

        // 基本的にenumのvalueOfは探せる値がないとき、IllegalArgumentExceptionをthrow
        // attachmentのために障害が発生するのはナンセンス、実装する際はexceptionを投げないようにする必要がある
        // githubソースではexceptionを投げない
        return Stream.of(attachments.split(","))
                     .map(String::toUpperCase)
                     .map(AttachmentType::valueOf)
                     .collect(Collectors.toSet());
    }
}

Test

定義したインターセプターが正しく動作するか確認してみよう。

public class AttachInterceptorTest {

    @InjectMocks
    private AttachInterceptor attachInterceptor;
    @Spy
    private AttachmentTypeHolder attachmentTypeHolder;
    @Mock
    private HandlerMethod handlerMethod;

    @Before
    public void setUp() throws Exception {
        MockitoAnnotations.initMocks(this);
    }

    @Test
    public void preHandle() throws Exception {
        // given
        given(handlerMethod.hasMethodAnnotation(Attachable.class)).willReturn(true);
        MockHttpServletRequest request = new MockHttpServletRequest();
        request.setParameter(AttachInterceptor.TARGET_PARAMETER_NAME, AttachmentType.COMMENTS.name().toLowerCase());
        MockHttpServletResponse response = new MockHttpServletResponse();

        // when
        attachInterceptor.preHandle(request, response, handlerMethod);

        // then
        assertThat(attachmentTypeHolder.getTypes(), hasItem(AttachmentType.COMMENTS));
    }
}

出典:https://github.com/supawer0728/simple-attachment/tree/save-attachment-request

ControllerからBoardDtoを返す

BoardDto定義

先に定義したBoardentityです。entityは作成、変更時に使用するようにして、付加情報であるコメント、推薦情報を入れるモデルをBoardDtoと定義して応答を与えよう。

@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class BoardDto {
    private Long id;
    private String title;
    private String content;

    @Setter(AccessLevel.PRIVATE)
    @JsonIgnore
    private Map<AttachmentType, Attachment> attachmentMap = new EnumMap<>(AttachmentType.class);
}

なぜattachmentMapを使ったのでしょうか?もしattachmentMapがなければ、以下のように、それぞれ別メンバとして宣言され、ソースをattachするモデルを追加するときに、ソースを修正する原因となります。

public class BoardDto {
  ...
  List<CommentDto> comments;
  Writer writer;
   // 後で推薦リストができる<recommendationdto> recommendations;が追加される
}

別途クラスを定義して使いたい場合はAttachmentWrapperなどのクラスを定義してMapをラッピングし、delegateパターンを実装したクラスを使うこともできます。Lombokの@Delegateは、このような場合に使うと便利です。

public class AttachmentWrapper {

    interface AttachmentMap {
        void put(AttachmentType type, Attachment attachment);
        void putAll(Map<? extends AttachmentType, ? extends Attachment> attachmentMap);
        boolean isEmpty();
        Set<Map.Entry<AttachmentType, Attachment>> entrySet();
    }

    @Delegate(types = AttachmentMap.class)
    private Map<AttachmentType, Attachment> value = new EnumMap<>(AttachmentType.class);
}

BoardDtoに適用しよう。

@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class BoardDto implements Attachable {
    private Long id;
    private String title;
    private String content;

    @Setter(AccessLevel.PRIVATE)
    @JsonIgnore
    private AttachmentWrapper attachmentWrapper = new AttachmentWrapper();
}

Attcahment
付加情報クラスを示すためのマークインタフェースがあれば良いでしょう。
Attachmentと名づけよう。

public interface Attachment {}

付加情報は、例えばコメントDTOを定義するなら、次のように宣言することになります。

@Data
public class CommentDto implements Attachment {
    private Long id;
    private String email;
    private String body;
}

Attachmentの中身はCollectionのデータ構造になることもあります。たとえば、コメントリストの追加が必要です。そのためのデータ構造を定義しよう。

public interface AttachmentCollection<T extends Attachment> extends Attachment, Collection<T> {
    @JsonUnwrapped
    Collection<T> getValue();
}

@Value
public class SimpleAttachmentCollection<T extends Attachment> implements AttachmentCollection<T> {
    @Delegate
    private Collection<T> value;
}

Converter定義

AオブジェクトをBオブジェクトに変換するには、いくつかの方法があります。
特別なモジュールに依存せず、簡単にSpringのconverterを実装して定義しました。

@Component
public class BoardDtoConverter implements Converter<Board, BoardDto> {

    @Override
    public BoardDto convert(@NonNull Board board) {
        BoardDto boardDto = new BoardDto();
        boardDto.setId(board.getId());
        boardDto.setTitle(board.getTitle());
        boardDto.setContent(board.getContent());
        return boardDto;
    }
}

SpringのConverterを実装しましたが、board.toDto()などのメソッドを作成して変換しても構いません。

Controllerの戻り値を変更する

先ほど定義したConverterを注入して、BoardBoardDtoに変換してから返却します。

@RestController
@RequestMapping("/boards")
public class BoardController {

    @Autowired private BoardRepository boardRepository;
    @Autowired private BoardDtoConverter boardDtoConverter;

    @Attachable
    @GetMapping("/{id}")
    public BoardDto getOne(@PathVariable("id") Board board) {
        return boardDtoConverter.convert(board);
    }
}

AOPで返された値にモデルを追加する

AOP使用設定

@EnableAspectJAutoProxy(proxyTargetClass = true)
@SpringBootApplication
public class SimpleAttachmentApplication implements CommandLineRunner { ... }

AOPでAdviceを作成する

@Attachがあるメソッドをpointcutで保持し、adviceが実行されるように定義します。

@Component
@Aspect
public class AttachmentAspect {

    @Autowired private final AttachmentTypeHolder attachmentTypeHolder;

    @Pointcut("@annotation(com.parfait.study.simpleattachment.attachment.Attach)")
    private void pointcut() { }

    @AfterReturning(pointcut = "pointcut()", returning = "returnValue")
    public Object afterReturning(Object returnValue) {

        if (attachmentTypeHolder.isEmpty() && !(returnValue instanceof Attachable)) {
            return returnValue;
        }

        executeAttach((Attachable) returnValue);

        return returnValue;
    }

    private void executeAttach(Attachable attachable) {
      // TODO : ロジック作成
    }
}

作業の半分まで終わりました。あとは重要なロジックであるTODOの中身を満たせば完了します。

どのようにモデルを追加するか?

まずBoardDtoに先に手を加えるべきでしょう。
BoardDtoCommentDtoを追加するための動作をinterfaceに抜き出そう。

public interface Attachable {

    AttachmentWrapper getAttachmentWrapper();

    default void attach(AttachmentType type, Attachment attachment) {
        getAttachmentWrapper().put(type, attachment);
    }

    default void attach(Map<? extends AttachmentType, ? extends Attachment> attachment) {
        getAttachmentWrapper().putAll(attachment);
    }

    @JsonAnyGetter
    default Map<String, Object> getAttachment() {
        AttachmentWrapper wrapper = getAttachmentWrapper();

        if (wrapper.isEmpty()) {
            return null;
        }

        return wrapper.entrySet()
                      .stream()
                      .collect(Collectors.toMap(e -> e.getKey().lowerCaseName(), Map.Entry::getValue));
    }
}

@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class BoardDto implements Attachable {
    private Long id;
    private String title;
    private String content;

    @Setter(AccessLevel.PRIVATE)
    @JsonIgnore
    private AttachmentWrapper attachmentWrapper = new AttachmentWrapper();
}

Attachableインタフェースに必要な動作をdefaultで宣言したため、BoardDtoは特に修正の必要がありません。BoardDtoにコメントを追加するときは、BoardDto.attach(AttachmentType.COMMENTS, new CommentsDto())を呼び出します。

AttachService定義

添付ロジック(attach)を宣言して実行するAttachmentServiceが必要です。AttachServiceの持つべき要件は3つに分けられます。

  1. どのAttachmentTypeに対して動作するか
  2. どのようなclassに対して作業を実行できるか
  3. attachmentをインポートする(生成)

これinterfaceで抜き出すと、次のように宣言できます。

public interface AttachService<T extends Attachable> {
    AttachmentType getSupportAttachmentType(); // 1. どのようなAttachmentTypeに対して動作するか

    Class<T> getSupportType(); // 2. どのようなAttachableクラスに対して動作するか

    /**
     * タイプの安全性を守ること
     *
     * @param attachment
     * @throws ClassCastException
     */
    Attachement getAttachment(Object attachment); // 3. attachmentをもってくる
}

コメントはクラス別に異なる方法で読み込む必要があります。なぜなら、書き込まれるコメントがメッセージであったり、ニュースであったり、動画であったり、毎回、書き込まれる方法が異なる場合があるためです。実装体がどのオブジェクトに対してattachを実行できますが、もう少し詳しく定義するためにClass<T> getSupportType()を定義しました。

以下のようにAttachServiceの実装体を定義することができます。
AttachCommentsToBoardService.java
CommentClientFeignClientを使用しました。

@Component
public class AttachCommentsToBoardService implements AttachService<BoardDto> {

    private static final AttachmentType supportAttachmentType = AttachmentType.COMMENTS;
    private static final Class<BoardDto> supportType = BoardDto.class;
    private final CommentClient commentClient; // feign client使用

    @Autowired
    public AttachCommentsToBoardService(@NonNull CommentClient commentClient) {
        this.commentClient = commentClient;
    }

    @Override
    public AttachmentType getSupportAttachmentType() {
        return supportAttachmentType;
    }

    @Override
    public Class<BoardDto> getSupportType() {
        return supportType;
    }

    @Override
    public Attachment getAttachment(Attachable attachment) {
        BoardDto boardDto = supportType.cast(attachment);
        return new SimpleAttachmentCollection<>(commentClient.getComments(boardDto.getId()));
    }
}

Adviceの残りの部分を作成する

先に作成したAttachmentAspect//TODO部分を設定します。
Listを使用してSpringに登録されたすべてのAttachServiceを注入し、AttachmentTypeAttachableのタイプでフィルタリングしてattachを実行します。

@Component
@Aspect
public class AttachmentAspect {

    private final AttachmentTypeHolder attachmentTypeHolder;
    private final Map<AttachmentType, List<AttachService<? extends Attachable>>> typeToServiceMap;

    // 作成者からすべてのAttachServiceを注入してもらい、対応するAttachmentTypeに合わせて`typeToServiceMap`に保存
    @Autowired
    public AttachmentAspect(@NonNull AttachmentTypeHolder attachmentTypeHolder,
                            @NonNull List<AttachService<? extends Attachable>> attachService) {
        this.attachmentTypeHolder = attachmentTypeHolder;
        this.typeToServiceMap = attachService.stream()
                                             .collect(Collectors.groupingBy(AttachService::getSupportAttachmentType, Collectors.toList()));
    }

    @Pointcut("@annotation(com.parfait.study.simpleattachment.attachment.Attach)")
    private void pointcut() {
    }

    @AfterReturning(pointcut = "pointcut()", returning = "returnValue")
    public Object afterReturning(Object returnValue) {

        if (attachmentTypeHolder.isEmpty() && !(returnValue instanceof Attachable)) {
            return returnValue;
        }

        executeAttach((Attachable) returnValue);

        return returnValue;
    }

    private void executeAttach(Attachable attachable) {

        Set<AttachmentType> types = attachmentTypeHolder.getTypes();
        Class attachableClass = attachable.getClass();

        // Stream APIを使って簡単にフィルタリングし、適合する`AttachService.attach()`を実行
        Map<AttachmentType, Attachment> attachmentMap =
                types.stream()
                     .flatMap(type -> typeToServiceMap.get(type).stream())
                     .filter(service -> service.getSupportType().isAssignableFrom(attachableClass))
                     .collect(Collectors.toMap(AttachService::getSupportAttachmentType, service -> service.getAttachment(attachable)));

        attachable.attach(attachmentMap);
    }
}

実行する

GET /boards/1?attachment=comments

{  
   "id":1,
   "title":"title1",
   "content":"content1",
   "comments":[  
      {  
         "id":1,
         "email":"Eliseo@gardner.biz",
         "body":"laudantium enim quasi est quidem magnam voluptate ipsam eos\ntempora quo necessitatibus\ndolor quam autem quasi\nreiciendis et nam sapiente accusantium"
      }
   ]
}

出典:https://github.com/supawer0728/simple-attachment/tree/attach-writer

Writerを追加してみよう

今まで作ったソースで構造を整えたので、新しいattachmentを追加することは難しくありません。

AttachmentType 修正(WRITER追加)

public enum AttachmentType {
    COMMENTS, WRITER;
    //...
}

WriterDto 追加

@Data
public class WriterDto implements Attachment {
    private Long id;
    private String username;
    private String email;
}

WriterClient 追加

@FeignClient(name = "writer-api", url = "https://jsonplaceholder.typicode.com")
public interface WriterClient {
    @GetMapping("/users/{id}")
    WriterDto getWriter(@PathVariable("id") long id);
}

AttachWriterToBoardService 追加

@Component
public class AttachWriterToBoardService implements AttachService<BoardDto> {

    private static final AttachmentType supportAttachmentType = AttachmentType.WRITER;
    private static final Class<BoardDto> supportType = BoardDto.class;
    private final WriterClient writerClient;

    @Autowired
    public AttachWriterToBoardService(@NonNull WriterClient writerClient) {
        this.writerClient = writerClient;
    }

    @Override
    public AttachmentType getSupportAttachmentType() {
        return supportAttachmentType;
    }

    @Override
    public Class<BoardDto> getSupportType() {
        return supportType;
    }

    @Override
    public Attachment getAttachment(Attachable attachment) {
        BoardDto boardDto = supportType.cast(attachment);
        return writerClient.getWriter(boardDto.getWriterId());
    }
}

既存のソースを修正するところは1箇所です。EnumにWRITERを追加しましたが、事実上は修正ではなく、追加と見做せます。
Springが依存性注入をすべて担当するため、必要なモデルを追加作成するには、どのように付加情報を取得するか、どのようにモデルを定義するか、POJOでうまく作成すればよいでしょう。

実行

GET /boards/1?attachment=comments,writer

{
  "id": 1,
  "title": "title1",
  "content": "content1",
  "comments":[
    {
      "id": 1,
      "email": "Eliseo@gardner.biz",
      "body": "laudantium enim quasi est quidem magnam voluptate ipsam eos\ntempora quo necessitatibus\ndolor quam autem quasi\nreiciendis et nam sapiente accusantium"
    }
  ],
  "writer":{
    "id": 1,
    "username": "Bret",
    "email": "Sincere@april.biz"
  }
}

出典:https://github.com/supawer0728/simple-attachment/tree/attach-writer

まとめ

HTTPリクエストでclientが必要なモデルを追加するロジックを構成してみました。次の記事では、パフォーマンスチューニングのため、一部ロジックを追加します。現在のソースには、大きな欠点が少なくとも2つ存在します。それは、AttachmentAspectで外部と通信し、Attachmentを取得する部分です。

Map<AttachmentType, Attachment> attachmentMap =
        types.stream()
             .flatMap(type -> typeToServiceMap.get(type).stream())
             .filter(service -> service.getSupportType().isAssignableFrom(attachable.getClass()))
             .collect(Collectors.toMap(AttachService::getSupportAttachmentType, service -> service.getAttachment(attachable)));

この部分が、なぜ大きな欠点であるか確認してみよう。

  1. Network I / Oを順次実行
    • O(n)時間がかかる:timeout * attachment改修
    • AsynchでO(1)で終わるようにチューニングが必要
  2. Failover
    • attachmentは単純な付加情報もかかわらず、attachmentServiceでexceptionが発生した場合、何の情報も取得できない
      attachは失敗してもBoard情報と、残りの成功したattachmentは表示する必要がある

以下は100番のwriterがなく(404)エラーになった例です。
GET /boards/100?attachment=comments,writer

{  
   "id":1,
   "title":"title1",
   "content":"content1",
   "comments":[  
      {  
         "id":1,
         "email":"Eliseo@gardner.biz",
         "body":"laudantium enim quasi est quidem magnam voluptate ipsam eos\ntempora quo necessitatibus\ndolor quam autem quasi\nreiciendis et nam sapiente accusantium"
      }
   ]
}

付加情報であるコメントの取得に失敗し、重要な書き込みもできない状態では、良い設計と言えるでしょうか。次回は前述した2つの欠点を重点的に改善していこうと思います。

TOAST Meetup 編集部

TOASTの技術ナレッジやお得なイベント情報を発信していきます
pagetop