转-RESTful 安卓网络层解决方案

 

 

http://blog.piasy.com/2016/08/29/RESTful-Android-Network-Solution-1/

 

 

RESTful 安卓网络层解决方案(一):概览与认证实现方案

Posted by Piasy on August 29, 2016

本文是 Piasy 原创,发表于 http://blog.piasy.com,请阅读原文支持原创http://blog.piasy.com/2016/08/29/RESTful-Android-Network-Solution-1/

拆轮子系列:拆 Okio 最后我曾说过会对 Retrofit、OkHttp、Okio 三者进行一个小结,并且整理一套网络层的“微架构”,今天终于得以完成,在这里一起奉送给大家 🙂

注:本来只打算写一篇文章,但篇幅太长,最后还是按照内容拆分为了三篇,也算是单一职责 🙂

1,网络“三板斧”架构回顾

okio_okhttp_retrofit

今天还在和 iOS 同事讨论,iOS 开发中有没有可以和“三板斧”相对应的存在,得到的答案是 AFNetworking,不过它独自完成了“三板斧”的所有工作,既有底层的 API,也有高度的封装(不一定准确,如有错误,欢迎指出)。

相比之下,“三板斧”根据分工完全隔离,还是更加合理的,灵活而且干净,flexible and clean。我们完全可以只用其中一层,例如用 Okio 进行 IO 操作、二进制数据操作,只用 OkHttp 进行网络访问,或者用 Retrofit 定义 RESTful API 但使用其他 HttpClient。

拆轮子系列:拆 OkHttp 中,我们就曾提到:

分层的思想在 TCP/IP 协议中就体现得淋漓尽致,分层简化了每一层的逻辑,每层只需要关注自己的责任(单一原则思想也在此体现),而各层之间通过约定的接口/协议进行合作(面向接口编程思想),共同完成复杂的任务。

分层(分治)在软件开发中可以说无处不在,是一种非常有用的方法。在这里我们也可以看到,“三板斧”除了在细节之处践行了分层思想,它们之间的协作,也正是一种更全局的分层思想的体现。

2,安卓 RESTful 网络层“微架构”

基础的 API 定义、请求发起,这些内容就不在这里展开了,对 Retrofit、OkHttp 不熟悉的朋友一定要先看看官方教程和文档,不然后面可能会觉得云里雾里。当然也可以阅读我的两篇文章:

在这套“微架构”里面主要涉及三大部分内容:

  1. 怎么做认证;
  2. 怎么做 JSON 解析,空 JSON 以及 API Error 解析;
  3. API model 和 Business model 分离;

在第一篇中,我们先讲一下认证功能的实现。

3,认证功能的实现

3.1,认证需求

身份认证其实是一个基本的需求,如果我们有用户系统,那登录之后发出的请求可能都是需要一个 token 的(query),而在登录之前发出的请求,我们可能会做一个 basic auth 认证(header)。而对于安全追求更高的团队,可能会有一些防止重放攻击、防止恶意构造请求的策略,例如每个请求加上时间戳,每个请求进行一次额外的校验(验证是合法的客户端,不验证具体是哪个用户)。

这里我先讲一下额外校验的一种方式,例如每个请求加上 timestampmac 这两个参数,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) {
        // ...
    }
}
  1. 由于我们的 token 是会发生变化的(未登录 -> 登录 -> 退出登录 -> 重新登录),所以我们需要保证它的可见性,而由于 token 的更新不依赖旧的状态,volatile 关键字就足够了。
  2. basic auth 的用户名密码是固定不变的,我们直接构造函数传入即可。
  3. token,macKey 都是后面会变化的,所以我们需要一个 setter,而不是在构造函数中传入。
  4. 这边有两个小技巧:方法声明为 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
}
  1. 我们需要移除这个标记 header,所以我们要构造一个新的 header 集合。
  2. 对比 header name,来从中寻找 auth 类型,这里同样应该定义为常量。
  3. 根据不同的类型应用不同的认证策略。
  4. 我们利用 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")));
    }
}

测试代码里面常量有点多,不过总的来说代码还是挺漂亮的:

  1. 我们把多个测例都需要的逻辑都放到 setUp 函数中。
  2. 时间戳我们在编码实现的时候就考虑到了测试,所以这里我们的测试非常稳定。
  3. 验证时预期结果是怎么来的?手动计算测试数据的输出,或者使用一个正确的标准测试用例数据。千万不要先跑一遍,把输出作为预期,否则测试的正确性就被“强制保证”了。
  4. 这里我们用了一些 hamcrest 的 assertion 和 matcher,功能要比 junit 内置的更强大一些,测试结果的信息也更丰富一些。
  5. basic auth 别忘了验证 header。

4,小结

好了,三板斧的回顾、网络层“微架构”的概览、以及认证功能的方案与实现就讲到这里。在接下来的第二篇中,我将讲讲 JSON 转换中的两点注意事项,欢迎继续阅读 RESTful 安卓网络层解决方案(二):空 JSON 和 API Error 解析

Bonus:拆轮子与 model 层架构推荐

前段时间拆轮子系列的前三篇,分别对 RetrofitOkHttpOkio 源码进行了分析和源码导读,发布之后大家反馈还不错,其中拆 OkHttp 篇成功登上开发者头条榜首。没有看过的朋友建议大家可以看一看:

此外,之前整理的安卓 model 层架构,有幸还在 GDG 进行了一次分享,大家反响也还不错,在这里也推荐大家看一看:


欢迎大家关注我的微信公众号,会推送最新 blog、以及短篇内容。

 

RESTful 安卓网络层解决方案(二):空 JSON 和 API Error 解析

Posted by Piasy on September 4, 2016

本文是 Piasy 原创,发表于 http://blog.piasy.com,请阅读原文支持原创http://blog.piasy.com/2016/09/04/RESTful-Android-Network-Solution-2/拆轮子系列:拆 Okio 最后我曾说过会对 Retrofit、OkHttp、Okio 三者进行一个小结,并且整理一套网络层的“微架构”,今天终于得以完成,在这里一起奉送给大家 🙂

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 增加功能。这边我们额外实现两个类:EmptyJsonLenientConverterFactoryYLApiErrorAwareConverterFactory,前者负责处理空 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
            }
        };
    }
}

总的来说还是比较直观的:

  1. 修饰模式要求我们实现同样的接口,并且进行一定程度的委托,我们这边明确就是对GsonConverterFactory 的功能进行扩充,所以我们的委托类型就直接声明为它。
  2. request body 我们无需特殊处理,直接返回 GsonConverterFactory 创建的 converter。
  3. 我们返回的 converter 可能会被多次使用,所以不要在匿名 converter 实例中创建委托 converter,而是只在外面创建一次。
  4. 尝试把请求转发给 GsonConverterFactory 创建的 converter。
  5. 如果抛出了 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);
        };
    }
}

依然比较直观,不过有几点值得一提:

  1. 我们的 YLApiErrorAwareConverterFactory 并不是明确针对哪个具体实现扩充功能的,所以我们把委托声明为接口。
  2. 除了正常的 response body converter,我们还需要一个专门转化为 API Error 的 converter。
  3. 这里我们必须对 ResponseBody 进行 clone,因为 Okio 的流都是只允许读一次的,如果我们直接对传入的参数进行操作,那后面我们尝试解析为正常 body 时就会出错了。
  4. 如果确实是一个 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 层架构推荐

前段时间拆轮子系列的前三篇,分别对 RetrofitOkHttpOkio 源码进行了分析和源码导读,发布之后大家反馈还不错,其中拆 OkHttp 篇成功登上开发者头条榜首。没有看过的朋友建议大家可以看一看:

此外,之前整理的安卓 model 层架构,有幸还在 GDG 进行了一次分享,大家反响也还不错,在这里也推荐大家看一看:


欢迎大家关注我的微信公众号,会推送最新 blog、以及短篇内容。

 

 

RESTful 安卓网络层解决方案(三):API model 与 Business model 分离

Posted by Piasy on September 4, 2016

本文是 Piasy 原创,发表于 http://blog.piasy.com,请阅读原文支持原创http://blog.piasy.com/2016/09/04/RESTful-Android-Network-Solution-3/拆轮子系列:拆 Okio 最后我曾说过会对 Retrofit、OkHttp、Okio 三者进行一个小结,并且整理一套网络层的“微架构”,今天终于得以完成,在这里一起奉送给大家 🙂

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_friendfriend_remarkstarred 这三个字段。

而如果获取自己的信息,又还不一样:

{
  "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"
}

相比于非好友,多了 phonetokenim_password 这三个字段。

一方面,服务端要践行信息隐藏的原则,不需要的数据就坚决不返回,这就造成即便返回的都是同样的东西(例如用户信息),但返回的字段组合却是多种多样的;另一方面,服务端使用动态类型,无需为每种字段组合创建一个类型,只需要返回时进行组装即可,这就进一步加剧了字段组合“碎片化”的问题。

如何解决这一问题呢?为每种组合创建一个类,还是把所有的字段都揉进一个类?

2,解决方案

上面最后提到的两种办法都有问题,但我们把它们可以结合起来。

首先,对于和 API 打交道的代码,我们把所有字段都装进一个类型,ApiUser。否则我们就需要定义三个 API 了,而这基本上是不可行的,当我们要获取一个用户的信息时,调用哪个接口,好友还是非好友?我们根本不知道是不是好友!

但紧接着,对于和上层业务打交道的代码,我们要分别定义不同的类型,SelfFriendNonFriend,绝不包含无用的信息。并且我们把 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();
}

其中 UserInfoModelFriendInfoModel 是由 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);
    }
}

尽管 UserInfoModelFriendInfoModel 都包含 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、类型转换的逻辑也并不复杂:

  1. mUserDbAccessor 负责封装数据库访问,我们先尝试从数据库读取缓存。
  2. 如果缓存命中,我们就从 mFriendDbAccessor 中尝试获取好友信息。
  3. 如果没有好友信息,那我们就认为这个用户是 NonFriend。这里我们有一个假设,所有好友都一定会保存好友信息。
  4. 如果有好友信息,那我们就组合出 Friend 返回。
  5. 调用 API 时,获取到的是 ApiUser,我们需要将其转换为 Friend/NonFriend。
  6. 我们利用 concat 把缓存和网络连接起来。
  7. 如果不需要刷新本地缓存,我们直接返回连接结果的第一个即可。
  8. 利用 is_friend,我们可以确定 ApiUser 是否为 Friend。
  9. 保存用户信息时,如果是好友,我们还需要保存 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);
}
  1. 我们准备好要返回的 ApiUser、FriendInfo、Friend、NonFriend 信息。
  2. 尽管让 mUserDbAccessor 返回 Friend 语法上没问题,但逻辑上是不会发生的,所以我们还是返回 NonFriend。
  3. 因为我们刷新了本地缓存,而且缓存命中、API 返回数据了,所以我们最终会收到两个 Friend。
  4. 我们对 mFriendDbAccessor 和 mUserDbAccessor 都进行了一次查询操作。
  5. 我们对 API 进行了一次调用。
  6. 我们对 mFriendDbAccessor 和 mUserDbAccessor 都进行了一次保存操作。

5,总结

网络层微架构的内容,本来是只打算写一篇文章的。但是后来发现内容太长,而且没有明确加上单元测试的内容,所以最终把单元测试内容完整加上,拆分为了三篇内容。希望大家能意识到测试代码的重要性:

既然无论手工还是测例,总归是要测试的,那我们何不稍微多花一点工夫,编写单元测试呢?

这套微架构主要包含三部分内容:

而每一部分都包含了尽可能详尽的单元测试,目前看来是最好水平了,已经使出了洪荒之力 😄

希望大家喜欢,欢迎留言讨论!

Bonus:拆轮子与 model 层架构推荐

前段时间拆轮子系列的前三篇,分别对 RetrofitOkHttpOkio 源码进行了分析和源码导读,发布之后大家反馈还不错,其中拆 OkHttp 篇成功登上开发者头条榜首。没有看过的朋友建议大家可以看一看:

此外,之前整理的安卓 model 层架构,有幸还在 GDG 进行了一次分享,大家反响也还不错,在这里也推荐大家看一看:


欢迎大家关注我的微信公众号,会推送最新 blog、以及短篇内容。