http://blog.piasy.com/2016/08/29/RESTful-Android-Network-Solution-1/
RESTful 安卓网络层解决方案(一):概览与认证实现方案
本文是 Piasy 原创,发表于 http://blog.piasy.com,请阅读原文支持原创http://blog.piasy.com/2016/08/29/RESTful-Android-Network-Solution-1/
在拆轮子系列:拆 Okio 最后我曾说过会对 Retrofit、OkHttp、Okio 三者进行一个小结,并且整理一套网络层的“微架构”,今天终于得以完成,在这里一起奉送给大家 🙂
- 🏁 RESTful 安卓网络层解决方案(一):概览与认证实现方案
- RESTful 安卓网络层解决方案(二):空 JSON 和 API Error 解析
- RESTful 安卓网络层解决方案(三):API model 与 Business model 分离
注:本来只打算写一篇文章,但篇幅太长,最后还是按照内容拆分为了三篇,也算是单一职责 🙂
1,网络“三板斧”架构回顾
今天还在和 iOS 同事讨论,iOS 开发中有没有可以和“三板斧”相对应的存在,得到的答案是 AFNetworking,不过它独自完成了“三板斧”的所有工作,既有底层的 API,也有高度的封装(不一定准确,如有错误,欢迎指出)。
相比之下,“三板斧”根据分工完全隔离,还是更加合理的,灵活而且干净,flexible and clean。我们完全可以只用其中一层,例如用 Okio 进行 IO 操作、二进制数据操作,只用 OkHttp 进行网络访问,或者用 Retrofit 定义 RESTful API 但使用其他 HttpClient。
在 拆轮子系列:拆 OkHttp 中,我们就曾提到:
分层的思想在 TCP/IP 协议中就体现得淋漓尽致,分层简化了每一层的逻辑,每层只需要关注自己的责任(单一原则思想也在此体现),而各层之间通过约定的接口/协议进行合作(面向接口编程思想),共同完成复杂的任务。
分层(分治)在软件开发中可以说无处不在,是一种非常有用的方法。在这里我们也可以看到,“三板斧”除了在细节之处践行了分层思想,它们之间的协作,也正是一种更全局的分层思想的体现。
2,安卓 RESTful 网络层“微架构”
基础的 API 定义、请求发起,这些内容就不在这里展开了,对 Retrofit、OkHttp 不熟悉的朋友一定要先看看官方教程和文档,不然后面可能会觉得云里雾里。当然也可以阅读我的两篇文章:
在这套“微架构”里面主要涉及三大部分内容:
- 怎么做认证;
- 怎么做 JSON 解析,空 JSON 以及 API Error 解析;
- API model 和 Business model 分离;
在第一篇中,我们先讲一下认证功能的实现。
3,认证功能的实现
3.1,认证需求
身份认证其实是一个基本的需求,如果我们有用户系统,那登录之后发出的请求可能都是需要一个 token 的(query),而在登录之前发出的请求,我们可能会做一个 basic auth 认证(header)。而对于安全追求更高的团队,可能会有一些防止重放攻击、防止恶意构造请求的策略,例如每个请求加上时间戳,每个请求进行一次额外的校验(验证是合法的客户端,不验证具体是哪个用户)。
这里我先讲一下额外校验的一种方式,例如每个请求加上 timestamp
和 mac
这两个参数,timestamp
就是当前时间戳,而 mac
则是一个认证码,mac
的计算取决于 timestamp
以及另外一个 mac_key
,它只在登录成功时会返回。也就是:
String mac = hash("timestamp=" + timestamp + "mac_key=" + macKey);
那这里其实有一个问题,如果用户还没有登录,我们怎么做 mac 校验?我们可以暂且用 basic auth 来代替 macKey。
3.2,方案设计
需求确定了,那我们怎么实现呢?我们的每个请求都需要加上额外的几个参数(timestamp,mac 以及可选的 token),每个 API 定义时都加上这些参数吗?
当然可以这样做,但这显然有点傻,而且这样会给 token 和 macKey 的管理带来麻烦:我们很多地方都需要维护它们,如何同步更新?当然可以通过全局变量的方式来实现,但这显然也不合理,它们只应该被需要的模块看到。
其实了解 OkHttp 的 Interceptor 链条的朋友应该能想到,我们可以利用一个 Interceptor 来集中实现我们的认证需求,请求发出去之前根据不同的情况添加不同的 query/header。
想到之后其实就比较简单了,但当我们真正去实现的时候会遇到一个问题:我们怎么知道哪些请求是需要 token 的,哪些请求是进行 basic auth 的呢?因为我们是在 OkHttp 层在做事了,Retrofit 定义 API 的信息已经完全丢失了。
怎么办?我们需要一个不会丢失的信息。Header!
我们可以给进行 basic auth 的 API 在定义时就加上一个特殊的 header,具体内容无所谓,只要它具有可识别性。那么在一个 API 调用到达 Interceptor 时,我们就有了可以进行判断的信息。
3.3,代码实现
好了,下面我们看一看简单实现的代码。
3.3.1,定义 API
public interface Api {
@POST("tokens")
@FormUrlEncoded
@Headers("Auth-Type:Basic") // 1
Observable<User> login(@Field("account") String account,
@Field("password") String password);
@GET("/users/{uid}") // 2
Observable<User> user(@Path("uid") long uid);
}
这边我们用一个特殊的 header 来标记是 basic auth(1),这里为了代码简洁,就没有定义在常量中,其实是需要定义常量的。而 auth 类型默认是 token auth,为了减少代码量,我们就不显式加上对应的 header 了(2)。
3.3.2,YLAuthInterceptor 的结构
public class YLAuthInterceptor implements Interceptor {
private final String mBasicAuthId;
private final String mBasicAuthPass;
private volatile String mToken; // 1
private volatile String mMacKey;
public YLAuthInterceptor(String basicAuthId, // 2
String basicAuthPass) {
mBasicAuthId = basicAuthId;
mBasicAuthPass = basicAuthPass;
}
public void setAuth(String token, String macKey) { // 3
mToken = token;
mMacKey = macKey;
}
@Override
public Response intercept(Chain chain) throws IOException {
// ...
}
@VisibleForTesting
void tokenAuth(Request.Builder newRequest, HttpUrl url,
long timestamp) { // 4
// ...
}
@VisibleForTesting
void basicAuth(Request.Builder newRequest, HttpUrl url,
long timestamp) {
// ...
}
}
- 由于我们的 token 是会发生变化的(未登录 -> 登录 -> 退出登录 -> 重新登录),所以我们需要保证它的可见性,而由于 token 的更新不依赖旧的状态,
volatile
关键字就足够了。 - basic auth 的用户名密码是固定不变的,我们直接构造函数传入即可。
- token,macKey 都是后面会变化的,所以我们需要一个 setter,而不是在构造函数中传入。
- 这边有两个小技巧:方法声明为 package private,便于测试代码访问;时间作为参数传入,使得测试可控制。
3.3.3,YLAuthInterceptor 的实现
先看 intercept()
的实现:
@Override
public Response intercept(Chain chain) throws IOException {
Request origin = chain.request();
Headers originHeaders = origin.headers();
Headers.Builder newHeaders = new Headers.Builder(); // 1
String authType = "Token";
for (int i = 0, size = originHeaders.size(); i < size; i++) {
if (!TextUtils.equals(originHeaders.name(i), "Auth-Type")) { // 2
newHeaders.add(originHeaders.name(i), originHeaders.value(i));
} else {
authType = originHeaders.value(i);
}
}
Request.Builder newRequest = origin.newBuilder()
.headers(newHeaders.build());
switch (authType) { // 3
case "Basic":
basicAuth(newRequest, origin.url(), System.currentTimeMillis());
break;
case "Token":
default:
tokenAuth(newRequest, origin.url(), System.currentTimeMillis());
break;
}
return chain.proceed(newRequest.build()); // 4
}
- 我们需要移除这个标记 header,所以我们要构造一个新的 header 集合。
- 对比 header name,来从中寻找 auth 类型,这里同样应该定义为常量。
- 根据不同的类型应用不同的认证策略。
- 我们利用 OkHttp 的 Interceptor API,发起修改过的请求,并返回响应。
再看 tokenAuth()
和 basicAuth()
的实现:
@VisibleForTesting
void tokenAuth(Request.Builder newRequest, HttpUrl url, long timestamp) {
if (TextUtils.isEmpty(mToken) || TextUtils.isEmpty(mMacKey)) {
throw new YLApiError(/**...*/); // 1
}
String text = "token=" + mToken + "timestamp=" + timestamp;
String mac = hash(text + "mac_key=" + mMacKey);
HttpUrl.Builder newUrl = url.newBuilder()
.addQueryParameter("timestamp", String.valueOf(timestamp))
.addQueryParameter("mac", mac)
.addQueryParameter("token", mToken);
newRequest.url(newUrl.build());
}
@VisibleForTesting
void basicAuth(Request.Builder newRequest, HttpUrl url, long timestamp) {
String text = "timestamp=" + timestamp;
String macKey = hash(mBasicAuthId + mBasicAuthPass);
String mac = HashUtils.sha1(text + "mac_key=" + macKey);
HttpUrl.Builder newUrl = url.newBuilder()
.addQueryParameter("timestamp", String.valueOf(timestamp))
.addQueryParameter("mac", mac);
newRequest.url(newUrl.build());
newRequest.addHeader("Authorization",
basicAuthHeader(mBasicAuthId, mBasicAuthPass));
}
String basicAuthHeader(String username, String pwd) {
final String userAndPassword = username + ":" + pwd;
return "Basic " + Base64.encodeToString(
userAndPassword.getBytes("UTF-8"), Base64.NO_WRAP);
}
这段代码比较直观,主要是对 OkHttp 相关 API 的使用。
我们需要在 tokenAuth 时检查 token 和 macKey,如果为空我们就抛出一个异常(1)。但这其实只能处理我们初始化时存在问题的情况,如果我们被挤下线,导致 token 失效,我们应该怎么处理呢?而进一步抽象这个问题,其实就是 token/macKey 如何管理。
解决方案其实很简单,我们把 interceptor 作为一个单例依赖,首先注入到登录注册模块中,登陆成功之后,我们就为它更新 token/macKey,其次我们的 API Error 要有一个集中处理的地方,我们把 interceptor 也注入进去,在捕获到 token 失效的错误后,我们就清除 interceptor 的 token/macKey。至于 UI 上怎么给用户提示,我们可以在 BaseActivity/BaseFragment 中监听错误的发生,并弹出对话框。
3.3.4,单元测试
从前面一篇讲 RxJava 复杂场景的文章开始,我就在强调单元测试的重要性,上面的代码也不短,足有一百多行,不写几个测试用例,还真没有信心它一定能正确工作。
public class YLAuthInterceptorTest {
private YLAuthInterceptor mYLAuthInterceptor;
private Request mOriginRequest;
private long mTimestamp;
@Before
public void setUp() { // 1
mYLAuthInterceptor = new YLAuthInterceptor(CLIENT_ID, CLIENT_PASS);
mOriginRequest = new Request.Builder()
.url(SERVER_ENDPOINT + "/users/1905378617")
.build();
mTimestamp = 1438141764; // 2
}
@Test
public void tokenAuth() throws Exception {
mYLAuthInterceptor.setAuth("wx:1905378617",
"975b56d640c0864a2c277dd0fe429b1dcbbf34a8");
Request.Builder builder = mOriginRequest.newBuilder();
mYLAuthInterceptor.tokenAuth(builder, mOriginRequest.url(), mTimestamp);
Request newRequest = builder.build();
HttpUrl expectedUrl = HttpUrl.parse(SERVER_ENDPOINT // 3
+ "/users/1905378617"
+ "?timestamp=1438141764"
+ "&mac=1cadbea4e322d42fdabe3b8fed15f741b6be67f1"
+ "&token=wx:1905378617");
assertThat(newRequest.url(), is(expectedUrl)); // 4
}
@Test
public void basicAuth() throws Exception {
Request.Builder builder = mOriginRequest.newBuilder();
mYLAuthInterceptor.basicAuth(builder, mOriginRequest.url(), mTimestamp);
Request newRequest = builder.build();
HttpUrl expectedUrl = HttpUrl.parse(SERVER_ENDPOINT
+ "/users/1905378617"
+ "?timestamp=1438141764"
+ "&mac=883df97d47e60a51236c4b08e82b0aa4be0076b2");
assertThat(newRequest.url(), is(expectedUrl));
assertThat(newRequest.headers("Authorization"), // 5
is(Collections.singletonList("Basic dGVzdF9jbGllbnQ6dGVzdF9wYXNz")));
}
}
测试代码里面常量有点多,不过总的来说代码还是挺漂亮的:
- 我们把多个测例都需要的逻辑都放到 setUp 函数中。
- 时间戳我们在编码实现的时候就考虑到了测试,所以这里我们的测试非常稳定。
- 验证时预期结果是怎么来的?手动计算测试数据的输出,或者使用一个正确的标准测试用例数据。千万不要先跑一遍,把输出作为预期,否则测试的正确性就被“强制保证”了。
- 这里我们用了一些 hamcrest 的 assertion 和 matcher,功能要比 junit 内置的更强大一些,测试结果的信息也更丰富一些。
- basic auth 别忘了验证 header。
4,小结
好了,三板斧的回顾、网络层“微架构”的概览、以及认证功能的方案与实现就讲到这里。在接下来的第二篇中,我将讲讲 JSON 转换中的两点注意事项,欢迎继续阅读 RESTful 安卓网络层解决方案(二):空 JSON 和 API Error 解析。
Bonus:拆轮子与 model 层架构推荐
前段时间拆轮子系列的前三篇,分别对 Retrofit,OkHttp 和 Okio 源码进行了分析和源码导读,发布之后大家反馈还不错,其中拆 OkHttp 篇成功登上开发者头条榜首。没有看过的朋友建议大家可以看一看:
此外,之前整理的安卓 model 层架构,有幸还在 GDG 进行了一次分享,大家反响也还不错,在这里也推荐大家看一看:
欢迎大家关注我的微信公众号,会推送最新 blog、以及短篇内容。
RESTful 安卓网络层解决方案(二):空 JSON 和 API Error 解析
- RESTful 安卓网络层解决方案(一):概览与认证实现方案
- 🏁 RESTful 安卓网络层解决方案(二):空 JSON 和 API Error 解析
- RESTful 安卓网络层解决方案(三):API model 与 Business model 分离
1,JSON 解析需求
JSON 应该是大部分项目 CS 通信的数据格式,相比于简单、调试友好的优势,它的性能不足几乎不足一提,毕竟绝大多数情况下,它都不会成为性能的瓶颈。在 Retrofit + Gson 的方案中,我们有两个问题需要特殊处理。
首先,如果一个 API 请求不需要返回数据,很可能我们的服务器也就不会返回数据(返回空的 response body),而空字符串并不是合法的 JSON,所以 Square 实现的GsonResponseBodyConverter
会不认账,直接抛出 JSON 解析错误。关于这个问题更多的讨论,可以看一下 Retrofit 的这个 issue:#1554 Handle Empty Body。
其次,很多公司的后端程序都会把 API Error 的 HTTP status code 设置为 200,这样我们就没法利用 OkHttp 的错误处理来解析 API Error 了,我们需要先尝试把响应数据解析为 API Error,如果不是 API Error,再解析为目标类型。
在 Retrofit 1.x 中,Gson 解析是通过设置一个自定义 Converter 来实现的,我们尝试解析为 API Error 的代码自然也在其中,但 Retrofit 在 2.x 中,单独实现了各种常用的 Converter,它们是没法实现我们这种解析需求的。
怎么办呢?其实如果顺着这个思路,答案也会很直接,自己实现一个 Converter 就好了嘛!
但遗憾的是,我在项目重构时没有想到这种方案,而是采用了 Interceptor 的方案,也许是思路被YLAuthInterceptor 给限制住了。这种方案其实也还说得过去,在拿到网络 response 之后,先拿到数据,再尝试转换为 API Error,如果成功,就抛出这个 API Error,否则返回 response。
但显然让 Converter 来做这件事更加合理,这完全是一件 response 转换的事情,如果说 API error 的响应会带着特殊的 header,那放在 interceptor 层来做就还是合理的。
所以加上这个需求,我们的 converter 需要实现三个功能:JSON 转换、空字符串处理、API Error 检查。
2,解决方案设计
明确了需求之后,有的朋友可能会把这三个功能都放到一起,用一个类来实现,至于 JSON 转换的功能,可以直接把 retrofit-converter-gson
的代码 copy 进来,还省得自己实现。
但这样真的好吗?
首先,copy 别人的代码,就意味着我们也需要对它进行维护,他们发布新版本之后,我们需要把最新的代码再次 copy 进来,这显然是在徒增成本。其次,一个类负责三件事情,一点都不“单一职责”。
那怎么办才好呢?
这里我们现学现卖,Okio 不是很好的践行了“修饰模式”嘛,我们这边也可以这么做,动态为 converter 增加功能。这边我们额外实现两个类:EmptyJsonLenientConverterFactory
和YLApiErrorAwareConverterFactory
,前者负责处理空 JSON 字符串,后者则用来捕获 API Error。
3,处理空 JSON 字符串
3.1,EmptyJsonLenientConverterFactory
public class EmptyJsonLenientConverterFactory extends Converter.Factory {
private final GsonConverterFactory mGsonConverterFactory; // 1
public EmptyJsonLenientConverterFactory(
GsonConverterFactory gsonConverterFactory) {
mGsonConverterFactory = gsonConverterFactory;
}
@Override
public Converter<?, RequestBody> requestBodyConverter(Type type,
Annotation[] parameterAnnotations,
Annotation[] methodAnnotations,
Retrofit retrofit) {
return mGsonConverterFactory.requestBodyConverter(type, // 2
parameterAnnotations, methodAnnotations, retrofit);
}
@Override
public Converter<ResponseBody, ?> responseBodyConverter(Type type,
Annotation[] annotations,
Retrofit retrofit) {
final Converter<ResponseBody, ?> delegateConverter = // 3
mGsonConverterFactory.responseBodyConverter(type,
annotations, retrofit);
return value -> {
try {
return delegateConverter.convert(value); // 4
} catch (EOFException e) {
// just return null
return null; // 5
}
};
}
}
总的来说还是比较直观的:
- 修饰模式要求我们实现同样的接口,并且进行一定程度的委托,我们这边明确就是对
GsonConverterFactory
的功能进行扩充,所以我们的委托类型就直接声明为它。 - request body 我们无需特殊处理,直接返回
GsonConverterFactory
创建的 converter。 - 我们返回的 converter 可能会被多次使用,所以不要在匿名 converter 实例中创建委托 converter,而是只在外面创建一次。
- 尝试把请求转发给
GsonConverterFactory
创建的 converter。 - 如果抛出了
EOFException
,则说明遇到了空 JSON 字符串,那我们直接返回null
。
3.2,单元测试
同样,我们要编写单元测试,增加我们的信心。
任何事情都不要极端,写完代码之后对着每个 if-else
分支编写测试用例是没必要的,这也会让我们抵触编写测试,因为这样做会让我们觉得测试代码都是重复的“废话”。合理的做法是我们首先就设计一些考察要点,用它们来验证我们的代码是否正确。其实如果不写测试,我们是怎么确保代码正确的呢?还是靠这些“潜在的”测例!所以何不先就把测例准备好呢?何不先就把测试代码写好呢?而这就是 TDD。
我们先看一下测试要点:
public class EmptyJsonLenientConverterFactoryTest {
private Retrofit mRetrofit;
private EmptyJsonLenientConverterFactory mFactory;
@Before
public void setUp() {
mRetrofit = new Retrofit.Builder()
.baseUrl(SERVER_ENDPOINT)
.build();
mFactory = new EmptyJsonLenientConverterFactory(
GsonConverterFactory.create());
}
@Test
public void convertNormalJson()
throws IOException {
// 验证正常 JSON 能正确解析
}
@Test(expected = EOFException.class)
public void gsonConverterFailOnEmptyJson()
throws IOException {
// 验证 GsonConverter 无法处理空字符串
}
@Test
public void convertEmptyJson()
throws IOException {
// 验证我们的 converter 可以处理空字符串
}
}
再看 convertNormalJson()
:
public static ResponseBody stringBody(String body) { // 1
return ResponseBody.create(
MediaType.parse("application/json"), body);
}
@Test
public void convertNormalJson()
throws IOException {
String normalJson = "{\"request\":\"req\","
+ "\"errcode\":123,"
+ "\"errmsg\":\"qw\"}";
Converter<ResponseBody, ?> converter =
mFactory.responseBodyConverter(YLApiError.class,
EMPTY_ANNOTATIONS, mRetrofit);
Object response = converter.convert(stringBody(normalJson));
assertTrue(response instanceof YLApiError);
YLApiError apiError = (YLApiError) response;
assertEquals(123, apiError.getErrcode());
}
测试代码也要保持简洁优雅,否则我们也会对编写测试产生抵触,所以这里我把从 String 创建ResponseBody
的代码封装了一个函数(1)。
再看 gsonConverterFailOnEmptyJson()
:
@Test(expected = EOFException.class) // 1
public void gsonConverterFailOnEmptyJson()
throws IOException {
String emptyJson = "";
Converter<ResponseBody, ?> converter =
GsonConverterFactory.create().responseBodyConverter(
YLApiError.class, EMPTY_ANNOTATIONS, mRetrofit);
converter.convert(stringBody(emptyJson));
}
这里我们利用 JUnit 的注解来验证测例抛出了 EOFException
(1)。
最后我们看看 convertEmptyJson()
,它就非常简单了:
@Test
public void convertEmptyJson()
throws IOException {
String emptyJson = "";
Converter<ResponseBody, ?> converter =
mFactory.responseBodyConverter(YLApiError.class,
EMPTY_ANNOTATIONS, mRetrofit);
Object response = converter.convert(stringBody(emptyJson));
assertNull(response);
}
4,解析 API Error
4.1,YLApiErrorAwareConverterFactory
public class YLApiErrorAwareConverterFactory extends Converter.Factory {
private final Converter.Factory mDelegateFactory; // 1
public YLApiErrorAwareConverterFactory(
Converter.Factory delegateFactory) {
mDelegateFactory = delegateFactory;
}
@Override
public Converter<?, RequestBody> requestBodyConverter(Type type,
Annotation[] parameterAnnotations,
Annotation[] methodAnnotations,
Retrofit retrofit) {
return mDelegateFactory
.requestBodyConverter(type, parameterAnnotations,
methodAnnotations, retrofit);
}
@Override
public Converter<ResponseBody, ?> responseBodyConverter(Type type,
Annotation[] annotations,
Retrofit retrofit) {
final Converter<ResponseBody, ?> apiErrorConverter = // 2
mDelegateFactory.responseBodyConverter(YLApiError.class,
annotations, retrofit);
final Converter<ResponseBody, ?> delegateConverter =
mDelegateFactory.responseBodyConverter(type,
annotations, retrofit);
return value -> {
ResponseBody clone = ResponseBody.create( // 3
value.contentType(),
value.contentLength(),
value.source().buffer().clone());
Object apiError = apiErrorConverter.convert(clone);
if (apiError instanceof YLApiError
&& ((YLApiError) apiError).isApiError()) {
throw (YLApiError) apiError; // 4
}
return delegateConverter.convert(value);
};
}
}
依然比较直观,不过有几点值得一提:
- 我们的
YLApiErrorAwareConverterFactory
并不是明确针对哪个具体实现扩充功能的,所以我们把委托声明为接口。 - 除了正常的 response body converter,我们还需要一个专门转化为 API Error 的 converter。
- 这里我们必须对
ResponseBody
进行 clone,因为 Okio 的流都是只允许读一次的,如果我们直接对传入的参数进行操作,那后面我们尝试解析为正常 body 时就会出错了。 - 如果确实是一个 API Error,那我们就抛出它,进入后面的错误处理流程。
4.2,单元测试
同样,先看测例结构:
public class YLApiErrorAwareConverterFactoryTest {
private Retrofit mRetrofit;
private YLApiErrorAwareConverterFactory mFactory;
@Before
public void setUp() {
mRetrofit = new Retrofit.Builder()
.baseUrl(SERVER_ENDPOINT)
.build();
EmptyJsonLenientConverterFactory delegate =
new EmptyJsonLenientConverterFactory(
GsonConverterFactory.create());
mFactory = new YLApiErrorAwareConverterFactory(delegate);
}
@Test
public void nonApiError() throws IOException {
// 验证解析正常数据
}
@Test
public void apiError() throws IOException {
// 验证解析 API Error
}
@Test
public void emptyJson() throws IOException {
// 验证空字符串不会被解析为 API Error
}
}
测试代码比较简单,我就只贴一下 apiError()
了:
@Test
public void apiError() throws IOException {
String errorString = "{\"request\":\"req\","
+ "\"errcode\":123,"
+ "\"errmsg\":\"qw\"}";
Converter<ResponseBody, ?> converter =
mFactory.responseBodyConverter(
Dummy.class, EMPTY_ANNOTATIONS, mRetrofit);
try {
converter.convert(stringBody(errorString));
assertTrue(false);
} catch (YLApiError apiError) {
assertEquals(123, apiError.getErrcode()); // 1
}
}
这里我们没有利用 JUnit 注解来验证异常的抛出,而是手动编写了 try-catch
,因为我们需要验证 API Error 对象的正确性(1)。
5,小结
好了,JSON 转换中的注意事项也就讲到这里。本文中 converter 对修饰模式的使用算是一大亮点,另外对于单元测试也进行了一定的思考和讨论。在接下来的第三篇中,我将讲讲 model 层中 API 和业务逻辑结合时的一个大问题,欢迎继续阅读 RESTful 安卓网络层解决方案(三):API model 与 Business model 分离。
Bonus:拆轮子与 model 层架构推荐
前段时间拆轮子系列的前三篇,分别对 Retrofit,OkHttp 和 Okio 源码进行了分析和源码导读,发布之后大家反馈还不错,其中拆 OkHttp 篇成功登上开发者头条榜首。没有看过的朋友建议大家可以看一看:
此外,之前整理的安卓 model 层架构,有幸还在 GDG 进行了一次分享,大家反响也还不错,在这里也推荐大家看一看:
欢迎大家关注我的微信公众号,会推送最新 blog、以及短篇内容。
RESTful 安卓网络层解决方案(三):API model 与 Business model 分离
- RESTful 安卓网络层解决方案(一):概览与认证实现方案
- RESTful 安卓网络层解决方案(二):空 JSON 和 API Error 解析
- 🏁 RESTful 安卓网络层解决方案(三):API model 与 Business model 分离
1,API model “碎片化”
当我们的服务端程序是用动态类型语言(例如 PHP)编写的时候,那我们得到的 API 响应就可能会比较杂乱了。
例如根据 id 获取用户信息的 API:
{
"uid": 1905378617,
"username": "hahaha",
"avatar_url": "https://frontend-yoloyolo-tv.alikunlun.com/official/v3/img/pc/logo.png"
}
这是非好友的情况,如果是好友,情况又还不一样:
{
"uid": 1905378617,
"username": "hahaha",
"avatar_url": "https://frontend-yoloyolo-tv.alikunlun.com/official/v3/img/pc/logo.png",
"is_friend": true,
"friend_remark": "Remarkable",
"starred": 0
}
好友比非好友多了 is_friend
,friend_remark
和 starred
这三个字段。
而如果获取自己的信息,又还不一样:
{
"uid": 1905378617,
"username": "hahaha",
"avatar_url": "https://frontend-yoloyolo-tv.alikunlun.com/official/v3/img/pc/logo.png",
"phone": "18812345678",
"token": "wx:1905378617",
"im_password": "6dbc987dffd33876"
}
相比于非好友,多了 phone
、token
和 im_password
这三个字段。
一方面,服务端要践行信息隐藏的原则,不需要的数据就坚决不返回,这就造成即便返回的都是同样的东西(例如用户信息),但返回的字段组合却是多种多样的;另一方面,服务端使用动态类型,无需为每种字段组合创建一个类型,只需要返回时进行组装即可,这就进一步加剧了字段组合“碎片化”的问题。
如何解决这一问题呢?为每种组合创建一个类,还是把所有的字段都揉进一个类?
2,解决方案
上面最后提到的两种办法都有问题,但我们把它们可以结合起来。
首先,对于和 API 打交道的代码,我们把所有字段都装进一个类型,ApiUser
。否则我们就需要定义三个 API 了,而这基本上是不可行的,当我们要获取一个用户的信息时,调用哪个接口,好友还是非好友?我们根本不知道是不是好友!
但紧接着,对于和上层业务打交道的代码,我们要分别定义不同的类型,Self
、Friend
、NonFriend
,绝不包含无用的信息。并且我们把 API 隐藏起来,外部不可访问,对外暴露的接口都要把 ApiUser
转换为相应的 Business model。
3,具体实现
首先,我们把所有的用户信息字段拆分为多个接口,遵循接口隔离。之所以使用接口而不是抽象类,是为了后面进行组合时可以多实现。
3.1,用户信息接口
public interface UserInfoModel {
long uid();
@NonNull
String username();
@Nullable
String avatar_url();
}
public interface FriendInfoModel {
long uid();
int starred();
@Nullable
String friend_remark();
}
interface RelationshipInfo {
boolean is_friend();
}
interface CredentialInfo {
@Nullable
String phone();
@Nullable
String im_password();
@Nullable
String token();
}
其中 UserInfoModel
和 FriendInfoModel
是由 SqlDelight 生成,用于进行持久化,它们都需要靠 uid 进行查询,所以都包含一个 uid 字段。RelationshipInfo
用于区分是否是好友,CredentialInfo
则包含自己的信息。
接下来的内容会涉及到 SqlDelight、AutoValue 及其扩展相关的内容,对这些不熟悉的朋友,强烈建议先看一下这篇文章:完美的安卓 model 层架构(上)。
3.2,ApiUser
我们的 ApiUser
要把所有的字段都包含进来,所以要实现上面的所有接口:
@AutoValue
abstract class ApiUser implements UserInfoModel,
RelationshipInfo, FriendInfoModel, CredentialInfo {
public static TypeAdapter<ApiUser> typeAdapter(final Gson gson) {
return new AutoValue_ApiUser.GsonTypeAdapter(gson);
}
}
尽管 UserInfoModel
和 FriendInfoModel
都包含 uid()
接口,但它们组合到一起的时候,ApiUser
只会获得一个 uid()
接口,所以没有问题。这边我们利用 auto-value 实现 immutable,利用 auto-value-gson 实现高效的 Gson 转换。
3.3,NonFriend
@AutoValue
public abstract class NonFriend implements UserInfoModel, Parcelable {
public static NonFriend createFrom(ApiUser user) {
// ...
}
}
NonFriend 只包含了基本的用户信息,它实现了 Parcelable
,以便在 Activity/Fragment 之间进行传递。它还提供了一个从 ApiUser 转换的工厂方法。
3.4,Friend
@AutoValue
public abstract class FriendInfo implements FriendInfoModel, Parcelable {
public static Builder builder() {
return new AutoValue_FriendInfo.Builder();
}
@AutoValue.Builder
public abstract static class Builder {
// ...
}
}
@AutoValue
public abstract class Friend implements UserInfoModel, Parcelable {
public abstract FriendInfo friendInfo();
public static Friend createFrom(ApiUser user) {
// ...
}
static Friend compose(UserInfoModel user, FriendInfo friendInfo) {
// ...
}
}
Friend 使用组合的方式加入 FriendInfo
,因为 FriendInfo 是需要单独持久化的,所以它需要是一个单独的类型。
3.5,Self
@AutoValue
public abstract class Self implements UserInfoModel,
CredentialInfo, Parcelable {
public static Self createFrom(ApiUser user) {
// ...
}
}
至此,API model 和 Business model 都已经定义好了,接下来我们需要把 API 的结果转化为对应的 model。
3.6,API model -> Business model
interface UserInfoApi {
@GET("/users/{uid}")
Observable<ApiUser> userInfo(@Path("uid") long uid);
}
public class UserRepo {
// ...
public Observable<UserInfoModel> otherUserInfo(long uid, boolean refresh) {
Observable<UserInfoModel> local = Observable.defer(() -> {
List<UserInfoModel> cached = mUserDbAccessor.get(uid); // 1
if (cached.isEmpty()) {
return Observable.empty();
} else {
List<FriendInfo> friendInfoList =
mFriendDbAccessor.get(uid); // 2
if (friendInfoList.isEmpty()) {
return Observable.just(
NonFriend.wrap(cached.get(0))); // 3
}
return Observable.just(Friend.compose(
cached.get(0), friendInfoList.get(0))); // 4
}
});
Observable<UserInfoModel> remote = mUserInfoApi
.userInfo(uid)
.map(API_USER_MAPPER) // 5
.doOnNext(mUserSaver);
Observable<UserInfoModel> combined =
Observable.concat(local, remote); // 6
if (!refresh) {
return combined.first(); // 7
}
return combined;
}
static final Func1<ApiUser, UserInfoModel> API_USER_MAPPER = apiUser -> {
if (apiUser.is_friend()) { // 8
return Friend.createFrom(apiUser);
} else {
return NonFriend.createFrom(apiUser);
}
};
private final Action1<UserInfoModel> mUserSaver = user -> {
if (user instanceof Friend) { // 9
mFriendDbAccessor.put(((Friend) user).friendInfo());
}
mUserDbAccessor.put(user);
};
}
API、DB、类型转换的逻辑也并不复杂:
- mUserDbAccessor 负责封装数据库访问,我们先尝试从数据库读取缓存。
- 如果缓存命中,我们就从 mFriendDbAccessor 中尝试获取好友信息。
- 如果没有好友信息,那我们就认为这个用户是 NonFriend。这里我们有一个假设,所有好友都一定会保存好友信息。
- 如果有好友信息,那我们就组合出 Friend 返回。
- 调用 API 时,获取到的是 ApiUser,我们需要将其转换为 Friend/NonFriend。
- 我们利用
concat
把缓存和网络连接起来。 - 如果不需要刷新本地缓存,我们直接返回连接结果的第一个即可。
- 利用
is_friend
,我们可以确定 ApiUser 是否为 Friend。 - 保存用户信息时,如果是好友,我们还需要保存 FriendInfo。
4,单元测试
public class UserRepoTest {
// ...
@Test
public void otherUserInfoNotRefreshCacheMissNonFriend() {
// 不刷新本地缓存、缓存缺失、对方不是好友的情形
}
@Test
public void otherUserInfoNotRefreshCacheHitNonFriend() {
// 不刷新本地缓存、缓存命中、对方不是好友的情形
}
@Test
public void otherUserInfoNotRefreshCacheHitFriend() {
// 不刷新本地缓存、缓存命中、对方是好友的情形
}
@Test
public void otherUserInfoRefreshCacheMissFriend() {
// 刷新本地缓存、缓存缺失、对方是好友的情形
}
@Test
public void otherUserInfoRefreshCacheHitFriend() {
// 刷新本地缓存、缓存命中、对方是好友的情形
}
}
这边我们测例其实并没有做到覆盖所有情形,稍微偷了一下懒,但我们有信心,经过这样的测试,代码已经可靠了。万一真的出了错误,到时候再加上相应的测例,小概率事件到时再说嘛 🙂
这里测试代码比较类似,只展示“刷新本地缓存、缓存命中、对方是好友的情形”:
@Test
public void otherUserInfoRefreshCacheHitFriend() {
long uid = 1905378617;
final ApiUser user = mGson.fromJson(MOCK_FRIEND, ApiUser.class);// 1
final FriendInfo friendInfo = FriendInfo.builder()
.uid(user.uid())
.friend_remark(user.friend_remark())
.starred(user.starred())
.build();
final Friend friend = Friend.compose(user, friendInfo);
final NonFriend nonFriend = NonFriend.createFrom(user);
when(mUserDbAccessor.get(uid))
.thenReturn(Collections.singletonList(nonFriend)); // 2
when(mFriendDbAccessor.get(uid))
.thenReturn(Collections.singletonList(friendInfo));
when(mUserInfoApi.userInfo(uid))
.thenReturn(Observable.just(user));
TestSubscriber<UserInfoModel> testSubscriber = new TestSubscriber<>();
mUserRepo.otherUserInfo(uid, true).subscribe(testSubscriber);
testSubscriber.awaitTerminalEvent();
testSubscriber.assertNoErrors();
testSubscriber.assertCompleted();
testSubscriber.assertValues(friend, friend); // 3
// 查询 db
verify(mFriendDbAccessor, times(1)).get(uid); // 4
verify(mUserDbAccessor, times(1)).get(uid);
// 请求 api
verify(mUserInfoApi, times(1)).userInfo(uid); // 5
verifyNoMoreInteractions(mUserInfoApi);
// 保存到 db
verify(mFriendDbAccessor, times(1)).put(friendInfo); // 6
verifyNoMoreInteractions(mFriendDbAccessor);
verify(mUserDbAccessor, times(1)).put(friend);
verifyNoMoreInteractions(mUserDbAccessor);
}
- 我们准备好要返回的 ApiUser、FriendInfo、Friend、NonFriend 信息。
- 尽管让 mUserDbAccessor 返回 Friend 语法上没问题,但逻辑上是不会发生的,所以我们还是返回 NonFriend。
- 因为我们刷新了本地缓存,而且缓存命中、API 返回数据了,所以我们最终会收到两个 Friend。
- 我们对 mFriendDbAccessor 和 mUserDbAccessor 都进行了一次查询操作。
- 我们对 API 进行了一次调用。
- 我们对 mFriendDbAccessor 和 mUserDbAccessor 都进行了一次保存操作。
5,总结
网络层微架构的内容,本来是只打算写一篇文章的。但是后来发现内容太长,而且没有明确加上单元测试的内容,所以最终把单元测试内容完整加上,拆分为了三篇内容。希望大家能意识到测试代码的重要性:
既然无论手工还是测例,总归是要测试的,那我们何不稍微多花一点工夫,编写单元测试呢?
这套微架构主要包含三部分内容:
- 认证实现方案
- 空 JSON 和 API Error 解析
- 🏁 API model 与 Business model 分离
而每一部分都包含了尽可能详尽的单元测试,目前看来是最好水平了,已经使出了洪荒之力 😄
希望大家喜欢,欢迎留言讨论!
Bonus:拆轮子与 model 层架构推荐
前段时间拆轮子系列的前三篇,分别对 Retrofit,OkHttp 和 Okio 源码进行了分析和源码导读,发布之后大家反馈还不错,其中拆 OkHttp 篇成功登上开发者头条榜首。没有看过的朋友建议大家可以看一看:
此外,之前整理的安卓 model 层架构,有幸还在 GDG 进行了一次分享,大家反响也还不错,在这里也推荐大家看一看:
欢迎大家关注我的微信公众号,会推送最新 blog、以及短篇内容。