NHN Cloud Meetup 編集部
Springからの要求に応じた付加応答を追加する(1)
2018.04.16
2,171
概要
近年、特定のコンテンツに付加属性を加えて表示する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リクエスト処理フローに沿ってまとめてみました。
- 必要に応じてInterceptorからattachmentを解析して保存する
1-1. 必要な場合がいつか定義する
1-2. attachmentを解析するclassを定義する(AttachmentType) - attachmentはRequest Scope
beanに入れておいて、必要なときに取り出して使用(AttachmentTypeHolder class定義)
- 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を保存する@RequestScope
beanが必要です。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を注入して、BoardをBoardDtoに変換してから返却します。
@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に先に手を加えるべきでしょう。
BoardDtoにCommentDtoを追加するための動作を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つに分けられます。
- どのAttachmentType
に対して動作するか
- どのようなclassに対して作業を実行できるか
- 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
CommentClientはFeignClientを使用しました。
@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を注入し、AttachmentTypeとAttachableのタイプでフィルタリングして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)));
この部分が、なぜ大きな欠点であるか確認してみよう。
- Network I / Oを順次実行
- O(n)時間がかかる:timeout * attachment改修
- AsynchでO(1)で終わるようにチューニングが必要
- Failover
- attachment
は単純な付加情報もかかわらず、attachmentServiceでexceptionが発生した場合、何の情報も取得できない
attachは失敗してもBoard情報と、残りの成功したattachmentは表示する必要がある
- 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つの欠点を重点的に改善していこうと思います。