iT邦幫忙

2025 iThome 鐵人賽

DAY 4
0

回顧

今天將會實作完 HackerNewsAdapter

[ 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 來處理 API
Mapper.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 。


上一篇
Day 3|HTTP 三隻貓:RestTemplate、WebClient、OpenFeign
下一篇
Day 5|Echo 貓咪😸: Line Webhook & 自訂 Annotation
系列文
Spring 冒險日記:六邊形架構、DDD 與微服務整合6
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言