iT邦幫忙

2025 iThome 鐵人賽

DAY 3
0

為什麼要學這三個?

昨天(Day 2)我們已經定義了 out port:ContentSourcePort。今天要做的,就是讓它「真的去拿資料」有有三個常見方法可以可以發 HTTP Request RestTemplateWebClientOpenFeign。本系列主要以 OpenFeign 實作 HackerNews 的來源,並接成一個 Hexagonal Adapter。不過在介紹前我們先熟悉一寫Spring的概念。

簡單比較

  • RestTemplate:傳統、同步阻塞、API 簡單。在 Spring 5 之後進入「maintenance mode」,新功能主要走 WebClient/RestClient,但仍可用(之後他就deprecated不是他不好,而是指不會有更多new features了)。
  • WebClient:Spring 5 引入的 reactive 客戶端,支援Sync/Async,適合高併發或需要 non-blocking (非阻塞)。
  • OpenFeign:只要定義 interface 即可使用,與 Spring Boot/Cloud 深度整合,能串 服務註冊/負載平衡(Spring Cloud LoadBalancer)。

因為我只想 access news api 沒有要 build 一個 reactive 的 app 所以用 OpenFeign 就好,在 Hexagonal Architecture 的設計裡 (已經夠複雜了),我們的目標就是 盡量減少不必要的樣板程式碼 (boilerplate code)。下面的程式範例你可以看到 OpenFeign 只要處理 interface 註冊。

HackerNews API

API endpoint:/v0/topstories.json 會return一串 story IDs;/v0/item/{id}.json
可參見 https://github.com/HackerNews/API

Dependency Injection (DI)

下面的 code 有 Spring 框架常見的 DI 簡單說就是 Spring 它可以幫你管理物件的週期,被管理的物件稱為Bean,而這些 Bean 會被加入倒 context 中,你要用 annoation 去表示他是 Bean。
https://ithelp.ithome.com.tw/upload/images/20250918/20178775D6avELpyj1.png

怎麼告訴 Spring 什麼是 Bean?

  • Class 級別的註解:@Configuration@Service@Repository
    → 加在 class 上,Spring 會自動把它註冊為 Bean。

  • Method 級別的註解:@Bean
    → 加在 @Configuration 類別的方法上,Spring 會把該方法回傳的物件 (return值) 註冊為 Bean,Bean 的名稱預設就是方法名第一個字母小寫。

注意的是 Bean 是 Singleton by default,除非特別定義他的 Scope , @Scope 簡單來說就是你設定 Bean 要怎麼被 Spring 管理,像是你可以把它改成不是 Singleton 而是 Prototype 等等。

  • prototype → 每次注入時都建立新的實例。
  • request / session → 在 Web 應用中根據 HTTP request 或 session 來管理。

為什麼要 DI ?

因為一旦定義好 Bean,Spring 在 DI 的時候就能從 Context 中找到並注入到建構子,讓程式碼 更彈性、低耦合。

public class Car {
    private final Engine engine;
    public Car(Engine engine) {
        this.engine = new Engine(); // 直接在建構子裡 new
    }
}

這樣 Car 永遠只能用 new Engine(),但如果你:

public class Car {
    private final Engine engine;
    public Car(Engine engine) {
        this.engine = engine; // 注入,而不是自己 new
    }
}

這樣在 new Car 的時候

Car c1 = new Car(new GasEngine());  
Car c2 = new Car(new ElectricEngine());
// 方便unit test
Car c3 = new Car(new MockEngine()); // 用假的 Engine 

如果 Engine 是介面,那就更符合 面向抽象程式設計 (Program to an interface) 的原則:

interface Engine {
    void start();
}

class GasEngine implements Engine {
    public void start() { System.out.println("Gas engine starts"); }
}

class ElectricEngine implements Engine {
    public void start() { System.out.println("Electric engine starts silently"); }
}

Car 只依賴 Engine 介面,而"不管具體是哪個實作":

Car gasCar = new Car(new GasEngine());
Car electricCar = new Car(new ElectricEngine());

DI + Interface 不只是低耦合,還能達到 Information Hiding:

  • Car 不知道具體 Engine 的 instance 型別、建立方式、生命週期。
  • Car 只看到 Engine interface這個「抽象契約」,其他全被隱藏。

在 Spring 中,DI 由 IoC Container 來自動完成 (這就是用Spring框架的好處之一):

@Component
class GasEngine implements Engine {}

@Component
class Car {
    private final Engine engine;
    @Autowired
    public Car(Engine engine) {//因為只有 1 個建構子 不寫 @Autowired 也沒關係 
        this.engine = engine;
    }
}

上述的code讓兩個物件 Engine and Car 有了 relationship
https://ithelp.ithome.com.tw/upload/images/20250918/20178775Yj3q59eSTT.png

配置參數

@Value 可以幫我們 access 到 application.yml 的設定參數,我們可以輕鬆地進行管理參數,不必重新 compile 程式碼。

source:
  hn:
    base-url: https://hacker-news.firebaseio.com/v0

這樣,我們在程式裡把 YAML 裡的 base-url 注入進來。

REST Endpoint

https://ithelp.ithome.com.tw/upload/images/20250918/20178775zzqWu4vx4d.png
下面的三個方法就是在建立左側的 client

RestTemplate

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
@Configuration
public class HttpClientConfig {
    @Bean
    RestTemplate restTemplate(RestTemplateBuilder builder) {
        return builder.build();
    }
}

必須先建立 header -> 然後製作 request data -> 然後用 exchange()

@Component
public class HackerNewsRestTemplateClient {
    private final RestTemplate restTemplate;
    private final String baseUrl;

    public HackerNewsRestTemplateClient(
            RestTemplate restTemplate,
            @Value("${source.hn.base-url}") String baseUrl
    ) {
        this.restTemplate = restTemplate;
        this.baseUrl = baseUrl;
    }

    public List<Long> topStories() {
        String url = baseUrl + "/topstories.json";

        // 1 先建立 header
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);

        // 2 製作request data: body is null because of we are using http method "GET"
        HttpEntity<String> request = new HttpEntity<>(null, headers);

        // 3 用exchange()去發送http request: put header + request + what http method into exchange() to call hackernews API
        ResponseEntity<long[]> response = 
        restTemplate.exchange(
                url,
                HttpMethod.GET,
                request,
                long[].class
        );

        return Arrays.stream(response.getBody())
                     .boxed()
                     .toList();
    }

WebClient

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
@Configuration
public class HttpClientConfig {
    @Bean
    WebClient webClient() {
        return webClient
                .builder()
                .build();
    }
}
@Component
public class HackerNewsWebClient {
    private final WebClient webClient;

    private final String baseUrl;
    
    public HackerNewsWebClient(
        WebClient webClient,
        @Value("${source.hn.base-url}") String baseUrl) {
        this.webClient = webClient;
        this.baseUrl = baseUrl;
    }
            
    public List<Integer> topStories() {
        return webClient.get()
                .uri(baseUrl + "/topstories.json")
                .retrieve()
                .bodyToMono(new ParameterizedTypeReference<List<Integer>>() {})
                .block(); // 示範 也可以改成 blocking
    }
}

OpenFeign

需要以下的 dependency :

<properties>
    <java.version>17</java.version>
    <spring-cloud.version>2025.0.0</spring-cloud.version>
</properties>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>${spring-cloud.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

使用 OpenFeign 時,我們只需要定義一個 interface,並加上 @FeignClient 註解。
在 app 啟動的時候,Spring 會自動為這個介面產生實作類別,並把它註冊成 Bean 放進 conetext 中(ApplicationContext)。

@Configuration
@EnableFeignClients(
        basePackageClasses = {
                HackerNewsClient.class,
        }
)
public class ProjectConfig {
}
@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);
}

在一開始的時候,我們就預想到會有很多不同的新聞來源(HackerNews、Guardian…)。如果每個來源都直接和 Domain 溝通,會造成 strongly coupled and hard to extend。

所以我們先透過一個 interface (ContentSourcePort) 定義「契約 (contract)」:新聞來源必須能提供 top() 和 byId() 這些方法。

HackerNewsAdapter 的角色:

  • 它知道如何呼叫 HackerNews API。
  • 它會把外部的 JSON 轉換成 Domain 能理解的 FeedItem
  • Domain Service (AggregateFeedService) 只依賴 ContentSourcePort,不需要知道底層是哪個 Adapter 在工作。

這樣一來,在 runtime 時,Spring 會幫我們用 依賴注入 (DI) 把正確的 Adapter 注入進去。
如果未來要再加 GuardianAdapterNYTimesAdapter,也只要同樣implements ContentSourcePort 就能無縫接入,不需要改 Domain 不需要修改任何程式碼,因為 AggregateFeedService depends on ContentSourcePort 這個 interface。

"Open for extension, Closed for modification." — Bertrand Meyer, Object-Oriented Software Construction (1988)

ContentSourcePort 的存在正好符合 OCP:

  • Open for extension:我們可以隨時新增新檔案 (新的 Adapter)。
  • Closed for modification:不需要回頭修改 Domain 的 core logic。
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) {
        // Ommit 之後的文章會說明
    }

    @Override
    public Optional<FeedItem> byId(String id) {
        // Ommit 之後的文章會說明
    }

    // Further api
}

Reference

LAURENŢIU SPILCĂ, Spring Start Here: Learn what You Need and Learn it Well


上一篇
Day 2|貓米🐈的黑盒子:Hexagonal 與 DDD 初探
下一篇
Day 4|六邊形的拓展 : 增新更多 Adapter
系列文
Spring 冒險日記:六邊形架構、DDD 與微服務整合6
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言