今天將會實作完 HackerNewsAdapter
回顧 Day3 Hackernews API
https://github.com/HackerNews/API
回顧 Day2 的 logic diagram:
[ Application layer ]
↓ calls
[ IN port (use case) ]
└─ AggregateFeedUseCase
↓ implemented by
[ Domain layer ]
└─ AggregateFeedService (domain service)
↓ depends on
[ OUT ports ]
└─ ContentSourcePort
↑ implemented by
HackerNewsAdapter
GuardianAdapter
因為 Hexagonal 的設計,我們的 Adapter 就像「插頭」。
Domain Service 只依賴 Port,Adapter 是可以隨時「插進去」或「拔掉」的。
(註: Adapter implements ContentSourcePort 請見下面的code)
換句話說:
想新增一個新聞來源?加一個 Adapter。
想停用某個來源?拔掉 Adapter,不動 Domain。
這也呼應了 Dependency Inversion Principle (DIP)。
High-level modules should not depend on low-level modules. Both should depend on abstractions.
Abstractions should not depend on details. Details should depend on abstractions.
— Robert C. Martin, Design Principles and Design Patterns
Directory:
├─adapters
│ ├─in
└─ ├─out
│
└─sources
├─guardian
│ GuardianAdapter.java
│ GuardianClient.java
│ GuardianFeignConfig.java
│ GuardianMapper.java
│ GuardianSearchItem.java
│
├─hackernews
│ HackerNewsAdapter.java
│ HackerNewsClient.java
│ HnItem.java
│ HnMapper.java
接下來我們就是繼續擴展我們的多邊形,而擴張的方式很簡單,就是每一個來源得多增新基本的4個檔案:Adapter.java
: implements ContentSourcePort
使用 Client.java
取得資料並利用 Mapper.java
做物件的映射成 FeedItem
(Day2有提及FeedItem是Domain裡面處理資料的主要型態)Client.java
: OpenFeign 來處理 APIMapper.java
: 將外部統一映射為 FeedItem
Item.java
: 定義從 API 取得的資料型台
為甚麼分那麼多物件?這樣修改某一個邏輯時,不會牽動整個系統,符合 Single Responsibility Principle (SRP),而且也方便測試。
A class should have only one reason to change.
— Robert C. Martin, Agile Software Development: Principles, Patterns, and Practices
以下是詳細的code
HackerNewsAdapter.java
public class HackerNewsAdapter implements ContentSourcePort {
private final HackerNewsClient client;
public HackerNewsAdapter(HackerNewsClient client) {
this.client = client;
}
@Override
public String sourceName() {
return "hackernews";
}
@Override
public List<FeedItem> top(int limit) {
long[] ids = client.topStories();
return Arrays.stream(ids).limit(limit)
.mapToObj(client::item)
.map(HnMapper::toDomain)
.toList();
}
@Override
public Optional<FeedItem> byId(String id) {
try {
HnItem i = client.item(Long.parseLong(id));
return Optional.ofNullable(i).map(HnMapper::toDomain);
} catch (NumberFormatException e) {
return Optional.empty();
}
}
// Further api
}
HackerNewsClient.java
@FeignClient(
name = "hn",
url = "${source.hn.base-url}"
)
public interface HackerNewsClient {
@GetMapping("/topstories.json")
long[] topStories();
@GetMapping("/item/{id}.json")
HnItem item(@PathVariable("id") long id);
}
HnItem.java
public record HnItem(
Long id,
String type,
String by,
Long time,
String title,
String url,
Integer score,
List<Long> kids,
Long parent
)
{}
HnMapper.java
public class HnMapper {
static FeedItem toDomain(HnItem i) {
return new FeedItem(
String.valueOf((i.id())),
SourceType.HN,
i.title(),
i.url(),
i.by(),
i.score(),
i.time() == null ? null : Instant.ofEpochSecond(i.time())
);
}
private HnMapper() {}
}
那同理 Guardian也照做 -> Open-Closed Principle (OCP)
此外可以用有一個 BeansConfig 集中管理 Beans
@Configuration
public class BeansConfig {
@Bean("hackernews")
ContentSourcePort hnSource(HackerNewsClient client) {
return new HackerNewsAdapter(client);
}
@Bean("guardian")
ContentSourcePort guardianSource(GuardianClient client) {
return new GuardianAdapter(client);
}
@Bean
AggregateFeedUseCase aggregateFeedUseCase(List<ContentSourcePort> sources) {
return new AggregateFeedService(sources);
}
}
可以看出:雖然看起來每加一個來源就要寫 4 個檔案,有點 boilerplate code,但好處是我不必改動原本存在的code,挺 scalable , 那 maintainability 就見仁見智了。要先得熟型六邊形的邏輯才好 maintain 。