RESTClient with Retrofit2 - OKHttp3


Taylor Durrer

Line-bot-sdk-java

前一陣子都在研究 line-bot-sdk-java,
看到 Retrofit2 搭配 OKHttp3,
媽媽就問我為什麼跪著寫程式。

Simsimi

Simsimi 聽說背後是百度的 NLP 技術的東西, 啊災...
因為我接完後, 他都回我幹話跟髒話, 我就決定叫他髒小雞,
他會根據一些關鍵字或什麼 NLP 之類的 (太難了) 來回我話, 支援多國語言,
對剛接觸 bot 來說, 算是滿方便, 又不用傷腦力去處理 NLP 的好服務, 當然你送的資料有可能都送給百度啦, maybe

Spring boot Framework

Spring boot framework 算是對新手滿友善的框架?那文件的厚實度, 印出來當枕頭都沒問題。
俗話說, 在哪裡跌倒, 就在哪裡躺下, 就在哪裡爬起來, Bean 很好用, 但有時候找不到文件就很雞掰。
Line-bot-sdk-java 的架構下了解一下 java 的注入, 營養真是滿分啊。

Purpose

包裝完後可以像這樣簡單俐落的使用。


Response simSimiResponse = simSimiService.chat("Hi~").execute();

RESTClient Service

Retrofit2 的起手式就是先墊一個 interface,透過 annonation 就可以很清楚的明暸 Resource,
滿符合 RESTful 的精神的啊, 在閱讀跟維護上也是好棒棒。
詳細的Retrofit2 Doc 請參閱這邊 https://square.github.io/retrofit/


public interface SimSimiService {
    /**
     * @param msg
     * @return ResponseBody {@link ResponseBody}
     * @see http://developer.simsimi.com/api
     */
    @GET("request.p")
    Call chat(@Query("text") String msg);
}

Implement Interface

line-idk-java 採用了 Builder Design Pattern, 不太確定是不是這樣稱呼?
因為 RESTClient 會有固定定的 Host,固定 Authorized token 跟一些 Header 欄位,
所以在組裝 HTTPClient 的時候採用 Builder 的方式比較容易,
比如說可以當 Api Url 更新時, 可以透過 builder 從外部注入。

Builder Design Pattern 範例 - https://sourcemaking.com/design_patterns/builder/java/2

Builder Design Pattern 說明 - http://design-patterns.readthedocs.io/zh_CN/latest/creational_patterns/builder.html


/**
 * SimSimiServiceBuilder
 * Builder with OkHttpClient Builder and Retrofit Builder
 */
public class SimSimiServiceBuilder {

    // 預設 api endpoint url
    public static final String DEFAULT_API_END_POINT = "http://sandbox.api.simsimi.com";

    // http 預設 connection 常數
    public static final long DEFAULT_CONNECT_TIMEOUT = 10_000;
    public static final long DEFAULT_READ_TIMEOUT = 10_000;
    public static final long DEFAULT_WRITE_TIMEOUT = 10_000;

    private String apiEndPoint = DEFAULT_API_END_POINT;
    private long connectTimeout = DEFAULT_CONNECT_TIMEOUT;
    private long readTimeout = DEFAULT_READ_TIMEOUT;
    private long writeTimeout = DEFAULT_WRITE_TIMEOUT;

    // okhttp 的攔截器
    private List interceptors = new ArrayList<>();

    private OkHttpClient.Builder okHttpClientBuilder;
    private Retrofit.Builder retrofitBuilder;

    // singleton 
    private SimSimiServiceBuilder(List interceptors) {
        this.interceptors.addAll(interceptors);
    }

    /**
     * Create a new {@link SimSimiServiceBuilder}
     */
    public static SimSimiServiceBuilder create() {
        return new SimSimiServiceBuilder(defaultInterceptors());
    }

    /**
     * 預設的攔截器, 放入 Log 攔截器, 
     * Log Request 跟 Resoponse 相關資訊
     */
    private static List defaultInterceptors() {
        final Logger slf4jLogger = LoggerFactory.getLogger("tw.com.dum");
        final HttpLoggingInterceptor httpLoggingInterceptor =
                new HttpLoggingInterceptor(message -> slf4jLogger.info("{}", message));
        httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY);

        return Arrays.asList(
                new HeaderInterceptor(),
                httpLoggingInterceptor
        );
    }

    /**
     * RetrofitBuilder
     */
    private static Retrofit.Builder createDefaultRetrofitBuilder() {
        final ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

        // Register JSR-310(java.time.temporal.*) module and read number as millsec.
        objectMapper.registerModule(new JavaTimeModule())
                .configure(DeserializationFeature.READ_DATE_TIMESTAMPS_AS_NANOSECONDS, false);

        return new Retrofit.Builder()
                .addConverterFactory(JacksonConverterFactory.create(objectMapper));
    }

    /**
     * 允許彈性的更改 api end point
     * Set apiEndPoint.
     */
    public SimSimiServiceBuilder apiEndPoint(@NonNull String apiEndPoint) {
        this.apiEndPoint = apiEndPoint;
        return this;
    }

    /**
     * 允許彈性的更改 connect timeout
     * Set connectTimeout in milliseconds.
     */
    public SimSimiServiceBuilder connectTimeout(long connectTimeout) {
        this.connectTimeout = connectTimeout;
        return this;
    }

    /**
     * 允許彈性的更改 read timeout
     * Set readTimeout in milliseconds.
     */
    public SimSimiServiceBuilder readTimeout(long readTimeout) {
        this.readTimeout = readTimeout;
        return this;
    }

    /**
     * 允許彈性的更改 write timeout
     * Set writeTimeout in milliseconds.
     */
    public SimSimiServiceBuilder writeTimeout(long writeTimeout) {
        this.writeTimeout = writeTimeout;
        return this;
    }

    /**
     * 允許彈性的增加 Interceptor
     * Add interceptor
     */
    public SimSimiServiceBuilder addInterceptor(Interceptor interceptor) {
        this.interceptors.add(interceptor);
        return this;
    }

    /**
     * 允許彈性的增加 First Interceptor
     * Add interceptor first
     */
    public SimSimiServiceBuilder addInterceptorFirst(Interceptor interceptor) {
        this.interceptors.add(0, interceptor);
        return this;
    }

    /**
     * If you want to use your own setting, specify {@link OkHttpClient.Builder} instance.
     */
    public SimSimiServiceBuilder okHttpClientBuilder(@NonNull OkHttpClient.Builder okHttpClientBuilder) {
        this.okHttpClientBuilder = okHttpClientBuilder;
        return this;
    }

    /**
     * If you want to use your own setting, specify {@link Retrofit.Builder} instance.
     * ref: {@link LineMessagingServiceBuilder#createDefaultRetrofitBuilder()} ()}.
     */
    public SimSimiServiceBuilder retrofitBuilder(@NonNull Retrofit.Builder retrofitBuilder) {
        this.retrofitBuilder = retrofitBuilder;
        return this;
    }

    /**
     * Creates a new {@link SimSimiService}.
     */
    public SimSimiService build() {
        // check and set HttpClient Builder
        if (okHttpClientBuilder == null) {
            okHttpClientBuilder = new OkHttpClient.Builder();
            interceptors.forEach(okHttpClientBuilder::addInterceptor);
        }
        okHttpClientBuilder
                .connectTimeout(connectTimeout, TimeUnit.MILLISECONDS)
                .readTimeout(readTimeout, TimeUnit.MILLISECONDS)
                .writeTimeout(writeTimeout, TimeUnit.MILLISECONDS);
        final OkHttpClient okHttpClient = okHttpClientBuilder.build();

        // check and set Retrofit Builder
        if (retrofitBuilder == null) {
            retrofitBuilder = createDefaultRetrofitBuilder();
        }
        retrofitBuilder.client(okHttpClient);
        retrofitBuilder.baseUrl(apiEndPoint);
        final Retrofit retrofit = retrofitBuilder.build();

        // Retrofit implements SimSimiService interface
        return retrofit.create(SimSimiService.class);
    }
}

/**
 * 這邊用 攔截器 替 Request 附加 Header
 * 附加 User-Agent 或是一些固定會用的 Header
 */
class HeaderInterceptor implements Interceptor {
    // user_agent, 在 http 通訊時告知自己的身份, 一堆爬蟲都會偽造自己是瀏覽器, 也都會改這個欄位 
    private static final String USER_AGENT =
            "my-botsdk-java/" + HeaderInterceptor.class.getPackage().getImplementationVersion();
    
    // channelToken 是 line-bot-sdk 必備的 header
    private final String channelToken;

    HeaderInterceptor(String channelToken) {
        this.channelToken = channelToken;
    }

    @Override
    public Response intercept(Chain chain) throws IOException {
        Request request = chain.request().newBuilder()
                               .addHeader("Authorization", "Bearer " + channelToken)
                               .addHeader("User-Agent", USER_AGENT)
                               .build();
        return chain.proceed(request);
    }
}

defaultInterceptors() 裡面有個 new HeaderInterceptor(),
當Request 透過發送出去的時候,你可以自定義自己的 Interceptor(攔截器),
去添加過濾或者做一些手腳, 比如說 token verify 的動作或 secret key 加密之類的,
透過 Interceptor 來處理, 就不用在商業邏輯處理這些鳥事。

Singleton Bean

早期的 Spring 的 Bean 都是要寫一堆臭臭的 xml,
在某個版本之後透過 @Configuration 的方式就可以取代臭臭的 xml,
是我比較喜歡的做法, Bean 的 Dependency Injection 特性,
可以讓我們要在商業邏輯中使用 SimSimiService 的時候,
只要給個 @Inject 或是 @Autowired 就可以實例化後注入。


public class MyBusinessService {

    private SimSimiService simService;

    @Inject
    public MyBusinessService(SimSimiService simService) {
        this.simService = simService;
    }
}

Configuration 裡面, 我放了 TRIAL_KEY 其實是可以從外部 application.yml 讀進來,
defaultQueryInterceptor 也應該從外面注入, 可以讓 Configuration 更單純一點。


/**
 * Created by jerry on 2017/1/9.
 */
@Configuration
public class Configuration {
    
    private static final String TRIAL_KEY = "foo-bar";
    
    /**
     * 沒有 zh-tw 嗚嗚嗚...
     * @see http://developer.simsimi.com/lclist
     */
    private static final String LOCATION_LAN = "ch";

    /**
     * 設定講髒話的程度, 忘了多少到多少
     */
    private static final String BAD_WORD_DISCRIMINATOR = "1.0";

    @Bean
    public SimSimiService simSimiService() {
        return SimSimiServiceBuilder
                .create()
                .addInterceptor(defaultQueryInterceptor())
                .build();
    }

    /**
     * 給定預設的 query, key, lc, ft
     *
     * @return
     */
    private Interceptor defaultQueryInterceptor() {
        return chain -> {
            HttpUrl keyQuery = chain.request().url()
                    .newBuilder()
                    .addQueryParameter("key", TRIAL_KEY)
                    .addQueryParameter("lc", LOCATION_LAN)
                    .addQueryParameter("ft", BAD_WORD_DISCRIMINATOR)
                    .build();
            Request request = chain.request().newBuilder().url(keyQuery).build();
            return chain.proceed(request);
        };
    }
}

測試

寫到這邊應該就可以寫個簡單的測試。
因為 Retrofit 用了 Callables 的方式來包裝每個 REST Request,
Callables 是是 Runnable 的進階版, 多允許在 multi-threads 下回傳 result,
這方便我們處理 Request 成功或失敗。


/**
 * Created by jerry on 2017/1/5.
 */
@RunWith(SpringRunner.class)
@SpringBootTest
public class SimSimServiceTest {

    /**
     * 注入的另一個好處是讓測試好方便
     */
    @Inject
    private SimSimiService simSimService;

    /**
     * Assert Response Code is 200
     */
    @Test
    public void simSimiApiTest() throws Exception {
        Response response = simSimService.chat("HI").execute();
        Assert.assertEquals(200, response.code());
    }
}

Batter than batter

雖然這樣在使用上已經很方便了, 但看到 line-bot-sdk 又多包了一層 servieClient 的東西,
多包這一層的用意是方便 DTO (Data Transfer Object) 的操作,
這邊細節又可以談很多, 下一篇吧。

沒有留言:

張貼留言