NHN Cloud Meetup 編集部
Springからの要求に応じた付加応答を追加する(1)
2018.04.16
2,294
概要
近年、特定のコンテンツに付加属性を加えて表示する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を保存する@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を注入して、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つの欠点を重点的に改善していこうと思います。