在做測試時, 利用假資料來做測試還算是一個蠻常被利用的技巧, 除了可以減少測試中的變動因子, 維持測試的scope的穩定度, 避免因為非程式本身造成的問題影響測試外, 還有就是在有跟別人API的對接的場合, 在還沒實際的API測試時, 一樣可以測試介面實作有沒問題

Mockito在這方面(mocking)算是一個很有趣的東西, 前陣子在做公司的東西時, 拿了Mockito做了一些unit tests, 就想要寫這一篇, 不過又一直拖著沒寫了 :P

如果對Mockito沒什麼接觸的話, 可以看一下他的文件, 並沒有很多很複雜的API, 初次看可能會覺得有點神奇, 不過, 這邊並不是要講它神奇的原理, 而是探討一下他的thenReturndoReturn

Mockito中, 你要創立一個mock object是像這樣:

// mock creation
List mockedList = mock(List.class);

上面這例子就會創造一個虛假的List object, 你可以說這個object擁有List的特性, 但他卻是個假貨!

針對mock object, 你可能會去做這樣一件事:

when(mockedList.get(0)).thenReturn("first");
/// 或是:
doReturn("first").when(mockedList).get(0);

上面這兩句話(好吧, 是兩行程式, 但Mockito實在太口語化了)在這狀況下是代表同一件事, 照理說應該也會有一樣的結果, 那為何要有兩種寫法呢?

這邊要注意一點是, “Mock”這件事是針對object, 創建出來的object實例(instance), 並不是針對類別(Class), 因此當你使用when(mockedList.get(0)).thenReturn("first")指的是如果你呼叫了mockedList.get(0)會回傳”first”, 但不代表你呼叫所有List.get(0)都是回傳”first”, 利用了mock創建出來的實例, 真的是個假貨, 呼叫它任何的方法(method), 都不會真的呼叫到你真正在類別裡面定義的實作, 而是被導引到空殼去了

再回到兩種寫法的問題, 針對上述的mock object, 這兩種寫法都是沒問題的, 完全一模一樣, 但Mockito裡面還有另一種形式的mock, 叫做Spy

   List list = new LinkedList();
   List spy = spy(list);

   //optionally, you can stub out some methods:
   when(spy.size()).thenReturn(100);

   //using the spy calls real methods
   spy.add("one");
   spy.add("two");

mock是偽造了全部的東西, 或許就像是個天才雷普利, 但spy想做的只是偽造部分的內容, 以上面的例子來說, spy.add會呼叫到真正的方法(method) - add, 也就是這個list實際上會有”one”和”two”兩個東西, 但呼叫size()時回傳的會是100, 所以以下這例子會有問題

   //optionally, you can stub out some methods:
   when(spy.size()).thenReturn(100);

   //using the spy calls real methods
   spy.add("one");
   spy.add("two");
   spy.get(spy.size() - 1);

如果一般正常狀況, 這個list的大小正好是加入的大小(這邊為2), 但因為我們偽造了size()讓他回傳了100, 這邊就會有問題了, 因為get實際上呼叫到的會是”real method”

其實回來看when的語法的話, 會發現本身就是蠻神奇的了, when(obj.method1()).thenReturn(anyObject()), 照一般Java語法來看, 為什麼把obj.method1()的執行結果帶給when當參數?為何when的回傳結果呼叫了thenReturn之後我們呼叫obj.method1()都會是回傳我們指定的回傳值?

想知道? 問香蕉….不是啦…這必須要知道Mockito的原理, 不過因為它是利用了很多很冷門的神奇技巧, 不在這邊探討範圍, 這邊只需要知道一件事, when(obj.method1())的確真的是呼叫了obj.method1()沒錯, 但對obj = mock(MyClass.class)來說, obj完全是沒作用的空殼, 所以呼叫了obj.method1(), 什麼事都不會發生的!

但對於Spy來說就不一樣了, Spy不是完全體的mock, 他是個代理, 最後還是會呼叫後面真正的method的, 因此如果碰到when(obj.method1()).thenReturn(anyObject()), obj.method1()絕對會起作用的, 但我們通常的目的就是想取代這個method的執行結果, 因此這樣會有副作用, 也就是當我們要假冒他前, 會先觸發一次, 就跟詐騙別人前先跑去通知警察一樣(什麼爛比喻), 這不是我們樂見的, 因此我們需要的就是doReturn

doReturn("my result").when(obj).method1()在這裡的when會再把obj mock一次, 以至於method1()暫時變成個空殼不會在這邊被觸發, 這樣就可以達到我們前面說的目的了, 但, 缺點是, when … thenReturn , 因為when可以先知道方法的回傳值型態, 因此thenReturn裡面放的值只能是那型態, 所以寫錯了, comoile time會抓出來, 但反過來用的doReturn, 它接的是Object, 並無法在compile time做檢查, 所以如果有出錯, 要到執行時期才會發現

Retrofit一直是一套在開發REST client一個相當好用的工具, 不只可以應用在Android上, 而是在任何Java相關的, 不管是獨立的App, 或是Server端的開發, 都相當好用

Retrofit是相當簡單容易使用的, 但它的能力也是強大的, 能做到的事情相當的多, 寫這篇主要是最近都在寫Spring Boot相關的, 在想說研究一下Spring的RestTemplateRetrofit時, 發現了Jake Wharton一份投影片, 之前一直以為Retrofit最早應該是由Jake Wharton開發出來的, 但後來根據Jake Wharton的文章才知道第一個commit是Square的CTO, Bob Lee, 但不管是誰, 這兩個都是大神級的人物啊

這篇主要是根據Jake WhartonMaking Retrofit Work For You (Ohio DevFest 2016)整理出來的, 這篇滿滿都是codes的投影片已經非常清楚的講了一堆關於Retrofit的技巧了, 這邊針對幾個有用的整理出來, 並加上一些我自己的內容(看完我文章後想繼續深入研究可以再回頭看投影片)

Retrofit的基本使用

在看這些技巧之前, 我們先來看看Retrofit的基本使用, 這邊使用官方的例子來說明, 官方的範例是以GitHub Api為例子, 以GitHub Api的“List user repository”為例, 它的URL是長這樣的:

GET /users/:username/repos

因此Retrofit的API interface (或稱Service class)可以定義成這樣:

public interface GitHubService {
  @GET("users/{user}/repos")
  Call<List<Repo>> listRepos(@Path("user") String user);
}

從API doc裡面發現, 其實還可以用Query parameter加上”type”, “sort”, “direction”三個參數, 如果我們希望再加上一個”sort”就可以變成這樣:

public interface GitHubService {
  @GET("users/{user}/repos")
  Call<List<Repo>> listRepos(@Path("user") String user, @Quer("sort") String sort);
}

這樣我們要取用GitHubService就可以用這樣:

Retrofit retrofit = new Retrofit.Builder()
    .baseUrl("https://api.github.com/")
	.addConverterFactory(GsonConverterFactory.create())
    .build();

GitHubService service = retrofit.create(GitHubService.class);

基本上我們自己不用寫跟Http connection相關的程式碼就可以達成, 這就是Retrofit簡單的地方, 只需要定義一個Java interface, 跟幾個簡單的annotation即可

它的原理也不算很高深, 基本上就是利用了Java Proxy class, 把一些寫REST client共通的程式碼封裝起來, Proxy class雖然一般來說是比較冷門的技巧, 也不是什麼新功能, 但卻是相當好用的工具, 其實現在很多地方也都廣泛地使用了

這邊有幾篇關於Proxy class可以參考參考

另外, 當然得記得加入相關的程式庫

Maven:

<dependency>
  <groupId>com.squareup.retrofit2</groupId>
  <artifactId>retrofit</artifactId>
  <version>2.2.0</version>
</dependency>

Gradle:

compile 'com.squareup.retrofit2:retrofit:2.2.0'

json2pojo

有了Retrofit後, 在寫REST client最繁雜的部分大概就剩下為了JSON資料格式定義相對應的Java Class了, 不過, 這部分, 也是交給工具就好了, 這邊有兩個我常用的好用的工具:

  • jsonschema2pojo 這蠻多功能的, 也可以針對Gson或是Jackson產生不同的對應
  • Json2Pojo 如果使用Intellij IDEA或是他衍生出的Android Studio, 也可以使用這個plugin, 直接用”New -> Generate POJOs from JSON”即可

OkHttpClient與Interceptor

在現在處處都需要Internet的世界, 一個Http client的實作應該相當基本的, 除了Java本身的HttpUrlConnection外, 也有Apache Http, 不過Retrofit用的是Square自己開源的OkHttp,不得不說Square真是一家厲害的公司, 開源出來的東西的品質都相當的高, OkHttp也是一個相當優秀的Http程式庫, 大概跟Http相關的, 你想得到的都支援了, 包含HTTP/2

如果沒特別指定, Retrofit使用的會是預設的OkHttpClient, 當然你也可以指定自己的給它, 像是

OkHttpClient client = new OkHttpClient.Builder().build();

Retrofit retrofit = new Retrofit.Builder()
    .baseUrl("https://api.github.com/")
	.addConverterFactory(GsonConverterFactory.create())
	.client(client)
    .build();

GitHubService service = retrofit.create(GitHubService.class);

通常, 如果你對Http部分有特別的需求, 就會需要這樣做, 比如說, 你希望你每一個HTTP連接有它的connection timeout或是read timeout, write timeout, 那就會利用:

OkHttpClient client = new OkHttpClient.Builder()
		.connectTimeout(10, TimeUnit.SECONDS)
        .writeTimeout(10, TimeUnit.SECONDS)
        .readTimeout(30, TimeUnit.SECONDS)
		.build();

但這並不是唯一用途, 還有一個蠻好用的用途是Interceptor, 什麼是Interceptor呢? 用一張圖來解釋一下:

Interceptor

簡而言之, Interceptor是用來放在傳輸的中間去監測或修改HTTP的需求(request)與回應的(response), 在OkHttp裡, Interceptor有兩種, 一種是Application Interceptor, 是介於你的程式和OkHttp的實作之間, 另一種是Network Interceptor, 是介於OkHttp跟實際網路傳輸之間, 兩者實作的介面都相同, 實際上就要看你的用途放在哪了

一個最淺而易見的應用是Log, 常常我們會需要看HTTP傳輸了什麼來確定我們REST client的實作是否正確, 透過Interceptor就可以做到這樣的事, 而且OkHttp已經提供好一個叫做HttpLoggingInterceptor可以用了:

HttpLoggingInterceptor logging = new HttpLoggingInterceptor();
logging.setLevel(Level.BASIC);
OkHttpClient client = new OkHttpClient.Builder()
  .addInterceptor(logging)
  .build();

利用addInterceptor可以加入Interceptor, 而且, 當然你也可以設定自己的Logger:

HttpLoggingInterceptor logging = new HttpLoggingInterceptor(new Logger() {
  @Override public void log(String message) {
    Timber.tag("OkHttp").d(message);
  }
});

不過HttpLoggingInterceptor並不包含在原本的OkHttp的jar內, 所以要另外加入:

Maven:

<dependency>
  <groupId>com.squareup.okhttp3</groupId>
  <artifactId>logging-interceptor</artifactId>
  <version>(insert latest version)</version>
</dependency>

Gradle:

compile 'com.squareup.okhttp3:logging-interceptor:(insert latest version)'

除了做Log外, 另外就是如果有一些共用的Header, 像是Authentication的header, 就可以透過Interceptor來加入, 如:

class ServiceInterceptor implements Interceptor {
	@Override
	public Response intercept(Chain chain) {
		Request request = chain.request();

		request = request.newBuilder()
		    .addHeader("Authentication", "myheader")
			.build();
		return chain.process(request);
	}
}

這是用來把原本的request替換成加了header的新request, 同樣的, 如果是OAuth也是可以利用這技巧, 這部分可以去參考okhttp-signpost

那如果針對有些需要加, 有些不需要呢? 那我們可以用另一個技巧來處理, 假設, Retrofit的Service定義是這樣的:

interface Service {
	@GET("/user")
	Call<User> user();

	@POST("/login")
	@Headers("No-Auth: true")
	Call<User> login();
}

這範例裡面, user() 是需要加authentication header的, login不需要, 因此在這邊就利用Headers這個Authentication加上一個假header, 這是為了給後面的Interceptor辨識的,結果就像是:

class ServiceInerceptor implements Interceptor {
	@Override
	public Response intercept(Chain chain) {
		Reuqest request = chain.request();

		if (request.header("No-Auth") == null) {
			request = request.newBuilder()
		    .addHeader("Authentication", "myheader")
			.build();
		}

		return chain.proceed(request);
	}
}

Converter

剛剛講的Inerceptor嚴格說來不屬於Retrofit, 但接下來要說的這個Converter就完全是Retrofit的東西了

一般常見的REST API的回傳格式大多是JSON, 但也有人是用Protocol buffer, 也有人還是用著XML, Retrofit好用的地方就是沒寫死這部分的格式, 而是把它變成像是Plugin的形式, 就叫做Converter, 官方提供的Converter有下列這些(後面是maven repo的group:artifect):

  • Gson: com.squareup.retrofit2:converter-gson
  • Jackson: com.squareup.retrofit2:converter-jackson
  • Moshi: com.squareup.retrofit2:converter-moshi
  • Protobuf: com.squareup.retrofit2:converter-protobuf
  • Wire: com.squareup.retrofit2:converter-wire
  • Simple XML: com.squareup.retrofit2:converter-simplexml

其中Gson, Jackson, Moshi是處理JSON相關的, Protobuf和Wire則是處理Protocol buffer, 有趣的是, Square常常很多東西都喜歡自己來, 因此關於JSON parser, 他們就不滿足於Google的GSON或是常見的Jackson, 而是自己開發另一套Moshi, Protocol buffer也一樣, Wire也是他們自己開發的

如果要用Converter, 就會使用到它對應的CoverterFactory, 以GSON為例是:

Retrofit retrofit = new Retrofit.Builder()
    .baseUrl("https://api.github.com")
    .addConverterFactory(GsonConverterFactory.create())
    .build();

GitHubService service = retrofit.create(GitHubService.class);

由於Converter等於是一種plugin, 如果你不滿意於官方提供的幾個JSON方案, 而想要用其他的, 也是可以的, 像是如果你要用阿里巴巴開源的Fast JSON你也可以參考這個Converter的實作: FastJson Retrofit Converter

那Converter只能支援這些格式嗎? 你只要想得出來的格式其實都可以自己寫出Converter來支援, 比如說HTML也可以,

class PageConverter implements Converter<ResponseBody, Page> { 
    static final Converter.Factory FACTORY = new Converter.Factory() {
      @Override 
	  public Converter<ResponseBody, ?> responseBodyConverter( 
          Type type, Annotation[] annotations, Retrofit retrofit) { 
        if (type == Page.class) return new PageConverter(); 
        return null; 
      } 
    }; 
 
    @Override 
	public Page convert(ResponseBody responseBody) throws IOException {
      Document document = Jsoup.parse(responseBody.string());
      List<String> links = new ArrayList<>();
      for (Element element : document.select("a[href]")) {
        links.add(element.attr("href"));
      } 
      return new Page(document.title(), Collections.unmodifiableList(links));
    } 
  } 

上面這個Converter的例子, 就是一個把HTML裡的title和links(利用Jsoup)抓出來的範例, 也可以參考Retrofit提供的完整範例

除了這例子外還可以有更進階的玩法, 比如說這例子:

interface Service {
	@GET("/user")
	Call<User> user();

	@GET("/friends")
	Call<User> friends();
}

假設寫API的人真的很惡搞, /user傳回的是JSON, 而/friends傳回的是protobuf怎辦呢?(淦, 誰會這麼幹啦?)

這時候你可以用一個你自訂的annotation來處理, 像是這樣:

interface Service {
	@GET("/user")
	@Json
	Call<User> user();

	@GET("/friends")
	@Protobuf
	Call<User> friends();
}

這邊@Json, @Protobuf都是自定義的, 不是Retrofit提供的, 那我們在自己提供的ConverterFactory來處理, 像是:

public class AnnotatedConverterFactory extends Converter.Factory {
	final Converter.Factory gson = ... //init gson converter factory
	final Converter.Factory proto = ... //init protobuf factory

	@Override 
	  public Converter<ResponseBody, ?> responseBodyConverter( 
          Type type, Annotation[] annotations, Retrofit retrofit) { 
			  for(Annotation annotation:annotations) {
				  if(annotation.getClass() == Json.class) {
					  return gson.responseBodyConverter(type, annotations, retrofit);
				  } else {
					  return proto.responseBodyConverter(type, annotations, retrofit);
				  }
			  }
			  return null
		  }
}

更進階的用法可以參考Retrofit的Sample: AnnotatedConverters.java

Mock Mode

Retrofit也提供一個可以利用在測試的Mock mode

假設我們有一個Service interface是這樣的:

public interface GitHub { 
	@GET("/repos/{owner}/{repo}/contributors") 
	Call<List<Contributor>> contributors(
		@Path("owner") String owner,
		@Path("repo") String repo);
} 

我們可以建立一個Mock Service像是:

/** A mock implementation of the {@link GitHub} API interface. */ 
public class MockGitHub implements GitHub { 
	private final BehaviorDelegate<GitHub> delegate;
	private final Map<String, Map<String, List<Contributor>>> ownerRepoContributors;

	MockGitHub(BehaviorDelegate<GitHub> delegate) {
		this.delegate = delegate;
		ownerRepoContributors = new LinkedHashMap<>();

		// Seed some mock data. 
		addContributor("square", "retrofit", "John Doe", 12); 
		addContributor("square", "retrofit", "Bob Smith", 2); 
		addContributor("square", "retrofit", "Big Bird", 40); 
		addContributor("square", "picasso", "Proposition Joe", 39); 
		addContributor("square", "picasso", "Keiser Soze", 152); 
	} 

	@Override public Call<List<Contributor>> contributors(String owner, String repo) {
		List<Contributor> response = Collections.emptyList();
		Map<String, List<Contributor>> repoContributors = ownerRepoContributors.get(owner);
		if (repoContributors != null) {
		List<Contributor> contributors = repoContributors.get(repo);
		if (contributors != null) {
			response = contributors;
		} 
		} 
		return delegate.returningResponse(response).contributors(owner, repo);
	} 

	void addContributor(String owner, String repo, String name, int contributions) {
		Map<String, List<Contributor>> repoContributors = ownerRepoContributors.get(owner);
		if (repoContributors == null) {
		repoContributors = new LinkedHashMap<>();
		ownerRepoContributors.put(owner, repoContributors);
		} 
		List<Contributor> contributors = repoContributors.get(repo);
		if (contributors == null) {
		contributors = new ArrayList<>();
		repoContributors.put(repo, contributors);
		} 
		contributors.add(new Contributor(name, contributions));
	} 
} 

這邊很簡單, 建立一個GitHub這個Interface的實作, 但資料不是透過HTTP去取得, 取而代之的是回傳我們預設好的假資料, 利用BehaviorDelegate<GitHub>來回傳, 這樣可以在測試中避免因為Server帶來的不確定性所造成的錯誤

建立實際上使用的Service實體就不是用原本的retrofit.create()了, 而是改用mockRetrofit.create(GitHub.class)像是這樣:

// Create a very simple Retrofit adapter which points the GitHub API. 
Retrofit retrofit = new Retrofit.Builder()
	.baseUrl(API_URL) 
	.build(); 

// Create a MockRetrofit object with a NetworkBehavior which manages the fake behavior of calls. 
NetworkBehavior behavior = NetworkBehavior.create();
MockRetrofit mockRetrofit = new MockRetrofit.Builder(retrofit)
	.networkBehavior(behavior)
	.build(); 

BehaviorDelegate<GitHub> delegate = mockRetrofit.create(GitHub.class);
MockGitHub gitHub = new MockGitHub(delegate);

這邊有一個networkBehavior, 這是可以用來模擬網路情況的, 比如說你可以用:

behavior.setDelay(500, TimeUnit.MILLISECONDS);
behavior.setFailurePercent(3);

這個可以用來測試可能的網路情況是否會帶來其他的邊際效應

補充 - CallAdapter

Retrofit 1時, Interface裡面定義的method回傳都是直接是要回傳的型態如:

public interface GitHub { 
	@GET("/repos/{owner}/{repo}/contributors") 
	List<Contributor> contributors(
		@Path("owner") String owner,
		@Path("repo") String repo);
} 

在Retorfit 2之後, 卻已經變成是:

public interface GitHub { 
	@GET("/repos/{owner}/{repo}/contributors") 
	Call<List<Contributor>> contributors(
		@Path("owner") String owner,
		@Path("repo") String repo);
} 

亦即就是, 原本在Retrofit 1採用的是Synchronous call, 就是你自己去管thread, 前景背景, 這些麻煩事, 但在2版後, 這部分就改了, 預設是Call這個Class, Call這個Interface的原型是這樣的

public interface Call<T> extends Cloneable {
  Response<T> execute() throws IOException;
  void enqueue(Callback<T> callback);
  boolean isExecuted(); 
  void cancel(); 
  boolean isCanceled(); 
  Call<T> clone(); 
  Request request(); 
} 

你拿到Call物件後, 其實並還沒透過HTTP去抓取東西, 而是要透過execute()或是enqueue()才會真的去發request, 這兩者本質上是不同的, execute()是一個Synchronous call, 也就是執行到有結果才會結束, 會卡程式執行, 而enqueue()則是Asynchronous call, HTTP的部份是在背景執行, 結束後會call callback

如果需要中斷(碰到下載很久的內容), 可以呼叫cancel()

對這部份內部實作有興趣的話可以參考OkHttpCall, 其實它是直接利用了OkHttp那邊的實作

當然, 誠如前面說的(有說過嗎?), Retrofit是一個高度模組化的套件, 因此這部分也可以透過所謂的Call Adapter換成你熟悉的進程管理(應該叫這樣嗎?不知道該怎稱呼), 如RxJava

例如:

Retrofit retrofit = new Retrofit.Builder()
        .baseUrl("http://example.com") 
        .addCallAdapterFactory(RxJavaCallAdapterFactory.create()) 
        .build();

或是:

Retrofit retrofit = new Retrofit.Builder()
        .baseUrl("http://example.com") 
        .addCallAdapterFactory(RxJavaCallAdapterFactory.createWithScheduler(io())) 
        .build();

當然要記得加入相關的Dependency:

compile 'com.squareup.retrofit2:adapter-rxjava:latest.version'

官方支援的Call adapter除了原本的”Call”外還有:

另外非官方的, 比如說你如果喜歡用Facebook的Bolts, 也有retrofit-bolts-call-adapter

那如果我要寫自己的Call Adapter呢?討厭啦, 當然可以, 你想怎樣都可以啦! XD

補充2 - Interceptor

除了可以改造Request外, 這邊也可以傳自己假造的Response回去喔, 這種可以用在某些情境, 比如說沒網路狀況下, 你也想傳自己假造的預設資料

應該來規定自己一週至少要寫一篇文章的, 這禮拜剛回歸工作的生活, 回歸了Java, 先從今天算起, 看多久能寫個一篇

這次來寫寫怎麼測試REST client, 測試最直覺的當然是讓Client直接連到Server, 但這樣變數比較多, 比如說網路斷了呀, Server掛掉了呀, 測試資料也不穩定(資料庫內的資料並不一定是固定的), 不太利於自動化測試, 如果只是要測試Client邏輯, 自然擺脫這些因素比較好, 餵假資料(設計好的資料)是比較好的選擇

但總不可能為了測試, 寫一個測試用的假server吧? 為了這樣的需求, Okhttp有提供一個叫做MockWebServer的(Android當然也可以用), 這個就是為了這用途而出現的

要使用MockWebServer的話, 它並不直接包含在Okhttp的包裝內, 要另外含入:

Maven:

<dependency>
  <groupId>com.squareup.okhttp3</groupId>
  <artifactId>mockwebserver</artifactId>
  <version>(insert latest version)</version>
  <scope>test</scope>
</dependency>

Gradle:

compile 'com.squareup.okhttp3:mockwebserver:(insert latest version)'

使用方法也很簡單, 以Retrofit來當例子:

// 建立一個MockWebServer
MockWebServer server = new MockWebServer();
// 建立假的回應資料
server.enqueue(new MockResponse().setBody("{\"status\":\"ok\"}"));

Retrofit retrofit = new Retrofit.Builder()
        .baseUrl(server.url("/"))
        .addCallAdapterFactory(RxJavaCallAdapterFactory.create()) 
        .build(); 
service = retrofit.create(Service.class);

// 啟動server 
server.start();
service.subscribe(subscriber);
server.shutdown();

MockWebServer是一個貨真價實的http server, 所以有自己的Url, 藉由呼叫 server.url("/") 可以取得它的url, 使用MockResponse來回傳假資料(比如說是JSON), 另外可以藉由takeRequest來驗證client送的request是否正確

RecordedRequest request1 = server.takeRequest();
assertEquals("/v1/chat/messages/", request1.getPath());
assertNotNull(request1.getHeader("Authorization"));

那如果要測試不止一個URL呢?那就可以利用Dispatcher, 如下:

final Dispatcher dispatcher = new Dispatcher() {

    @Override
    public MockResponse dispatch(RecordedRequest request) throws InterruptedException {

        if (request.getPath().equals("/v1/login/auth/")){
            return new MockResponse().setResponseCode(200);
        } else if (request.getPath().equals("v1/check/version/")){
            return new MockResponse().setResponseCode(200).setBody("version=9");
        } else if (request.getPath().equals("/v1/profile/info")) {
            return new MockResponse().setResponseCode(200).setBody("{\\\"info\\\":{\\\"name\":\"Lucas Albuquerque\",\"age\":\"21\",\"gender\":\"male\"}}");
        }
        return new MockResponse().setResponseCode(404);
    }
};
server.setDispatcher(dispatcher);

在Unit test中要測試REST Client的話, MockWebServer應該算是蠻好用的一個工具

最近才發現, ptt的rss功能好像拿掉了, 這樣好像就不能拿feedly之類的來訂閱版面內容, 反正我自己有寫了一個gopttcrawler 所幸自己來寫一個吧!

source code在: pttrss

可以自行deploy到heroku去, 如果不想這麼麻煩, 可以用:

https://ptt.cowbay.wtf/rss/版名

例如:

  • 表特版的rss url是 - https://ptt.cowbay.wtf/rss/Beauty
  • 電影版的是 - https://ptt.cowbay.wtf/rss/movie

要找到英文版名才可以

資料30分鐘才會更新一次, 不會時時更新, 避免被灌

最近搬家又讓我挖出了Amazon Kindle, 又覺得拿來看漫畫很方便(這戲演了幾次了呀?), 雖然說好像也有網站可以下載漫畫.mobi檔, 不過似乎是會員制的, 不喜歡

因此又讓我想寫漫畫的爬蟲了, 這次的目標是: 無限動漫 (他們的app實在做得有夠差)

這次幾個需求是:

  1. Command line下就可以跑了(這也沒必要做UI吧?)
  2. 在os x下可以執行(我自己電腦是mac)
  3. 出來的檔案可以放到kindle看(.mobi檔或epub)

mobi或epub的檔案格式似乎有點麻煩, 也不太好做得好, 所以決定用cbz檔再用Calibre轉mobi

Calibre有一個方便的command line tool叫ebook-convert, 可以用來轉檔, 而cbz本身非常的簡單 , 它就是一個zip檔, 裡面的圖片檔名照編號就好, 這code還算好寫

再來就是看一下怎麼解析無限動漫的內容了, 它的URL是長這樣的:

http://v.comicbus.com/online/comic-653.html?ch=1

以上範例是名偵探柯南第一卷, 大膽猜測, 653是漫畫編號, ch是集數, 選到第二頁, URL會變成這樣

http://v.comicbus.com/online/comic-653.html?ch=4-2

這樣其實就很明顯了, 接下來是內容的部分

每一集的頭上有一個”正在觀看:[ 名偵探柯南 1 ]”, “[]”內就是標題了吧, 另外還有一個”select”, 裡面有這集所有的頁數資訊, 而圖片的id是”TheImg”

不過麻煩的是, 這些資訊似乎隱藏在javascript中, page載入後才會出現

這如果使用headless browser像是Phantomjs就沒啥問題, 但這邊我不想用它, 因為使用這工具還要再裝它

我下一個選擇是Go + Webloop, Webloop是一個Go的headless browser lib, 它是基於WebkitGtk+做成的, 不過我在mac上裝WebkitGTK+裝好久一直有問題, 所以…放棄….

接下來的選擇呢? 還有其他的headless browser嗎?有的! Erik, 這是一個Swift的head less browser, 用Swift寫爬蟲好像挺酷的, 查了一下, 有人用Alamofire + Kanna, 不過這在這例子不適用, 這例子還是比較適合Erik

成品

先給成果: ComicGo

這已經是一個OS X的可執行檔, 在Command line下執行 ComicGo 653 1就可以抓名偵探柯南第一集, 相關的漫畫編號集數, 就去無限動漫查吧

抓完會在你的Download目錄出現”名偵探柯南 1.cbz”再用ebook-covert去轉成你要的格式就可以了

少少的時間隨便寫寫而已, 有bug就見諒囉

OS X Command line tool

XCode + Swift是可以拿來寫command line tool的, 新增一個專案選”Command line tool”:

這樣就可以開始寫了

一開始在專案內部會發現一個”main.swift”, 由於用swift寫command line app並沒有像其他語言有main function這類的東西 所以程式就寫在這吧

開發Command line tool的坑

坑…真的不少

首先, 你不能使用任何的framework, 因為command line tool產出會是一個可執行檔, 不是一個app bundle, 所以不能包含任何的framework

第二, swift framework不能static link, 像是Erik, Kanna這些swift module, 都是dynamic lib

慘, 光前面這兩點就麻煩了, 開發這個ComicGo, 我用到了Erik, Kanna, Zip等等 , 這樣到底要怎麼辦? 跑起來就image not found

所以呢?土法煉鋼, 把這些module的codes全部引入到我的專案內(所以沒打算Open ssource, 太醜了), 這樣一來就解決掉問題了, 不過這功不算小, 因為Kanna相依libxml, Zip相依libz這些native lib

第三個坑, Erik是利用OS X裡面原生的WebKit去讀取網頁的, 因此他的設計是把載入網頁放到另一個DispatchQueue(javascript執行又是另一個), 但Command line邏輯很單線, 它並不會等callback回來才結束程式, 因此會發現怎麼Erik都沒動作就結束程式了, 因此必須要有個機制來卡住

這個機制就是RunLoop, 關於RunLoop這邊不多做解釋, 看一下官方文件 在程式內則是這樣:

let rl = RunLoop.current
var finished = false

while !finished {
    rl.run(mode: RunLoopMode.defaultRunLoopMode, before: Date(timeIntervalSinceNow: 2))
}

當callback完畢後, 把finished設成true就可以結束整個程式了

Erik

好像還沒介紹Erik喔?其實有點想偷懶跳過了 :P

使用Erik來爬網頁其實很簡單,

Erik.visit(url: url) { object, error in
    if let e = error {

    } else if let doc = object {
        // HTML Inspection
		for link in doc.querySelectorAll("a, link") {
    		print(link.text)
    		print(link["href"])
		}
    }
}

只要有些CSS selector的觀念就可以了, 連querySelectorAll這名字都是一樣的, Erik並不是直接用Webkit去做CSS query的, 而是把webkit的內容拿來用Kanna解析, javascript的執行也一樣, 因此如果對html node有任何變動, 是不會反映到webkit裡面去的, 用Erik來爬的優點是專門針對那些動態網頁的, 有這個就簡單太多了!