Java HTTP 组件库选型看这篇就够了

Java HTTP 组件库选型看这篇就够了

最近项目需要使用 Java 重度调用 HTTP API 接口,于是想着封装一个团队公用的 HTTP client lib. 这个库需要支持以下特性:

  1. 连接池管理,包括连接创建和超时、空闲连接数控制、每个 host 的连接数配置等。基本上,我们想要一个 go HTTP 标准库自带的连接池管理功能。
  2. 域名解析控制。因为调用量会比较大,因此希望在域名解析这一层做一个调用端可控的负载均衡,同时可以对每个服务器 IP 进行失败率统计和健康度检查。
  3. Form/JSON 调用支持良好。
  4. 支持同步和异步调用。

在 Java 生态中,虽然有数不清的 HTTP client lib 组件库,但是大体可以分为这三类:

  1. JDK 自带的 HttpURLConnection 标准库;
  2. Apache HttpComponents HttpClient, 以及基于该库的 wrapper, 如 Unirest.
  3. 非基于 Apache HttpComponents HttpClient, 大量重写应用层代码的 HTTP client 组件库,典型代表是 OkHttp.

HttpURLConnection

使用 HttpURLConnection 发起 HTTP 请求最大的优点是不需要引入额外的依赖,但是使用起来非常繁琐,也缺乏连接池管理、域名机械控制等特性支持。以发起一个 HTTP POST 请求为例:

可以看到,使用 HttpURLConnection 发起 HTTP 请求是比较原始(low level)的,基本上你可以理解为它就是对网络栈传输层(HTTP 一般为 TCP,HTTP over QUIC 是 UDP)进行了一次浅层次的封装,操作原语就是在打开的连接上面写请求 request 与读响应 response. 而且 HttpURLConnection 无法支持 HTTP/2. 显然,官方是知道这些问题的,因此在 Java 9 中,官方在标准库中引入了一个 high level、支持 HTTP/2 的 HttpClient. 这个库的接口封装就非常主流到位了,发起一个简单的 POST 请求:

封装的最大特点是链式调用非常顺滑,支持连接管理等特性。但是这个库只能在 Java 9 及以后的版本使用,Java 9 和 Java 10 并不是 LTS 维护版本,而接下来的 Java 11 LTS 要在2018.09.25发布,应用到线上还需要等待一段时间。因此,虽然挺喜欢这个自带标准库(毕竟可以不引入三方依赖),但当前是无法在生产环境使用的。

Apache HttpComponents HttpClient

Apache HttpComponents HttpClient 的前身是 Apache Commons HttpClient, 但是 Apache Commons HttpClient 已经停止开发,如果你还在使用它,请切换到 Apache HttpComponents HttpClient 上来。

Apache HttpComponents HttpClient 支持的特性非常丰富,完全覆盖我们的需求,使用起来也非常顺手:

对 Client 细致的配置和自定义支持也是非常到位的:

完整示例请参考 ClientConfiguration.

基本上,在 Java 原生标准库不给力的情况下,Apache HttpComponents HttpClient 是最佳的 HTTP Client library 选择。但这个库当前还不支持 HTTP/2,支持 HTTP/2 的版本还处于 beta 阶段(2018.09.23),因此并不适合用于 Android APP 中使用。

OkHttp

由于当前 Apache HttpComponents HttpClient 版本并不支持 HTTP/2, 而 HTTP/2 对于移动客户端而言,无论是从握手延迟、响应延迟,还是资源开销看都有相当吸引力。因此这就给了高层次封装且支持 HTTP/2 的 http client lib 足够的生存空间。其中最典型的要数OkHttp.

OkHttp 接口设计友好,支持 HTTP/2,并且在弱网和无网环境下有自动检测和恢复机制,因此,是当前 Android APP 开发中使用最广泛的 HTTP clilent lib 之一。

另一方面,OkHttp 提供的接口与 Java 9 中 HttpClint 接口比较类似 (严格讲,应该是 Java 9 借鉴了 OkHttp 等开源库的接口设计?),因此,对于喜欢减少依赖,钟情于原生标准库的开发者来说,在 Java 11 中,从 OkHttp 切换到标准库是相对容易的。因此,以 OkHttp 为代表的 http 库以后的使用场景可能会被蚕食一部分。

小结

  • HttpURLConnection 封装层次太低,并且支持特性太少,不建议在项目中使用。除非你的确不想引入第三方 HTTP 依赖(如减少包大小、目标环境不提供三方库支持等)。
  • Java 9 中引入的 HttpClient,封装层次和支持特性都不错。但是因为 Java 版本的原因,应用场景还十分有限,建议观望一段时间再考虑在线上使用。
  • 如果你不需要 HTTP/2特性,Apache HttpComponents HttpClient 是你的最佳选择,比如在服务器之间的 HTTP 调用。否则,请使用 OkHttp, 如 Android 开发。

扩展阅读

那些产品设计中的小惊喜

虽然技术出身,但是日常工作中经常需要单独的设计一些产品的原型,因此,需要对产品有一些感觉。

你也许被《人人都是产品经理》“荼毒”过,不用悲伤,我也是;你也许跟我一样技术出身,习惯了从技术角度看功能和需求,对产品和业务比较麻木,不要心急,我也是。你也许无数次的跟产品经理争论这个功能的合理性和必要性,谁也说服不了谁,最后你还是做了这个功能,仅仅是“厌恶”了跟产品无意义的浪费时间,没有关系,我也是。

然而,你要知道,大多数时候我们要做的产品设计其实都被前辈验证和解决过,是存在最佳实践的,也是可以从其他产品习得的。这种习得可能是一个特定的产品实现方式,但更多的是产品设计的思考和思路。这种习得,产品经理的整套方法论并不是必要条件,只需要你在使用产品的时候有意识的培养发现问题的敏感性和好奇心。

这里,我先把这几天使用产品过程中看到和体会到的一些产品设计中有意思的「小惊喜」整理一下,以后会不定期更新,争取能形成一个系列。

登录页面:简单的功能比你想象的更难更重要

随着 OAuth 以及开放平台的发展,国内绝大部分产品都支持微信、微博、QQ等多种社交账号的登录方式。这类登录方式不仅可以降低用户登录/注册的心里决策和输入成本,还能够从技术上简化整个账号系统的设计。因此,除了阿里和腾讯自成一体的公司产品,绝大多数产品都会在第一时间支持这类登录方式。然而,把这件事情做好的产品却寥寥无几。

以国内技术社区 segmentfault 为例,很多产品的社交账号登录页面可能是这样的:

从功能层面看,支持的登录方式挺多,把使用频率高的登录方式放在前面,这都没有问题。但是,我们的用户是健忘也是“无情”的。如果因为某种原因(APP更新、网络异常、更换手机等),用户需要重新登录,当用户再次看到这个页面的时候,是比较抓狂的:因为“登录”功能的使用频率很低,用户有很大概率不记得自己当初是以什么方式登录进来的;于是用户只能一个一个的去尝试不同社交账号登录方式来判断自己以前的账号是怎么登录的(这样做唯一能想到的好处就是注册用户数会提高,提前完成 KPI ……)。即使用户粘性高如微信,你去问问身边的朋友,他们的微信是手机号登录还是QQ登录,还是微信号登录,大部分人是搞不清楚的。

几天前用喜马拉雅听《晓说》,因为前一天晚上更新过APP,登录状态被踢了出来(这是个很糟的体验),尝试重新登录:

左下角提示微信为上次登录方式,这让我有了一点惊喜的感觉!

这是最佳的实践方式吗?私以为还可以再进一步:

之所以更加推荐这种将用户上次登录的头像和昵称显示出来的方式,不是因为这是东家咕咚APP采用的方式,而是因为,1)图片的阅读或者说注意力效率是远高于文字的;2)关系亲近的人之间可能会共用设备,显示头像和昵称有助于提高账号和登录方式判定的准确率。

在很多人眼里,登录页面这些改进太过于“鸡毛蒜皮”。但是如果你们的APP在登录页面的各个环节都有埋点的话,你也对这些数据足够熟悉的话,你会发现这些微不足道的改进对用户转化率的影响比你想象的重要得多。

说起转化率,使用社交账号登录一个非常常见也是非常糟糕的设计是:社交账号登录后,要求用户绑定手机/邮箱并设定密码。我能理解绑定手机号在我朝当前实名制政策下的无奈,但是大部分业务其实是不需要强制绑定手机号的。如果你是担心太过于依赖微信,要求用户必须有独立的登录用户民和密码,你应该想想你是不是太把自己当回事了?大部分情况下,我遇到这种社交账号登录后要求用户绑定手机和邮箱的服务,都会默默问候一个“产品13B”,然后直接关闭页面。

懂场景的音乐 APP

在听觉上,我应该算是典型的木耳。但是偏偏有喜欢在开车的时候听音乐。以前一直用豆瓣 FM, 歌曲推荐做的真心赞。但是豆瓣 FM 登录模块实在太渣,会因为各种原因把我从登录状态踢出来要求重新登录,老哥,我在开车呢,这样真的好吗?几经辗转,一次出行的时候,偶然打开了虾米音乐,因为连接了车载蓝牙,判断我是要开车听音乐,询问我是否进入车载模式:

进入车载模式以后,界面就变成跟老年手机一样,按钮巨大,按键也只有简单的几个上一曲,下一曲,暂停。心里默默念了一句: MMP, 这正是我想要的!

还没听完一首歌,又发现了一个小惊喜:

虾米把显示作者信息的地方用来显示歌曲的单词了!很Low是不是?是!很解决问题是不是?当然是!以后在车里想哼两句的时候终于不用因为不知道(不是不记得哟)歌词而随机脑补了。我知道很多技术背景的同学可能会对这种实现方式嗤之以鼻,但是以我对汽车软件行业平均水平的了解和自己折腾这台车歌词显示的经历看,这就是通用性最好、成本最低的方案!

坦率讲,虾米最主要的使用场景肯定不是车载,但是,透过这两个细节,我还是能够非常清晰的感受到虾米的产品在做音乐类 APP 是对场景的细致、全面的思考和实践。我们常常说一个功能或产品好用不好用,这其实是比较抽象和模糊的,如果你想找到具体的问题和原因,回到具体的场景和交互上,你一定能发现一些有趣的问题,同时给用户带来惊喜。

微信悬浮窗:悬浮的内容,流逝的时间

微信在不久前迎来了它的年度更新,其中最为大家津津乐道的一点就是悬浮窗设计:

但凡是常用微信进行阅读的同学,都能深刻的体会阅读文章与聊天消息之间切换的痛苦。悬浮窗一出,很多人大呼痛快。

使用一段时间后,发现了一个细节,随着悬浮时间的变长,悬浮的图标会逐渐变红,最终变成全红。有人说,悬浮窗是微信拯救不断被抖音等APP吞噬的APP使用时间所祭出的武器,此言不虚,但我想也一定有产品对提供更加良好的阅读体验的细致入微的思考和设计,毕竟,大家都是成年人了,「稍后阅读」往往就变成了「永远不读」。另一方面,我也比较悲观的认为,碎片化阅读其实是有非常明显的局限和天花板的,悬浮窗能做的已经接近这个极限。如果你真的是一个重度的微信公众号订阅者,悬浮窗是解决不了你的问题的,你应该关注一下订阅号展示形式的变化。

验证码繁与简

小区楼下有两个自动存取货柜,一般没人在家的时候,我都喜欢让快递老哥帮我把快递放在货柜中。回来的时候,凭验证码提取快递。一家是已经被中国邮政收编的速递易,它的验证码是这样的:

一家是顺丰家的丰巢,它的验证码是这样的:

都是6位验证码,不同的是前者使用的书“数字+字母”组合,后者只使用了数字。前者应该更加符合工程师思维:验证码空间要足够大,这样才能减少碰撞的概率,降低安全风险。但是,我倒觉得后者其实对验证码的认识更加深刻:

  1. 验证码是需要人工输入的,输入方式越简单成本越低。打一个不恰当的对比,前者需要用户会标准键盘输入,后者只需要用户知道如何打电话就够了。“会标准键盘输入”这个要求很过分吗?对你也许都不值一提,但是你要知道小区很多大爷大妈连手机上的T9输入法都不会用,只会手写输入,你要他会基本的标准键盘输入,显然是对你的用户群不够了解。
  2. 绝大部分用户短时间记忆一串数字是经过训练的,这种训练贯穿我们的小学数学教育以及功能机时代的“背电话号码训练”。因此,对于喜欢晚上出来遛弯取快递的同学来说,他们是可以看一眼验证码,然后放心的不带手机去取货。而背诵数字+字母组合,对很多人来说几乎就是不可完成的任务。
  3. 部分数字和字母字符不易区分,引入额外的错误率。典型字符如数字“1”和字母“I”, 数字“0”和字母“O”。
  4. 货柜的安全问题其实不在验证码。这个不方便细说,可以讲的是,如果你推演一下如何非正常手段获取货柜的的货物,你最后推演出来的方法一定不是傻乎乎的到带有好几个摄像头的货柜前去试验证码。

在验证码这个问题上,并不是说丰巢做的多好,而是想说速递易实在做得太烂:产品上的很多问题发展到今天已经是被很好的解决了,如果想在一些常用的产品设计上创新,请一定思考清楚这个「改变」是仅仅为了不同,还是因为当前设计存在很具体的问题。

浏览器地址栏存在多年的 bug

相信绝大部分人都注意到我们使用的浏览器地址栏是可以显示中文的:

但是,较真的工程师会告诉你:URL 地址的合法字符是 ASCII 字符的子集,是不支持中文的。也许浏览器地址栏显示中文是一个 bug?

虽然,平日里我是不太支持研发同学说 “这不是bug, 这是feature”,但是,这一次,我得说这的确是浏览器的一个 feature: 用户毕竟不是工程师,对于他们来讲,他们不关心也不在乎地址栏上面的字符是否经过 urlencode, 地址栏提供给他们的信息越直接越好。从这个逻辑出发,我们就不难理解地址栏中的安全绿色小锁以及直接显示中文了。当然,为了确保技术上的正确性,你通过 Chrome 地址栏复制的和开发者工具上看到的 URL 地址其实是经过 urlencode 过的:

Go 中一个非典型不加锁读写变量案例分析

前段时间在 v2 看到一个关于并发读写变量的问题:go 一个线程写, 另外一个线程读, 为什么不能保证最终一致性。帖子中给出的例子非常简单(稍作修改)main.go

package main

import (
    "fmt"
    "runtime"
    "time"
)

var i = 0

func main() {
    runtime.GOMAXPROCS(2)
    go func() {
        for {
            fmt.Println("i am here", i)
            time.Sleep(time.Second)
        }
    }()
    for {
        i += 1
    }
}

既然是问题贴,直接运行的结果应该是出乎大多数人预料的:

╰─➤  go run main.go                                                                                                                                     1 ↵
i am here 0
i am here 0
i am here 0
i am here 0
i am here 0
i am here 0
...

帖子的回复比较多,涉及的信息量相对杂乱,爬完楼反而感觉没有看懂。这里就不卖关子,直接给出脱水后的结论:出现上面结果的原因是 go 的编译器把代码 i 自加 1 的 for 循环优化掉了。要验证这一点也很简单,我们使用 go tool objdump -s 'main\.main' main 查看编译出的二进制可执行文件的汇编代码:

╰─➤  go tool objdump -s 'main\.main' main
TEXT main.main(SB) /Users/liudanking/code/golang/gopath/src/test/main.go
  main.go:11        0x108de60       65488b0c25a0080000  MOVQ GS:0x8a0, CX
  main.go:11        0x108de69       483b6110        CMPQ 0x10(CX), SP
  main.go:11        0x108de6d       7635            JBE 0x108dea4
  main.go:11        0x108de6f       4883ec18        SUBQ $0x18, SP
  main.go:11        0x108de73       48896c2410      MOVQ BP, 0x10(SP)
  main.go:11        0x108de78       488d6c2410      LEAQ 0x10(SP), BP
  main.go:12        0x108de7d       48c7042402000000    MOVQ $0x2, 0(SP)
  main.go:12        0x108de85       e8366bf7ff      CALL runtime.GOMAXPROCS(SB)
  main.go:13        0x108de8a       c7042400000000      MOVL $0x0, 0(SP)
  main.go:13        0x108de91       488d05187f0300      LEAQ go.func.*+115(SB), AX
  main.go:13        0x108de98       4889442408      MOVQ AX, 0x8(SP)
  main.go:13        0x108de9d       e8fe13faff      CALL runtime.newproc(SB)
  main.go:20        0x108dea2       ebfe            JMP 0x108dea2
  main.go:11        0x108dea4       e8c7dffbff      CALL runtime.morestack_noctxt(SB)
  main.go:11        0x108dea9       ebb5            JMP main.main(SB)
  :-1           0x108deab       cc          INT $0x3
  :-1           0x108deac       cc          INT $0x3
  :-1           0x108dead       cc          INT $0x3
  :-1           0x108deae       cc          INT $0x3
  :-1           0x108deaf       cc          INT $0x3

TEXT main.main.func1(SB) /Users/liudanking/code/golang/gopath/src/test/main.go
  main.go:13        0x108deb0       65488b0c25a0080000  MOVQ GS:0x8a0, CX
  main.go:13        0x108deb9       483b6110        CMPQ 0x10(CX), SP
  main.go:13        0x108debd       0f8695000000        JBE 0x108df58
  main.go:13        0x108dec3       4883ec58        SUBQ $0x58, SP
  main.go:13        0x108dec7       48896c2450      MOVQ BP, 0x50(SP)
  main.go:13        0x108decc       488d6c2450      LEAQ 0x50(SP), BP
  main.go:15        0x108ded1       0f57c0          XORPS X0, X0
  main.go:15        0x108ded4       0f11442430      MOVUPS X0, 0x30(SP)
  main.go:15        0x108ded9       0f11442440      MOVUPS X0, 0x40(SP)
  main.go:15        0x108dede       488d059b020100      LEAQ runtime.types+65664(SB), AX
  main.go:15        0x108dee5       4889442430      MOVQ AX, 0x30(SP)
  main.go:15        0x108deea       488d0d0f2d0400      LEAQ main.statictmp_0(SB), CX
  main.go:15        0x108def1       48894c2438      MOVQ CX, 0x38(SP)
  main.go:15        0x108def6       488d1583fb0000      LEAQ runtime.types+63872(SB), DX
  main.go:15        0x108defd       48891424        MOVQ DX, 0(SP)
  main.go:15        0x108df01       488d1d107c0c00      LEAQ main.i(SB), BX
  main.go:15        0x108df08       48895c2408      MOVQ BX, 0x8(SP)
  main.go:15        0x108df0d       e84eddf7ff      CALL runtime.convT2E64(SB)
  main.go:15        0x108df12       488b442410      MOVQ 0x10(SP), AX
  main.go:15        0x108df17       488b4c2418      MOVQ 0x18(SP), CX
  main.go:15        0x108df1c       4889442440      MOVQ AX, 0x40(SP)
  main.go:15        0x108df21       48894c2448      MOVQ CX, 0x48(SP)
  main.go:15        0x108df26       488d442430      LEAQ 0x30(SP), AX
  main.go:15        0x108df2b       48890424        MOVQ AX, 0(SP)
  main.go:15        0x108df2f       48c744240802000000  MOVQ $0x2, 0x8(SP)
  main.go:15        0x108df38       48c744241002000000  MOVQ $0x2, 0x10(SP)
  main.go:15        0x108df41       e85a9dffff      CALL fmt.Println(SB)
  main.go:16        0x108df46       48c7042400ca9a3b    MOVQ $0x3b9aca00, 0(SP)
  main.go:16        0x108df4e       e87d27fbff      CALL time.Sleep(SB)
  main.go:15        0x108df53       e979ffffff      JMP 0x108ded1
  main.go:13        0x108df58       e813dffbff      CALL runtime.morestack_noctxt(SB)
  main.go:13        0x108df5d       e94effffff      JMP main.main.func1(SB)
  :-1           0x108df62       cc          INT $0x3
  :-1           0x108df63       cc          INT $0x3
  :-1           0x108df64       cc          INT $0x3
  :-1           0x108df65       cc          INT $0x3
  :-1           0x108df66       cc          INT $0x3
  :-1           0x108df67       cc          INT $0x3
  :-1           0x108df68       cc          INT $0x3
  :-1           0x108df69       cc          INT $0x3
  :-1           0x108df6a       cc          INT $0x3
  :-1           0x108df6b       cc          INT $0x3
  :-1           0x108df6c       cc          INT $0x3
  :-1           0x108df6d       cc          INT $0x3
  :-1           0x108df6e       cc          INT $0x3
  :-1           0x108df6f       cc          INT $0x3

显然,

    for {
        i += 1
    }

直接被优化没了。我们可以在语句 i += 1 添加一个其他语句来避免被优化掉:

    for {
        i += 1
        time.Sleep(time.Nanosecond)
    }

重新运行程序,运行结果“看似正确”了:

╰─➤  go run main.go                                                                                                                                     1 ↵
i am here 30
i am here 1806937
i am here 3853635
i am here 5485251
...

显然,如此修改之后,这段代码并非真正正确。因为变量 i 存在并发读写,即 data race 的问题。而 data race 场景下,go 的行为是未知的。程序员最讨厌的几件事中,不确定性必居其一。因此,一步小心写出 data race 的bug,调试起来是不太开心的。这里的例子因为只有几行代码,我们可以目测定位问题。如果代码规模比较大,我们可以借助 golang 工具链中的 -race 参数来排查该类问题:

╰─➤  go run -race main.go                                                                                                                               2 ↵
==================
WARNING: DATA RACE
Read at 0x0000011d4318 by goroutine 6:
  runtime.convT2E64()
      /usr/local/go/src/runtime/iface.go:335 +0x0
  main.main.func1()
      /Users/liudanking/code/golang/gopath/src/test/main.go:15 +0x7d

Previous write at 0x0000011d4318 by main goroutine:
  main.main()
      /Users/liudanking/code/golang/gopath/src/test/main.go:20 +0x7f

Goroutine 6 (running) created at:
  main.main()
      /Users/liudanking/code/golang/gopath/src/test/main.go:13 +0x53
==================
i am here 1
i am here 558324
i am here 1075838

除了在 go run 上可以使用 -trace, 其他几个常用的golang工具链指令也支持这个参数:

$ go test -race mypkg    // to test the package
$ go run -race mysrc.go  // to run the source file
$ go build -race mycmd   // to build the command
$ go install -race mypkg // to install the package

需要说明的是, -trace 并不保证能够检查出程序中所有的 data race, 而检查出 data race 则必然存在。说起来比较绕,大家记住它跟布隆过滤器 (Bloom Filter) 的真值表是一样的就对了。

而要把最开始提到的代码改对,方法有很多,我们可以使用 The Go Memory Model 推荐的 sync 包中的读写锁即可:

package main

import (
    "fmt"
    "runtime"
    "sync"
    "time"
)

var i = 0

func main() {
    runtime.GOMAXPROCS(2)
    mtx := sync.RWMutex{}
    go func() {
        for {
            mtx.RLock()
            fmt.Println("i am here", i)
            mtx.RUnlock()
            time.Sleep(time.Second)
        }
    }()
    for {
        mtx.Lock()
        i += 1
        mtx.Unlock()
        time.Sleep(time.Nanosecond)
    }

扩展阅读