该如何设计你的 PasswordEncoder?

缘起

前端时间将一个集成了 spring-security-oauth2 的旧项目改造了一番,将 springboot 升级成了 springboot 2.0,众所周知 springboot 2.0 依赖的是 spring5,并且许多相关的依赖都发生了较大的改动,与本文相关的改动罗列如下,有兴趣的同学可以看看:Spring Security 5.0 New Features ,增强了 oauth2 集成的功能以及和一个比较有意思的改动—重构了密码编码器的实现(Password Encoding,由于大多数 PasswordEncoder 相关的算法是 hash 算法,所以本文将 PasswordEncoder 翻译成‘密码编码器’和并非‘密码加密器’)官方称之为

Modernized Password Encoding — 现代化的密码编码方式

另外,springboot2.0 的自动配置也做了一些调整,其中也有几点和 spring-security 相关,戳这里看所有细节 springboot2.0 迁移指南

一开始,我仅仅修改了依赖,将

1
2
3
4
5
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.5.4.RELEASE</version>
</parent>

升级成了

1
2
3
4
5
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.1.RELEASE</version>
</parent>

不出意料出现了兼容性的问题,我在尝试登陆时,出现了如下的报错

1
java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"

原因也很明显,正如 spring security 的更新文档中描述的那样,spring security 5 对 PasswordEncoder 做了相关的重构,原先默认配置的 PlainTextPasswordEncoder(明文密码)被移除了。这引起了我的兴趣,spring security 在新版本中对于 passwordEncoder 进行了哪些改造,这些改造背后又是出于什么样的目的呢?卖个关子,先从远古时期的案例来一步步演化出所谓的“现代化密码编码方式”。

密码存储演进史

自从互联网有了用户的那一刻起,存储用户密码这件事便成为了一个健全的系统不得不面对的一件事。远古时期,明文存储密码可能还不被认为是一个很大的系统缺陷(事实上这是一件很恐怖的事)。提及明文存储密码,我立刻联想到的是 CSDN 社区在 2011 年末发生的 600 万用户密码泄露的事件,谁也不会想到这个和程序员密切相关的网站会犯如此低级的错误。明文存储密码使得恶意用户可以通过 sql 注入等攻击方式来获取用户名和密码,虽然安全框架和良好的编码规范可以规避很多类似的攻击,但依旧避免不了系统管理员,DBA 有途径获取用户密码这一事实。事实上,不用明文存储存储密码,程序员们早在 n 多年前就已经达成了共识。

不能明文存储,一些 hash 算法便被广泛用做密码的编码器,对密码进行单向 hash 处理后存储数据库,当用户登录时,计算用户输入的密码的 hash 值,将两者进行比对。单向 hash 算法,顾名思义,它无法(或者用不能轻易更为合适)被反向解析还原出原密码。这杜绝了管理员直接获取密码的途径,可仅仅依赖于普通的 hash 算法(如 md5,sha256)是不合适的,他主要有 3 个特点:

  1. 同一密码生成的 hash 值一定相同
  2. 不同密码的生成的 hash 值可能相同(md5 的碰撞问题相比 sha256 还要严重)
  3. 计算速度快。

以上三点结合在一起,破解此类算法成了不是那么困难的一件事,尤其是第三点,会在下文中再次提到,多快才算非常快?按照相关资料的说法:

modern hardware perform billions of hash calculations a second.

考虑到大多数用户使用的密码多为数字+字母+特殊符号的组合,攻击者将常用的密码进行枚举,甚至通过排列组合来暴力破解,这被称为 rainbow table。算法爱好者能够立刻看懂到上述的方案,这被亲切地称之为—打表,一种暴力美学,这张表是可以被复用的。

虽然仅仅依赖于传统 hash 算法的思路被否决了,但这种 hash 后比对的思路,几乎被后续所有的优化方案继承。

hash 方案迎来的第一个改造是对引入一个“随机的因子”来掺杂进明文中进行 hash 计算,这样的随机因子通常被称之为盐 (salt)。salt 一般是用户相关的,每个用户持有各自的 salt。此时狗蛋和二丫的密码即使相同,由于 salt 的影响,存储在数据库中的密码也是不同的,除非…为每个用户单独建议一张 rainbow table。很明显 salted hash 相比普通的单向 hash 方案加大了 hacker 攻击的难度。但了解过 GPU 并行计算能力之强大的童鞋,都能够意识到,虽然破解 salted hash 比较麻烦,却并非不可行,勤劳勇敢的安全专家似乎也对这个方案不够满意。

为解决上述 salted hash 仍然存在的问题,一些新型的单向 hash 算法被研究了出来。其中就包括:Bcrypt,PBKDF2,Scrypt,Argon2。为什么这些 hash 算法能保证密码存储的安全性?因为他们足够慢,恰到好处的慢。这么说不严谨,只是为了给大家留个深刻的映像:慢。这类算法有一个特点,存在一个影响因子,可以用来控制计算强度,这直接决定了破解密码所需要的资源和时间,直观的体会可以见下图,在一年内破解如下算法所需要的硬件资源花费(折算成美元)

一年内破解如下算法所需要的硬件资源花费

这使得破解成了一件极其困难的事,并且,其中的计算强度因子是可控的,这样,即使未来量子计算机的计算能力爆表,也可以通过其控制计算强度以防破解。注意,普通的验证过程只需要计算一次 hash 计算,使用此类 hash 算法并不会影响到用户体验。

慢 hash 算法真的安全吗?

Bcrypt,Scrypt,PBKDF2 这些慢 hash 算法是目前最为推崇的 password encoding 方式,好奇心驱使我思考了这样一个问题:慢 hash 算法真的安全吗?

我暂时还没有精力仔细去研究他们中每一个算法的具体实现,只能通过一些文章来拾人牙慧,简单看看这几个算法的原理和安全性。

PBKDF2 被设计的很简单,它的基本原理是通过一个伪随机函数(例如 HMAC 函数),把明文和一个盐值作为输入参数,然后按照设置的计算强度因子重复进行运算,并最终产生密钥。这样的重复 hash 已经被认为足够安全,但也有人提出了不同意见,此类算法对于传统的 CPU 来说的确是足够安全,但 GPU 被搬了出来,前文提到过 GPU 的并行计算能力非常强大。

Bcrypt 强大的一点在于,其不仅仅是 CPU 密集型,还是 RAM 密集型!双重的限制因素,导致 GPU,ASIC(专用集成电路)无法应对 Bcrypt 带来的破解困境。

然后…看了 Scrypt 的相关资料之后我才意识到这个坑有多深。一个熟悉又陌生的词出现在了我面前:FPGA(现场可编程逻辑门阵列),这货就比较厉害了。现成的芯片指令结构如传统的 CPU,GPU,ASIC 都无法破解 Bcrypt,但是 FPGA 支持烧录逻辑门(如AND、OR、XOR、NOT),通过编程的方式烧录指令集的这一特性使得可以定制硬件来破解 Bcrypt。尽管我不认为懂这个技术的人会去想办法破解真正的系统,但,只要这是一个可能性,就总有方法会被发明出来与之对抗。Scrypt 比 Bcrypt 额外考虑到的就是大规模的自定义硬件攻击 ,从而刻意设计需要大量内存运算。

理论终归是理论,实际上 Bcrypt 算法被发明至今 18 年,使用范围广,且从未因为安全问题而被修改,其有限性是已经被验证过的,相比之下 Scrypt 据我看到的文章显示是 9 年的历史,没有 Bcrypt 使用的广泛。从破解成本和权威性的角度来看,Bcrypt 用作密码编码器是不错的选择。

spring security 废弃的接口

回到文档中,spring security 5 对 PasswordEncoder 做了相关的重构,原先默认配置的 PlainTextPasswordEncoder(明文密码)被移除了,想要做到明文存储密码,只能使用一个过期的类来过渡

1
2
3
4
@Bean
PasswordEncoder passwordEncoder(){
return NoOpPasswordEncoder.getInstance();
}

实际上,spring security 提供了 BCryptPasswordEncoder 来进行密码编码,并作为了相关配置的默认配置,只不过没有暴露为全局的 Bean。使用明文存储的风险在文章一开始就已经强调过,NoOpPasswordEncoder 只能存在于 demo 中。

1
2
3
4
@Bean
PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}

别忘了对你数据库中的密码进行同样的编码,否则无法对应。

更深层的思考

实际上,spring security 5 的另一个设计是促使我写成本文的初衷。

不知道有没有读者产生跟我相同的困扰:

  1. 如果我要设计一个 QPS 很高的登录系统,使用 spring security 推荐的 BCrypt 会不会存在性能问题?
  2. spring security 怎么这么坑,原来的密码编码器都给改了,我需要怎么迁移旧密码编码的应用程序?
  3. 万一以后出了更高效的加密算法,这种笨重的硬编码方式配置密码编码器是不是不够灵活?

在 spring security 5 提供了这样一个思路,应该将密码编码之后的 hash 值和加密方式一起存储,并提供了一个 DelegatingPasswordEncoder 来作为众多密码密码编码方式的集合。

1
2
3
4
@Bean
PasswordEncoder passwordEncoder(){
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

负责生产 DelegatingPasswordEncoder 的工厂方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class PasswordEncoderFactories {

public static PasswordEncoder createDelegatingPasswordEncoder() {
String encodingId = "bcrypt";
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put(encodingId, new BCryptPasswordEncoder());
encoders.put("ldap", new LdapShaPasswordEncoder());
encoders.put("MD4", new Md4PasswordEncoder());
encoders.put("MD5", new MessageDigestPasswordEncoder("MD5"));
encoders.put("noop", NoOpPasswordEncoder.getInstance());
encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
encoders.put("SHA-1", new MessageDigestPasswordEncoder("SHA-1"));
encoders.put("SHA-256", new MessageDigestPasswordEncoder("SHA-256"));
encoders.put("sha256", new StandardPasswordEncoder());

return new DelegatingPasswordEncoder(encodingId, encoders);
}

private PasswordEncoderFactories() {}
}

如此注入 PasswordEncoder 之后,我们在数据库中需要这么存储数据:

1
2
3
4
5
{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG 
{noop}password
{pbkdf2}5d923b44a6d129f3ddf3e3c8d29412723dcbde72445e8ef6bf3b508fbf17fa4ed4d6b99ca763d8dc
{scrypt}$e0801$8bWJaSu2IKSn9Z9kM+TPXfOc/9bdYSrN1oD9qfVThWEwdRTnO7re7Ei+fUZRJ68k9lTyuTeUp4of4g24hHnazw==$OAOec05+bXxvuu/1qZ6NUR+xQYvYv7BeL1QxwRpY5Pc=
{sha256}97cde38028ad898ebc02e690819fa220e88c62e0699403e94fff291cfffaf8410849f27605abcbc0

还记得文章开始的报错吗?

1
java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"

这个 id 就是因为我们没有为数据库中的密码添加 {bcrypt} 此类的前缀导致的。

你会不会担心密码泄露后,{bcrypt},{pbkdf2},{scrypt},{sha256} 此类前缀会直接暴露密码的编码方式?其实这个考虑是多余的,因为密码存储的依赖算法并不是一个秘密。大多数能搞到你密码的 hacker 都可以轻松的知道你用的是什么算法,例如,bcrypt 算法通常以 \$2a$ 开头

稍微思考下,前面的三个疑问就可以迎刃而解,这就是文档中所谓的:能够自适应服务器性能的现代化密码编码方案

参考

Password Hashing: PBKDF2, Scrypt, Bcrypt

core-services-password-encoding

show me the code

spring security oauth2 的 github 代码示例,体会下 spring security 4 -> spring security 5 的相关变化。

https://github.com/lexburner/oauth2-demo

分享到 评论

理解JWT的使用场景和优劣

经过前面两篇文章《JSON Web Token - 在Web应用间安全地传递信息》《八幅漫画理解使用JSON Web Token设计单点登录系统》的科普,相信大家应该已经知道了 JWT 协议是什么了。至少看到

1
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJxaWFubWlJZCI6InFtMTAzNTNzaEQiLCJpc3MiOiJhcHBfcW0xMDM1M3NoRCIsInBsYXRmb3JtIjoiYXBwIn0.cMNwyDTFVYMLL4e7ts50GFHTvlSJLDpePtHXzu7z9j4

这样形如 A.B.C 的字符串时能敏感地认出这是使用了 jwt。发了这两篇文章后,有不少读者在文末留言,表达了对 jwt 使用方式的一些疑惑,以及到底哪些场景适合使用 jwt。我并不是 jwt 方面的专家,和不少读者一样,起初研究时我也存在相同疑惑,甚至在逐渐接触后产生了更大的疑惑,经过这段时间项目中的使用和一些自己思考,把个人的总结整理成此文。

编码,签名,加密

这些基础知识简单地介绍下,千万别搞混了三个概念。在 jwt 中恰好同时涉及了这三个概念,笔者用大白话来做下通俗的讲解(非严谨定义,供个人理解)

编码(encode)和解码(decode)

一般是编码解码是为了方便以字节的方式表示数据,便于存储和网络传输。整个 jwt 串会被置于 http 的 Header 或者 url 中,为了不出现乱码解析错误等意外,编码是有必要的。在 jwt 中以 . 分割的三个部分都经过 base64 编码(secret 部分是否进行 base64 编码是可选的,header 和 payload 则是必须进行 base64 编码)。注意,编码的一个特点:编码和解码的整个过程是可逆的。得知编码方式后,整个 jwt 串便是明文了,随意找个网站验证下解码后的内容:

base64

所以注意一点,payload 是一定不能够携带敏感数据如密码等信息的

签名(signature)

签名的目的主要是为了验证我是“我”。jwt 中常用的签名算法是 HS256,可能大多数人对这个签名算法不熟悉,但 md5,sha 这样的签名算法肯定是为人熟知的,签名算法共同的特点是整个过程是不可逆的。由于签名之前的主体内容(header,payload)会携带在 jwt 字符串中,所以需要使用带有密钥(yuè)的签名算法,密钥是服务器和签发者共享的。header 部分和 payload 部分如果被篡改,由于篡改者不知道密钥是什么,也无法生成新的 signature 部分,服务端也就无法通过,在 jwt 中,消息体是透明的,使用签名可以保证消息不被篡改。

前面转载的文章中,原作者将 HS256 称之为加密算法,不太严谨。

加密(encryption)

加密是将明文信息改变为难以读取的密文内容,使之不可读。只有拥有解密方法的对象,经由解密过程,才能将密文还原为正常可读的内容。加密算法通常按照加密方式的不同分为对称加密(如 AES)和非对称加密(如 RSA)。你可能会疑惑:“jwt 中哪儿涉及加密算法了?”,其实 jwt 的 第一部分(header) 中的 alg 参数便可以指定不同的算法来生成第三部分(signature),大部分支持 jwt 的框架至少都内置 rsa 这种非对称加密方式。这里诞生了第一个疑问

疑问:一提到 rsa,大多数人第一想到的是非对称加密算法,而 jwt 的第三部分明确的英文定义是 signature,这不是矛盾吗?

划重点!

rsa 加密rsa 签名 是两个概念!(吓得我都换行了)

这两个用法很好理解:

  • 既然是加密,自然是不希望别人知道我的消息,只有我自己才能解密,所以公钥负责加密,私钥负责解密。这是大多数的使用场景,使用 rsa 来加密。
  • 既然是签名,自然是希望别人不能冒充我发消息,只有我才能发布签名,所以私钥负责签名,公钥负责验证

所以,在客户端使用 rsa 算法生成 jwt 串时,是使用私钥来“加密”的,而公钥是公开的,谁都可以解密,内容也无法变更(篡改者无法得知私钥)。

所以,在 jwt 中并没有纯粹的加密过程,而是使加密之虚,行签名之实。

什么场景该适合使用jwt?

来聊聊几个场景,注意,以下的几个场景不是都和jwt贴合。

  1. 一次性验证

比如用户注册后需要发一封邮件让其激活账户,通常邮件中需要有一个链接,这个链接需要具备以下的特性:能够标识用户,该链接具有时效性(通常只允许几小时之内激活),不能被篡改以激活其他可能的账户…这种场景就和 jwt 的特性非常贴近,jwt 的 payload 中固定的参数:iss 签发者和 exp 过期时间正是为其做准备的。

  1. restful api的无状态认证

使用 jwt 来做 restful api 的身份认证也是值得推崇的一种使用方案。客户端和服务端共享 secret;过期时间由服务端校验,客户端定时刷新;签名信息不可被修改…spring security oauth jwt 提供了一套完整的 jwt 认证体系,以笔者的经验来看:使用 oauth2 或 jwt 来做 restful api 的认证都没有大问题,oauth2 功能更多,支持的场景更丰富,后者实现简单。

  1. 使用 jwt 做单点登录+会话管理(不推荐)

在《八幅漫画理解使用JSON Web Token设计单点登录系统》一文中提及了使用 jwt 来完成单点登录,本文接下来的内容主要就是围绕这一点来进行讨论。如果你正在考虑使用 jwt+cookie 代替 session+cookie ,我强力不推荐你这么做。

首先明确一点:使用 jwt 来设计单点登录系统是一个不太严谨的说法。首先 cookie+jwt 的方案前提是非跨域的单点登录(cookie 无法被自动携带至其他域名),其次单点登录系统包含了很多技术细节,至少包含了身份认证和会话管理,这还不涉及到权限管理。如果觉得比较抽象,不妨用传统的 session+cookie 单点登录方案来做类比,通常我们可以选择 spring security(身份认证和权限管理的安全框架)和 spring session(session 共享)来构建,而选择用 jwt 设计单点登录系统需要解决很多传统方案中同样存在和本不存在的问题,以下一一详细罗列。

jwt token泄露了怎么办?

前面的文章下有不少人留言提到这个问题,我则认为这不是问题。传统的 session+cookie 方案,如果泄露了 sessionId,别人同样可以盗用你的身份。扬汤止沸不如釜底抽薪,不妨来追根溯源一下,什么场景会导致你的 jwt 泄露。

遵循如下的实践可以尽可能保护你的 jwt 不被泄露:使用 https 加密你的应用,返回 jwt 给客户端时设置 httpOnly=true 并且使用 cookie 而不是 LocalStorage 存储 jwt,这样可以防止 XSS 攻击和 CSRF 攻击(对这两种攻击感兴趣的童鞋可以看下 spring security 中对他们的介绍CSRF,XSS

你要是正在使用 jwt 访问一个接口,这个时候你的同事跑过来把你的 jwt 抄走了,这种泄露,恕在下无力

secret如何设计

jwt 唯一存储在服务端的只有一个 secret,个人认为这个 secret 应该设计成和用户相关的,而不是一个所有用户公用的统一值。这样可以有效的避免一些注销和修改密码时遇到的窘境。

注销和修改密码

传统的 session+cookie 方案用户点击注销,服务端清空 session 即可,因为状态保存在服务端。但 jwt 的方案就比较难办了,因为 jwt 是无状态的,服务端通过计算来校验有效性。没有存储起来,所以即使客户端删除了 jwt,但是该 jwt 还是在有效期内,只不过处于一个游离状态。分析下痛点:注销变得复杂的原因在于 jwt 的无状态。我提供几个方案,视具体的业务来决定能不能接受。

  • 仅仅清空客户端的 cookie,这样用户访问时就不会携带 jwt,服务端就认为用户需要重新登录。这是一个典型的假注销,对于用户表现出退出的行为,实际上这个时候携带对应的 jwt 依旧可以访问系统。
  • 清空或修改服务端的用户对应的 secret,这样在用户注销后,jwt 本身不变,但是由于 secret 不存在或改变,则无法完成校验。这也是为什么将 secret 设计成和用户相关的原因。
  • 借助第三方存储自己管理 jwt 的状态,可以以 jwt 为 key,实现去 redis 一类的缓存中间件中去校验存在性。方案设计并不难,但是引入 redis 之后,就把无状态的 jwt 硬生生变成了有状态了,违背了 jwt 的初衷。实际上这个方案和 session 都差不多了。

修改密码则略微有些不同,假设号被到了,修改密码(是用户密码,不是 jwt 的 secret)之后,盗号者在原 jwt 有效期之内依旧可以继续访问系统,所以仅仅清空 cookie 自然是不够的,这时,需要强制性的修改 secret。在我的实践中就是这样做的。

续签问题

续签问题可以说是我抵制使用 jwt 来代替传统 session 的最大原因,因为 jwt 的设计中我就没有发现它将续签认为是自身的一个特性。传统的 cookie 续签方案一般都是框架自带的,session 有效期 30 分钟,30 分钟内如果有访问,session 有效期被刷新至 30 分钟。而 jwt 本身的 payload 之中也有一个 exp 过期时间参数,来代表一个 jwt 的时效性,而 jwt 想延期这个 exp 就有点身不由己了,因为 payload 是参与签名的,一旦过期时间被修改,整个 jwt 串就变了,jwt 的特性天然不支持续签!

如果你一定要使用 jwt 做会话管理(payload 中存储会话信息),也不是没有解决方案,但个人认为都不是很令人满意

  1. 每次请求刷新 jwt

jwt 修改 payload 中的 exp 后整个 jwt 串就会发生改变,那…就让它变好了,每次请求都返回一个新的 jwt 给客户端。太暴力了,不用我赘述这样做是多么的不优雅,以及带来的性能问题。

但,至少这是最简单的解决方案。

  1. 只要快要过期的时候刷新 jwt

一个上述方案的改造点是,只在最后的几分钟返回给客户端一个新的 jwt。这样做,触发刷新 jwt 基本就要看运气了,如果用户恰巧在最后几分钟访问了服务器,触发了刷新,万事大吉;如果用户连续操作了 27 分钟,只有最后的 3 分钟没有操作,导致未刷新 jwt,无疑会令用户抓狂。

  1. 完善 refreshToken

借鉴 oauth2 的设计,返回给客户端一个 refreshToken,允许客户端主动刷新 jwt。一般而言,jwt 的过期时间可以设置为数小时,而 refreshToken 的过期时间设置为数天。

我认为该方案并可行性是存在的,但是为了解决 jwt 的续签把整个流程改变了,为什么不考虑下 oauth2 的 password 模式和 client 模式呢?

  1. 使用 redis 记录独立的过期时间

实际上我的项目中由于历史遗留问题,就是使用 jwt 来做登录和会话管理的,为了解决续签问题,我们在 redis 中单独会每个 jwt 设置了过期时间,每次访问时刷新 jwt 的过期时间,若 jwt 不存在与 redis 中则认为过期。

tips:精确控制 redis 的过期时间不是件容易的事,可以参考我最近的一篇借助于 spring session 讲解 redis 过期时间的排坑记录。

同样改变了 jwt 的流程,不过嘛,世间安得两全法。我只能奉劝各位还未使用 jwt 做会话管理的朋友,尽量还是选用传统的 session+cookie 方案,有很多成熟的分布式 session 框架和安全框架供你开箱即用。

jwt,oauth2,session千丝万缕的联系

具体的对比不在此文介绍,就一位读者的留言回复下它的提问

这么长一个字符串,还不如我把数据存到数据库,给一个长的很难碰撞的key来映射,也就是专用token。

这位兄弟认为 jwt 太长了,是不是可以考虑使用和 oauth2 一样的 uuid 来映射。这里面自然是有问题的,jwt 不仅仅是作为身份的认证(验证签名是否正确,签发者是否存在,有限期是否过期),还在其 payload 中存储着会话信息,这是 jwt 和 session 的最大区别,一个在客户端携带会话信息,一个在服务端存储会话信息。如果真的是要将 jwt 的信息置于在共享存储中,那再找不到任何使用 jwt 的意义了。

jwt 和 oauth2 都可以用于 restful 的认证,就我个人的使用经验来看,spring security oauth2 可以很好的使用多种认证模式:client 模式,password 模式,implicit 模式(authorization code 模式不算单纯的接口认证模式),也可以很方便的实现权限控制,什么样的 api 需要什么样的权限,什么样的资源需要什么样的 scope…而 jwt 我只用它来实现过身份认证,功能较为单一(可能是我没发现更多用法)。

总结

在 web 应用中,使用 jwt 代替 session 存在不小的风险,你至少得解决本文中提及的那些问题,绝大多数情况下,传统的 cookie-session 机制工作得更好。jwt 适合做简单的 restful api 认证,颁发一个固定有效期的 jwt,降低 jwt 暴露的风险,不要对 jwt 做服务端的状态管理,这样才能体现出 jwt 无状态的优势。

可能对 jwt 的使用场景还有一些地方未被我察觉,后续会研究下 spring security oauth jwt 的源码,不知到时会不会有新发现。

分享到 评论

从Spring-Session源码看Session机制的实现细节

去年我曾经写过几篇和 Spring Session 相关的文章,从一个未接触过 Spring Session 的初学者视角介绍了 Spring Session 如何上手,如果你未接触过 Spring Session,推荐先阅读下「从零开始学习Spring Session」系列(https://www.cnkirito.moe/categories/Spring-Session/) Spring Session 主要解决了分布式场景下 Session 的共享问题,本文将从 Spring Session 的源码出发,来讨论一些 Session 设计的细节。

Spring Session 数据结构解读

想象一个场景,现在一到面试题呈现在你面前,让你从零开始设计一个 Session 存储方案,你会怎么回答?

说白了就是让你设计一套数据结构存储 Session,并且我相信提出这个问题时,大多数读者脑海中会浮现出 redis,设计一个 map,使用 ttl 等等,但没想到的细节可能会更多。先来预览一下 Spring Session 的实际数据结构是什么样的(使用 spring-session-redis 实现),当我们访问一次集成了Spring Session 的 web 应用时

1
2
3
4
5
@RequestMapping("/helloworld")
public String hello(HttpSession session){
session.setAttribute("name","xu");
return "hello.html";
}

可以在 Redis 中看到如下的数据结构:

1
2
3
4
5
A) "spring:session:sessions:39feb101-87d4-42c7-ab53-ac6fe0d91925"

B) "spring:session:expirations:1523934840000"

C) "spring:session:sessions:expires:39feb101-87d4-42c7-ab53-ac6fe0d91925"

这三种键职责的分析将会贯彻全文,为了统一叙述,在此将他们进行编号,后续简称为 A 类型键,B 类型键,C 类型键。先简单分析下他们的特点

  • 他们公用的前缀是 spring:session
  • A 类型键的组成是前缀 +”sessions”+sessionId,对应的值是一个 hash 数据结构。在我的 demo 中,其值如下
1
2
3
4
5
6
{
"lastAccessedTime": 1523933008926,/*2018/4/17 10:43:28*/
"creationTime": 1523933008926, /*2018/4/17 10:43:28*/
"maxInactiveInterval": 1800,
"sessionAttr:name": "xu"
}

其中 creationTime(创建时间),lastAccessedTime(最后访问时间),maxInactiveInterval(session 失效的间隔时长) 等字段是系统字段,sessionAttr:xx 可能会存在多个键值对,用户存放在 session 中的数据如数存放于此。

A 类型键对应的默认 TTL 是 35 分钟。

  • B 类型键的组成是前缀+”expirations”+时间戳,无需纠结这个时间戳的含义,先卖个关子。其对应的值是一个 set 数据结构,这个 set 数据结构中存储着一系列的 C 类型键。在我的 demo 中,其值如下
1
2
3
[
"expires:39feb101-87d4-42c7-ab53-ac6fe0d91925"
]

B 类型键对应的默认 TTL 是 30 分钟

  • C 类型键的组成是前缀+”sessions:expires”+sessionId,对应一个空值,它仅仅是 sessionId 在 redis 中的一个引用,具体作用继续卖关子。

C 类型键对应的默认 TTL 是 30 分钟。

kirito-session 的天使轮方案

介绍完 Spring Session 的数据结构,我们先放到一边,来看看如果我们自己设计一个 Session 方案,拟定为 kirito-session 吧,该如何设计。

kirito 的心路历程是这样的:“使用 redis 存 session 数据,对,session 需要有过期机制,redis 的键可以自动过期,肯定很方便。”

于是 kirito 设计出了 spring-session 中的 A 类型键,复用它的数据结构:

1
2
3
4
5
6
{
"lastAccessedTime": 1523933008926,
"creationTime": 1523933008926,
"maxInactiveInterval": 1800,
key/value...
}

然后对 A 类型的键设置 ttl A 30 分钟,这样 30分钟之后 session 过期,0-30 分钟期间如果用户持续操作,那就根据 sessionId 找到 A 类型的 key,刷新 lastAccessedTime 的值,并重新设置 ttl,这样就完成了「续签」的特性。

显然 Spring Session 没有采用如此简练的设计,为什么呢?翻看 Spring Session 的文档

One problem with relying on Redis expiration exclusively is that Redis makes no guarantee of when the expired event will be fired if the key has not been accessed. Specifically the background task that Redis uses to clean up expired keys is a low priority task and may not trigger the key expiration. For additional details see Timing of expired events section in the Redis documentation.

大致意思是说,redis 的键过期机制不“保险”,这和 redis 的设计有关,不在此拓展开,研究这个的时候翻了不少资料,得出了如下的总结:

  1. redis 在键实际过期之后不一定会被删除,可能会继续存留,但具体存留的时间我没有做过研究,可能是 1~2 分钟,可能会更久。
  2. 具有过期时间的 key 有两种方式来保证过期,一是这个键在过期的时候被访问了,二是后台运行一个定时任务自己删除过期的 key。划重点:这启发我们在 key 到期后只需要访问一下 key 就可以确保 redis 删除该过期键
  3. 如果没有指令持续关注 key,并且 redis 中存在许多与 TTL 关联的 key,则 key 真正被删除的时间将会有显著的延迟!显著的延迟!显著的延迟!

天使轮计划惨遭破产,看来单纯依赖于 redis 的过期时间是不可靠的,秉持着力求严谨的态度,迎来了 A 轮改造。

A 轮改造—引入 B 类型键确保 session 的过期机制

redis 的官方文档启发我们,可以启用一个后台定时任务,定时去删除那些过期的键,配合上 redis 的自动过期,这样可以双重保险。第一个问题来了,我们将这些过期键存在哪儿呢?不找个合适的地方存起来,定时任务到哪儿去删除这些应该过期的键呢?总不能扫描全库吧!来解释我前面卖的第一个关子,看看 B 类型键的特点:

1
spring:session:expirations:1523934840000

时间戳的含义

1523934840000 这明显是个 Unix 时间戳,它的含义是存放着这一分钟内应该过期的键,所以它是一个 set 数据结构。解释下这个时间戳是怎么计算出来的org.springframework.session.data.redis.RedisSessionExpirationPolicy#roundUpToNextMinute

1
2
3
4
5
6
7
8
static long roundUpToNextMinute(long timeInMs) {
Calendar date = Calendar.getInstance();
date.setTimeInMillis(timeInMs);
date.add(Calendar.MINUTE, 1);
date.clear(Calendar.SECOND);
date.clear(Calendar.MILLISECOND);
return date.getTimeInMillis();
}

还记得 lastAccessedTime=1523933008926,maxInactiveInterval=1800 吧,lastAccessedTime 转换成北京时间是: 2018/4/17 10:43:28,向上取整是2018/4/17 10:44:00,再次转换为 Unix 时间戳得到 1523932980000,单位是 ms,1800 是过期时间的间隔,单位是 s,二者相加 1523932980000+1800*1000=1523934840000。这样 B 类型键便作为了一个「桶」,存放着这一分钟应当过期的 session 的 key。

后台定时任务

org.springframework.session.data.redis.RedisSessionExpirationPolicy#cleanupExpiredSessions

1
2
3
4
@Scheduled(cron = "${spring.session.cleanup.cron.expression:0 * * * * *}")
public void cleanupExpiredSessions() {
this.expirationPolicy.cleanExpiredSessions();
}

后台提供了定时任务去“删除”过期的 key,来补偿 redis 到期未删除的 key。方案再描述下,方便大家理解:取得当前时间的时间戳作为 key,去 redis 中定位到 spring:session:expirations:{当前时间戳} ,这个 set 里面存放的便是所有过期的 key 了。

续签的影响

每次 session 的续签,需要将旧桶中的数据移除,放到新桶中。验证这一点很容易。

在第一分钟访问一次 http://localhost:8080/helloworld 端点,得到的 B 类型键为:spring:session:expirations:1523934840000;第二分钟再访问一次 http://localhost:8080/helloworld 端点,A 类型键的 lastAccessedTime 得到更新,并且 spring:session:expirations:1523934840000 这个桶被删除了,新增了 spring:session:expirations:1523934900000 这个桶。当众多用户活跃时,桶的增删和以及 set 中数据的增删都是很频繁的。对了,没提到的一点,对应 key 的 ttl 时间也会被更新。

kirito-session 方案貌似比之前严谨了,目前为止使用了 A 类型键和 B 类型键解决了 session 存储和 redis 键到期不删除的两个问题,但还是存在问题的。

B 轮改造—优雅地解决 B 类型键的并发问题

引入 B 类型键看似解决了问题,却也引入了一个新的问题:并发问题。

来看看一个场景:

假设存在一个 sessionId=1 的会话,初始时间戳为 1420656360000

1
2
spring:session:expirations:1420656360000 -> [1]
spring:session:session:1 -> <session>

接下来迎来了并发访问,(用户可能在浏览器中多次点击):

  • 线程 1 在第 2 分钟请求,产生了续签,session:1 应当从 1420656360000 这个桶移动到 142065642000 这个桶
  • 线程 2 在第 3 分钟请求,也产生了续签,session:1 本应当从 1420656360000 这个桶移动到 142065648000 这个桶
  • 如果上两步按照次序执行,自然不会有问题。但第 3 分钟的请求可能已经执行完毕了,第 2 分钟才刚开始执行。

像下面这样:

线程 2 从第一分钟的桶中移除 session:1,并移动到第三分钟的桶中

1
2
3
spring:session:expirations:1420656360000 -> []
spring:session:session:1 -> <session>
spring:session:expirations:1420656480000 -> [1]

线程 1 完成相同的操作,它也是基于第一分钟来做的,但会移动到第二分钟的桶中

1
2
3
spring:session:expirations:1420656360000 -> []
spring:session:session:1 -> <session>
spring:session:expirations:1420656420000 -> [1]

最后 redis 中键的情况变成了这样:

1
2
3
4
spring:session:expirations:1420656360000 -> []
spring:session:session:1 -> <session>
spring:session:expirations:1420656480000 -> [1]
spring:session:expirations:1420656420000 -> [1]

后台定时任务会在第 32 分钟扫描到 spring:session:expirations:1420656420000 桶中存在的 session,这意味着,本应该在第 33 分钟才会过期的 key,在第 32 分钟就会被删除!

一种简单的方法是用户的每次 session 续期加上分布式锁,这显然不能被接受。来看看 Spring Session 是怎么巧妙地应对这个并发问题的。

org.springframework.session.data.redis.RedisSessionExpirationPolicy#cleanExpiredSessions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public void cleanExpiredSessions() {
long now = System.currentTimeMillis();
long prevMin = roundDownMinute(now);

if (logger.isDebugEnabled()) {
logger.debug("Cleaning up sessions expiring at " + new Date(prevMin));
}

// 获取到 B 类型键
String expirationKey = getExpirationKey(prevMin);
// 取出当前这一分钟应当过期的 session
Set<Object> sessionsToExpire = this.redis.boundSetOps(expirationKey).members();
// 注意:这里删除的是 B 类型键,不是删除 session 本身!
this.redis.delete(expirationKey);
for (Object session : sessionsToExpire) {
String sessionKey = getSessionKey((String) session);
// 遍历一下 C 类型的键
touch(sessionKey);
}
}

/**
* By trying to access the session we only trigger a deletion if it the TTL is
* expired. This is done to handle
* https://github.com/spring-projects/spring-session/issues/93
*
* @param key the key
*/
private void touch(String key) {
// 并不是删除 key,而只是访问 key
this.redis.hasKey(key);
}

这里面逻辑主要是拿到过期键的集合(实际上是 C 类型的 key,但这里可以理解为 sessionId,C 类型我下面会介绍),此时这个集合里面存在三种类型的 sessionId。

  1. 已经被 redis 删除的过期键。万事大吉,redis 很靠谱的及时清理了过期的键。
  2. 已经过期,但是还没来得及被 redis 清除的 key。还记得前面 redis 文档里面提到的一个技巧吗?我们在 key 到期后只需要访问一下 key 就可以确保 redis 删除该过期键,所以 redis.hasKey(key); 该操作就是为了触发 redis 的自己删除。
  3. 并发问题导致的多余数据,实际上并未过期。如上所述,第 32 分钟的桶里面存在的 session:1 实际上并不应该被删除,使用 touch 的好处便是我只负责检测,删不删交给 redis 判断。session:1 在第 32 分钟被 touch 了一次,并未被删除,在第 33 分钟时应当被 redis 删除,但可能存在延时,这个时候 touch 一次,确保删除。

所以,源码里面特别强调了一下:要用 touch 去触发 key 的删除,而不能直接 del key。

参考 https://github.com/spring-projects/spring-session/issues/93

C 轮改造—增加 C 类型键完善过期通知事件

虽然引入了 B 类型键,并且在后台加了定时器去确保 session 的过期,但似乎…emmmmm…还是不够完善。在此之前,kirito-session 的设计方案中,存储 session 实际内容的 A 类型键和用于定时器确保删除的桶 B 类型键过期时间都是 30 分钟(key 的 TTL 是 30 分钟),注意一个细节,spring-session 中 A 类型键的过期时间是 35 分钟,比实际的 30 分钟多了 5 分钟,这意味着即便 session 已经过期,我们还是可以在 redis 中有 5 分钟间隔来操作过期的 session。于此同时,spring-session 引入了 C 类型键来作为 session 的引用。

解释下之前卖的第二个关子,C 类型键的组成为前缀+”sessions:expires”+sessionId,对应一个空值,同时也是 B 类型键桶中存放的 session 引用,ttl 为 30 分钟,具体作用便是在自身过期后触发 redis 的 keyspace notifications (http://redis.io/topics/notifications),具体如何监听 redis 的过期事件简单介绍下:org.springframework.session.data.redis.config.ConfigureNotifyKeyspaceEventsAction 该类配置了相关的过期监听,并使用 SessionExpiredEvent 事件发放 session 的过期事件。为什么引入 C 类型键?keyspace notifications 只会告诉我们哪个键过期了,不会告诉我们内容是什么。关键就在于如果 session 过期后监听器可能想要访问 session 的具体内容,然而自身都过期了,还怎么获取内容。所以,C 类型键存在的意义便是解耦 session 的存储和 session 的过期,并且使得 server 获取到过期通知后可以访问到 session 真实的值。对于用户来说,C 类型键过期后,意味着登录失效,而对于服务端而言,真正的过期其实是 A 类型键过期,这中间会有 5 分钟的误差。

一点点想法,担忧,疑惑

本文大概介绍了 Spring Session 的三种 key 的原因,理清楚其中的逻辑花了不少时间,项目改造正好涉及到相关的缓存值过期这一需求,完全可以参考 Spring Session 的方案。但担忧也是有的,如果真的只是 1~2 两分钟的延迟过期(对应 A 轮改造中遇到的问题),以及 1 分钟的提前删除(对应 B 轮改造中的并发问题)其实个人感觉没必要计较。从产品体验上来说,用户应该不会在意 32 分钟自动退出和 30 分钟退出,可以说 Spring Session 是为了严谨而设计了这一套方案,但引入了定时器和很多辅助的键值对,无疑对内存消耗和 cpu 消耗都是一种浪费。如果在生产环境大量使用 Spring Session,最好权衡下本文提及的相关问题。

分享到 评论

八幅漫画理解使用JSON Web Token设计单点登录系统

转载自:http://blog.leapoahead.com/2015/09/06/understanding-jwt/

作者:John Wu

###博主前言

这篇转载的文章和上一篇《JSON Web Token - 在Web应用间安全地传递信息》文章均为转载,是我个人在研究 jwt 时浏览下来发现的两篇质量比较高的文章,所以分享给大家。个人对于 jwt 使用场景的理解,包括微信公众号留言中的提问,我都会在下一篇文章中来聊一聊。实际上使用 jwt 设计单点登录系统存在诸多的问题,很多有经验的工程师比较抵制用 jwt 做会话和所谓的单点登录系统,但不妨碍大家作为一个知识点去学习。

以下是原文

上次在《JSON Web Token - 在Web应用间安全地传递信息》中我提到了JSON Web Token可以用来设计单点登录系统。我尝试用八幅漫画先让大家理解如何设计正常的用户认证系统,然后再延伸到单点登录系统。

如果还没有阅读《JSON Web Token - 在Web应用间安全地传递信息》,我强烈建议你花十分钟阅读它,理解JWT的生成过程和原理。

用户认证八步走

所谓用户认证(Authentication),就是让用户登录,并且在接下来的一段时间内让用户访问网站时可以使用其账户,而不需要再次登录的机制。

小知识:可别把用户认证和用户授权(Authorization)搞混了。用户授权指的是规定并允许用户使用自己的权限,例如发布帖子、管理站点等。

首先,服务器应用(下面简称“应用”)让用户通过Web表单将自己的用户名和密码发送到服务器的接口。这一过程一般是一个HTTP POST请求。建议的方式是通过SSL加密的传输(https协议),从而避免敏感信息被嗅探。

auth1

接下来,应用和数据库核对用户名和密码。

auth2

核对用户名和密码成功后,应用将用户的id(图中的user_id)作为JWT Payload的一个属性,将其与头部分别进行Base64编码拼接后签名,形成一个JWT。这里的JWT就是一个形同lll.zzz.xxx的字符串。

auth3

应用将JWT字符串作为该请求Cookie的一部分返回给用户。注意,在这里必须使用HttpOnly属性来防止Cookie被JavaScript读取,从而避免跨站脚本攻击(XSS攻击)

auth4

在Cookie失效或者被删除前,用户每次访问应用,应用都会接受到含有jwt的Cookie。从而应用就可以将JWT从请求中提取出来。

auth5

应用通过一系列任务检查JWT的有效性。例如,检查签名是否正确;检查Token是否过期;检查Token的接收方是否是自己(可选)。

auth6

应用在确认JWT有效之后,JWT进行Base64解码(可能在上一步中已经完成),然后在Payload中读取用户的id值,也就是user_id属性。这里用户的id为1025。

应用从数据库取到id为1025的用户的信息,加载到内存中,进行ORM之类的一系列底层逻辑初始化。

auth7

应用根据用户请求进行响应。

auth8

和Session方式存储id的差异

Session方式存储用户id的最大弊病在于要占用大量服务器内存,对于较大型应用而言可能还要保存许多的状态。一般而言,大型应用还需要借助一些KV数据库和一系列缓存机制来实现Session的存储。

而JWT方式将用户状态分散到了客户端中,可以明显减轻服务端的内存压力。除了用户id之外,还可以存储其他的和用户相关的信息,例如该用户是否是管理员、用户所在的分桶(见[《你所应该知道的A/B测试基础》一文]等。

虽说JWT方式让服务器有一些计算压力(例如加密、编码和解码),但是这些压力相比磁盘I/O而言或许是半斤八两。具体是否采用,需要在不同场景下用数据说话。

单点登录

Session方式来存储用户id,一开始用户的Session只会存储在一台服务器上。对于有多个子域名的站点,每个子域名至少会对应一台不同的服务器,例如:

所以如果要实现在login.taobao.com登录后,在其他的子域名下依然可以取到Session,这要求我们在多台服务器上同步Session。

使用JWT的方式则没有这个问题的存在,因为用户的状态已经被传送到了客户端。因此,我们只需要将含有JWT的Cookie的domain设置为顶级域名即可,例如

1
Set-Cookie: jwt=lll.zzz.xxx; HttpOnly; max-age=980000; domain=.taobao.com

注意domain必须设置为一个点加顶级域名,即.taobao.com。这样,taobao.com和*.taobao.com就都可以接受到这个Cookie,并获取JWT了。

分享到 评论

JSON Web Token - 在Web应用间安全地传递信息

转载自:http://blog.leapoahead.com/2015/09/06/understanding-jwt/

作者:John Wu

JSON Web Token(JWT)是一个非常轻巧的规范。这个规范允许我们使用JWT在用户和服务器之间传递安全可靠的信息。

让我们来假想一下一个场景。在A用户关注了B用户的时候,系统发邮件给B用户,并且附有一个链接“点此关注A用户”。链接的地址可以是这样的

1
https://your.awesome-app.com/make-friend/?from_user=B&target_user=A

上面的URL主要通过URL来描述这个当然这样做有一个弊端,那就是要求用户B用户是一定要先登录的。可不可以简化这个流程,让B用户不用登录就可以完成这个操作。JWT就允许我们做到这点。

jwt

JWT的组成

一个JWT实际上就是一个字符串,它由三部分组成,头部载荷签名

载荷(Payload)

我们先将上面的添加好友的操作描述成一个JSON对象。其中添加了一些其他的信息,帮助今后收到这个JWT的服务器理解这个JWT。

1
2
3
4
5
6
7
8
9
{
"iss": "John Wu JWT",
"iat": 1441593502,
"exp": 1441594722,
"aud": "www.example.com",
"sub": "jrocket@example.com",
"from_user": "B",
"target_user": "A"
}

这里面的前五个字段都是由JWT的标准所定义的。

  • iss: 该JWT的签发者
  • sub: 该JWT所面向的用户
  • aud: 接收该JWT的一方
  • exp(expires): 什么时候过期,这里是一个Unix时间戳
  • iat(issued at): 在什么时候签发的

这些定义都可以在标准中找到。

将上面的JSON对象进行[base64编码]可以得到下面的字符串。这个字符串我们将它称作JWT的Payload(载荷)。

1
eyJpc3MiOiJKb2huIFd1IEpXVCIsImlhdCI6MTQ0MTU5MzUwMiwiZXhwIjoxNDQxNTk0NzIyLCJhdWQiOiJ3d3cuZXhhbXBsZS5jb20iLCJzdWIiOiJqcm9ja2V0QGV4YW1wbGUuY29tIiwiZnJvbV91c2VyIjoiQiIsInRhcmdldF91c2VyIjoiQSJ9

如果你使用Node.js,可以用Node.js的包base64url来得到这个字符串。

1
2
3
4
5
6
7
var base64url = require('base64url')
var header = {
"from_user": "B",
"target_user": "A"
}
console.log(base64url(JSON.stringify(header)))
// 输出:eyJpc3MiOiJKb2huIFd1IEpXVCIsImlhdCI6MTQ0MTU5MzUwMiwiZXhwIjoxNDQxNTk0NzIyLCJhdWQiOiJ3d3cuZXhhbXBsZS5jb20iLCJzdWIiOiJqcm9ja2V0QGV4YW1wbGUuY29tIiwiZnJvbV91c2VyIjoiQiIsInRhcmdldF91c2VyIjoiQSJ9

小知识:Base64是一种编码,也就是说,它是可以被翻译回原来的样子来的。它并不是一种加密过程。

头部(Header)

JWT还需要一个头部,头部用于描述关于该JWT的最基本的信息,例如其类型以及签名所用的算法等。这也可以被表示成一个JSON对象。

1
2
3
4
{
"typ": "JWT",
"alg": "HS256"
}

在这里,我们说明了这是一个JWT,并且我们所用的签名算法(后面会提到)是HS256算法。

对它也要进行Base64编码,之后的字符串就成了JWT的Header(头部)。

1
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
签名(签名)

将上面的两个编码后的字符串都用句号.连接在一起(头部在前),就形成了

1
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJmcm9tX3VzZXIiOiJCIiwidGFyZ2V0X3VzZXIiOiJBIn0

这一部分的过程在node-jws的源码中有体现

最后,我们将上面拼接完的字符串用HS256算法进行加密。在加密的时候,我们还需要提供一个密钥(secret)。如果我们用mystar作为密钥的话,那么就可以得到我们加密后的内容

1
rSWamyAYwuHCo7IFAgd1oRpSP7nzL7BF5t7ItqpKViM

这一部分又叫做签名

sig1

最后将这一部分签名也拼接在被签名的字符串后面,我们就得到了完整的JWT

1
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJmcm9tX3VzZXIiOiJCIiwidGFyZ2V0X3VzZXIiOiJBIn0.rSWamyAYwuHCo7IFAgd1oRpSP7nzL7BF5t7ItqpKViM

于是,我们就可以将邮件中的URL改成

1
https://your.awesome-app.com/make-friend/?jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJmcm9tX3VzZXIiOiJCIiwidGFyZ2V0X3VzZXIiOiJBIn0.rSWamyAYwuHCo7IFAgd1oRpSP7nzL7BF5t7ItqpKViM

这样就可以安全地完成添加好友的操作了!

且慢,我们一定会有一些问题:

  1. 签名的目的是什么?
  2. Base64是一种编码,是可逆的,那么我的信息不就被暴露了吗?

让我逐一为你说明。

签名的目的

最后一步签名的过程,实际上是对头部以及载荷内容进行签名。一般而言,加密算法对于不同的输入产生的输出总是不一样的。对于两个不同的输入,产生同样的输出的概率极其地小(有可能比我成世界首富的概率还小)。所以,我们就把“不一样的输入产生不一样的输出”当做必然事件来看待吧。

所以,如果有人对头部以及载荷的内容解码之后进行修改,再进行编码的话,那么新的头部和载荷的签名和之前的签名就将是不一样的。而且,如果不知道服务器加密的时候用的密钥的话,得出来的签名也一定会是不一样的。

sig2

服务器应用在接受到JWT后,会首先对头部和载荷的内容用同一算法再次签名。那么服务器应用是怎么知道我们用的是哪一种算法呢?别忘了,我们在JWT的头部中已经用alg字段指明了我们的加密算法了。

如果服务器应用对头部和载荷再次以同样方法签名之后发现,自己计算出来的签名和接受到的签名不一样,那么就说明这个Token的内容被别人动过的,我们应该拒绝这个Token,返回一个HTTP 401 Unauthorized响应。

信息会暴露?

是的。

所以,在JWT中,不应该在载荷里面加入任何敏感的数据。在上面的例子中,我们传输的是用户的User ID。这个值实际上不是什么敏感内容,一般情况下被知道也是安全的。

但是像密码这样的内容就不能被放在JWT中了。如果将用户的密码放在了JWT中,那么怀有恶意的第三方通过Base64解码就能很快地知道你的密码了。

JWT的适用场景

我们可以看到,JWT适合用于向Web应用传递一些非敏感信息。例如在上面提到的完成加好友的操作,还有诸如下订单的操作等等。

其实JWT还经常用于设计用户认证和授权系统,甚至实现Web应用的单点登录。

分享到 评论

Kong 集成 Jwt 插件

上一篇文章使用 Kong 完成了负载均衡的配置,本文介绍下在此基础上如何集成 jwt 插件来保护内部服务的安全。前置知识点:Jwt 基础概念。推荐阅读:

通俗易懂地介绍 Jwt https://blog.leapoahead.com/2015/09/06/understanding-jwt/

Jwt 的官网 https://jwt.io/

为 Kong 安装 Jwt 插件

Kong 官方提供了 Jwt 插件,可以对 某个 service 或者 route 添加 Jwt 认证,我以 service 为例介绍 Jwt 插件的使用

为 hello(上篇文章创建的 service)添加 Jwt 插件

1
curl -X POST http://localhost:8001/services/hello/plugins --data "name=jwt"

接着尝试访问这个受保护的服务

1
2
kirito$ curl http://localhost:8000/hello/hi
=> {"message":"Unauthorized"}

说明该 service 已经被 Jwt 保护起来了。

在 Kong 中创建用户

1
curl -X POST http://localhost:8001/consumers --data "username=kirito"

使用了新的端点 consumers 创建了一个名称为 kirito 的用户。

查看用户信息

1
curl http://127.0.0.1:8001/consumers/kirito/jwt

响应如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"total": 1,
"data": [
{
"created_at": 1523432449000,
"id": "cb01a6cf-7371-4f23-8193-fa69a0bb070c",
"algorithm": "HS256",
"key": "vcnvYSFzTIGyMxzKSgnNU0uvxixdYWB9",
"secret": "qQ9tSqIYjilnJmKuZXvJpgNo4ZqJDrim",
"consumer_id": "7d34e6bc-89ea-4f33-9346-9c10600e4afd"
}
]
}

重点关注三个值 algorithm,key,secret,他们和 Jwt 算法的参数密切相关

生成 Jwt

使用 jwt 官网(jwt.io)提供的 Debugger 功能可以很方便的生成 jwt。

jwt官网

HEADER 部分声明了验证方式为 JWT,加密算法为 HS256

PAYLOAD 部分原本有 5 个参数

1
2
3
4
5
6
7
{
"iss": "kirito",
"iat": 1441593502,
"exp": 1441594722,
"aud": "cnkirito.moe",
"sub": "250577914@qq.com",
}

这里面的前五个字段都是由 JWT 的标准(RFC7519)所定义的。

  • iss: 该 JWT 的签发者
  • sub: 该 JWT 所面向的用户
  • aud: 接收该 JWT 的一方
  • exp(expires): 什么时候过期,这里是一个 Unix 时间戳
  • iat(issued at): 在什么时候签发的

iss 这一参数在 Kong 的 Jwt 插件中对应的是

curl http://127.0.0.1:8001/consumers/kirito/jwt 获取的用户信息中的 key 值。

而其他值都可以不填写

最后还要一个没有用到的用户信息:secret。HS256 加密算法是对称加密算法,加密和解密都依赖于同一个密钥,在生成 Jwt 的消息签名时(Verify Signature)需要被使用到。

我们使用 jwt 官网(jwt.io)提供的 Debugger 功能快速生成我们的 Jwt

1
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ2Y252WVNGelRJR3lNeHpLU2duTlUwdXZ4aXhkWVdCOSJ9.3iL4sXgZyvRx2XtIe2X73yplfmSSu1WPGcvyhwq7TVE

由三个圆点分隔的长串便是用户身份的标识了

携带 Jwt 访问受限资源

1
2
kirito$ curl http://localhost:8000/hello/hi
=> {"message":"Unauthorized"}

在此之前直接访问 hello 服务是处于未验证状态

携带 Jwt 访问

1
2
curl http://localhost:8000/hello/hi -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ2Y252WVNGelRJR3lNeHpLU2duTlUwdXZ4aXhkWVdCOSJ9.3iL4sXgZyvRx2XtIe2X73yplfmSSu1WPGcvyhwq7TVE'
=> 3000

成功获取到了服务端的响应,Jwt 插件就这样正常工作了。

补充

  1. 可以指定生成的 key(对应 Jwt 中的 iss),和 secret
1
curl -X POST http://localhost:8001/consumers/kirito/jwt --data "secret=YmxvYiBkYXRh" --data "key=kirito"

如果想要修改 secret 和 key,经过目前笔者的尝试后,似乎只能够先删除,后新增。

  1. Jwt 也可以作为 QueryString 参数携带在 get 请求中
1
curl http://localhost:8000/hello/hi?jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ2Y252WVNGelRJR3lNeHpLU2duTlUwdXZ4aXhkWVdCOSJ9.3iL4sXgZyvRx2XtIe2X73yplfmSSu1WPGcvyhwq7TVE
  1. 通常用户需要自己写一个服务去帮助 Consumer 生成自己的 Jwt,自然不能总是依赖于 Jwt 官方的 Debugger,当然也没必要重复造轮子(尽管这并不难),可以考虑使用开源实现,比如 Java 中推荐使用 jjwt(https://github.com/jwtk/jjwt)
1
2
3
4
5
6
String jwt = Jwts.builder()
.setHeaderParam("typ","jwt")
.setHeaderParam("alg","HS256")
.setIssuer("kirito")
.signWith(SignatureAlgorithm.HS256, Base64.getEncoder().encodeToString("YmxvYiBkYXRh".getBytes(Charset.forName("utf-8"))))
.compact();
分享到 评论

初识 Kong 之负载均衡

使用 Kong Community Edition(社区版 v1.3.0)来搭建一个负载均衡器,由于 Kong 是基于 Openresty 的,而 Openresty 又是 Nginx 的二次封装,所有很多配置项和 Nginx 类似。

来看一个较为典型的 Nginx 负载均衡配置

1
2
3
4
5
6
7
8
9
10
11
upstream hello {
server localhost:3000 weight=100;
server localhost:3001 weight=50;
}

server {
listen 80;
location /hello {
proxy_pass http://hello;
}
}

nginx 监听来自本地 80 端口的请求,如果路径与 /hello 匹配,便将请求原封不动的转发到名称为 hello 的upstream,而该 upstream 我们配置了一个负载均衡器,会路由到本地的 3000 端口和 3001 端口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@SpringBootApplication
@RestController
public class KongDemoApplication {

public static void main(String[] args) {
System.setProperty("server.port","3000");
//System.setProperty("server.port","3001");
SpringApplication.run(KongDemoApplication.class, args);
}

@RequestMapping("/hi")
public String port(){
return System.getProperty("server.port");
}

}

启动两个 server 分别监听本地 3000 端口和 3001 端口。

如何你的机器已经安装好了 Kong,并对 Kong 的 admin api 有了基础的认识,接下来便可以针对 Kong 进行负载均衡的配置了。

配置 upstream 和 target

创建一个名称 hello 的 upstream

1
curl -X POST http://localhost:8001/upstreams --data "name=hello"

为 hello 添加两个负载均衡节点

1
curl -X POST http://localhost:8001/upstreams/hello/targets --data "target=localhost:3000" --data "weight=100"
1
curl -X POST http://localhost:8001/upstreams/hello/targets --data "target=localhost:3001" --data "weight=50"

如上的配置对应了 Nginx 的配置

1
2
3
4
upstream hello {
server localhost:3000 weight=100;
server localhost:3001 weight=50;
}

配置 service 和 route

使用老版本 Kong 的用户可能会接触过 api 这个概念,但是在 Kong v1.3.0 中,已经被废除了,取而代之的是 service 和 route 的配置。

配置一个 service

1
curl -X POST http://localhost:8001/services --data "name=hello" --data "host=hello"

host 的值便对应了 upstream 的名称,配置成功后会返回生成的 service 的 id,我的返回结果:8695cc65-16c1-43b1-95a1-5d30d0a50409

为上面的 service 配置路由信息

1
curl -X POST http://localhost:8001/routes --data "paths[]=/hello" --data "service.id=8695cc65-16c1-43b1-95a1-5d30d0a50409"

请求路径包含 /hello 的请求都会被转移到对应的 service 进行处理。

如上的配置便对应了

1
2
3
location /hello {
proxy_pass http://hello;
}

测试 Kong 的负载均衡

1
curl http://localhost:8000/hello/hi

因为复杂均衡的原因,需要多测试几次,多次 curl 之后结果如下:

1
2
3
4
5
6
7
8
9
10
11
3000
3000
3000
3000
3000
3000
3001
3001
3001
3000
3001

参考文档

https://getkong.org/docs/0.13.x/loadbalancing/

https://getkong.org/docs/0.13.x/configuration/

分享到 评论

技术精进的三境界

最近更新了一篇 Docker 的文章,朋友跟我反馈说效果并不是很好,我回头看了下,的确没有我自己的特色,没有太多思考,让公众号显得有些「百货」了。经过反思,今后只在个人博客更新 Docker 相关的个人学习经验(传送门),个人公众号还是主要推送和 Java 结合较为紧密的内容。

前言

前不久公众号后台有人给我留言,请教如何体系地学习 Java 知识,我当时心想:这话题太大,Java 技术栈也太深,不是一篇文章能说清楚的,但要说学习技巧,却的确是有规律所寻,秉持着「授人以鱼不如授人以渔」的想法,这篇文章便分享下我个人的一些学习技巧。

王国维在《人间词话》中提到了古今之成大事业、大学问者,必经的三种境界: “昨夜西风凋碧树,独上高楼,望尽天涯路。” 此第一境也。 “ 衣带渐宽终不悔,为伊消得人憔悴。” 此第二境也。 “ 众里寻他千百度蓦然回首,那人却在,灯火阑珊处。”此第三境也。我也按照我的理解,将精进技术分成了三个境界。

第一境:从 overview 和 guides 获取新知识的直观感受

善用搜索,推荐 google。google 第一页搜索出来权威内容的概率要大于 baidu,人很容易有先入为主的观念,前五篇文章对一个知识点的直观感受,就会形成固化映像。(技术精进的第一层关卡:你可能缺少一个梯子

以一个可能大多数读者都觉得陌生的知识点:图形数据库 Neo4j 作为引子,来介绍。

搜索 neo4j 关键词时大概率会搜索到官网,但作为一门技术的初学者,我更习惯于阅读下中文博客,教程,以对这个陌生的技术有一个宏观的了解。虽然大家都很赞同一个观点:英文文档更加权威,但是我个人还是觉得,中文教程更加直观,实际上我读中文文档的速度是英文文档的 2-3 倍。

w3cschool

不需要细读每个语法,我一般习惯看下概览,再看下目录对整体的知识点有个宏观的掌握。这种入门级的网站有很多,大多出现在搜索引擎的第一页。而官方的 overview 通常也是很适合入门的手段。官方网站

英文阅读能力强的读者可以直接上手官方的 guides,overview,get started 等入门教程,它们比中文教程强在很多方面(技术精进的第二层关卡:你可能需要一定的英语阅读能力):

  1. 避免了语言转换带来的翻译失真。很多中文教程读起来总觉得拗口,太过于书面化,语句也不符合中国人阅读的习惯。翻译烂的原因不需要我总结,事实上我对翻译这个事有过比较深的真实体验,我曾经和 spring4all 的小伙伴们一起对 spring 文档进行过翻译,大家积极性都很高,一周之类几乎所有的 guides 文档全部翻译完成了,但质量真的惨不忍睹,参与校对时,竟然有人将 「jar」翻译成「蜜罐」,而且不在少数。
  2. 官方文档维护着最新的版本。对于一些热门的软件技术,比如我最近接触的 docker,之前研究过得 lucene,它们都一些共同点:版本差异非常大,版本更迭速度快。翻看 docker 在国内零散的博客,大多数比最新的 docker 版本落后 3 个大版本以上;而像 lucene 这种大版本演进非常迅速的技术(大版本演进意味着不兼容老版本),api 几乎是翻天覆地的变化。
  3. 权威性。

英语能力要求并不是很高,大学英语 4 级足矣,毕竟有个东西叫谷歌翻译

再比如 spring 体系的知识点,有一个通用的学习路线。还是以 neo4j 为例,得知 spring 对 neo4j 有二次封装之后,便熟练地来到了 spring 的 guides 专栏(https://spring.io/guides),spring 对所有的知识点提供了两个维度的学习文档,其中 https://spring.io/guides 一般都是一个 15 分钟上手的 hello world,让你快速上手一门新的技术;另一个是较为完善的文档:https://spring.io/docs/reference,成体系地介绍技术细节。第一阶段主要关注前者,一般它长这样:

guides

敲完 hello world,一般就可以对应简历上「了解 XXXX」的描述了(斜眼笑。

第二境:官方文档与社区

大多数时候,一个停留在 hello world 认知级别的知识点对于我们的帮助不会很大,你甚至连在群里装逼的机会都没有!恐怖如斯!

了解完毕这门技术是用来解决什么问题的,它大概是怎么使用的,有了这些直观体验之后再来看文档,会直观不少。下面主要介绍个人阅读官方文档的一些心路历程。

首先来看看宇宙级开源项目 spring 的官方文档,它长这样:

mage-20180408153613

琳琅满目的项目,所有与 spring 相关的技术都可以在这儿获取最权威的解读,怎么学习 springboot,springcloud 还需要问吗?平时经常接触 spring 的同学如果这个页面都没见过,私以为在学习认知上是有所欠缺的。

refrence

文档除了主体知识内容之外还有整体介绍,新旧版本迭代的改动,新特性,依赖分析,性能测试等,文档内容一般都是非常多的,可以撷取其中核心的几节,将主要用法和注意事项掌握,至于不常用的特性,可以在碰到时再翻阅。

曾经一个项目需求中涉及到 spring security 的改造,而相关的文档又比较少,我至少通读过 5 遍 spring security 完整的文档,从一开始的大概了解,到最后的基本掌握,很多细节点一开始无法 get 到,读多了之后很多常用词汇和语感都会提升,细节会被消化,整体阅读速度也会随着文档阅读量提高而提升(比如 out-of-box 这个高频词一开始是不知道什么意思,谷歌翻译也翻不出来,后来得知是「开箱即用」)。

开源社区也是精进一门技术的重要途径,比如 spring4all,k8s,netty,elk 社区汇聚了不少文章和问答,初中高级的使用者都在社区中扮演着各自的角色(部分中文社区甚至样式都差不多,估计是用同一套开源代码搭建的[捂脸]),社区活跃度也是一门技术火热的衡量指标。经常被大家调侃的面向 github 编程背后的 github,以及 Stack Overflow,都是质量比较硬的社区。

第三境:源码阅读

技术精进的第三层关卡:对源码的恐惧阻止了一个人探索的步伐)如果你觉得看完前面的文字是在说废话,那么这一节可能稍微能勾起你的兴趣,权当之前是一个铺垫,照顾下一些初学者。

在交流群里面发现的一个现象,很多人对源码有一种天生的畏惧感,诸如:“我才刚毕业/我才工作三年,还没到看源码的阶段”,“源码不是架构师看的吗”,“源码看不懂“,”看看别人的源码分析不就行了“…这一节主要聊聊源码阅读技巧,以及一些个人对阅读源码的感悟。

首先澄清几点:阅读源码绝对和工作年限无关;阅读源码绝对和工作职位无关;大多数源码并不是很难;debug+源码分析绝对比看文章来的直观。

1 源码中的测试用例

还是以 neo4j 为例,我们在 github 找到 spring-data-neo4j 的源码,然后 git clone 到本地,在本地 idea 中打开。

test

和源码相关的第一点介绍的便是源码中的测试用例,对于大多数的开源项目而言,测试覆盖率是一个质量衡量的指标。大部分 Java 相关开源项目会包含测试用例,项目的一些功能特性可能在文档中无法一一介绍,通常可以在 src/test/java 中找到对应的用法,比如 neo4j 是怎么支持事务的,怎么维护边和边的关系的,在测试用例中都可以看到官方是怎么使用的。再举个例子,之前在使用 orika 这个拷贝工具时,一开始不知道怎么实现泛型的拷贝(泛型的运行时擦除特性),谷歌搜索和 Stack Overflow 提问无果之后,终于在源码的诸多测试用例中找到了我需要的代码。

2 通过核心接口/包结构分析框架层次结构

我猜测有人拒绝阅读源码的一个原因:源码注释量不够,压根不知道一段代码是干嘛用的。的确,我在阅读有些源码时也会出现这样的情况:这儿会什么要加锁?为什么要用 AtomicReference 这个类?为什么这个方法放在父类,而另外看似功能差不多的代码放在子类实现?框架编写者不会像培训班的老师一样跟你讲解他为什么要这么写,也不是所有的源码都能像 HashMap 的源码那样被大家泛滥地解读,是的,可能大多数情况下你的境地是「虽然不懂,还没法问!」气不气,尴不尴尬?没办法,因为这已经是第三境了,曲高和寡,但还是有些方法规避这样的情况的,那就是:主要关心核心接口,通过接口暴露的方法,猜测出作者的意图。据我不多的源码阅读经验,一个实现类的注释可能不多,但接口的注释通常会很多,毕竟一个原则是面向接口编程。

spring security

上图是我分析 spring security 源码时,根据接口间的关系整理出来的 UML 类图,对于绿色实现类的细节我可能并不是特别关注,浅蓝色代表的接口才是我们理解整个架构体系的切入点,配合 idea 这些优秀的集成开发环境,可以很方便的整理出 UML 类图。接口是全局架构,实现类是源码细节。

顺带一提:熟练使用 IDE 很重要。无论你是 eclipse 玩家还是 intellij idea 玩家,你都应该熟练掌握快捷键和一些常用操作,方便你阅读源码。比如 idea 右键可以自动生成类的继承关系图(聚合关系的体现不够智能),方便分析层次关系;显示方法 outline 快速查看一个类的方法概览,在成片的源码中非常有用;快速定位一个接口的实现类,一个类的子类等等。

再比如我在阅读 motan 这款 rpc 框架源码时遵循的顺序是其包结构的层次关系。

motan

写源码分析文章时基本就是按照一个包一篇文章来分析,从而化繁为简。无论是模块结构,包结构,还是代码层面的接口结构,重点都是在强调:我们需要从宏观掌握一个框架,再去扣细节,否则我个人感觉学习状态就是很迷,不知道学到哪儿了。

3 带着问题阅读实现类源码

具体的实现类源码真的没什么技巧可讲,一定是看一个人的写代码功底,以及代码敏感度了,非要说技巧的话,可能就是多写代码,培养代码敏感度了。此外,带着问题去读源码个人体验下来感觉不错,在阅读 spring security 源码时,我带着的问题是,怎么结合 zuul 实现动态的权限控制,一步步地 debug,看它原来的实现,之后是改源码,debug 看改变的效果。

具体的源码的阅读难度也是参差不齐的,个人学习经历中发现 motan 的源码就很容易阅读,spring 的源码因为文档比较齐全,阅读体验也很好,但 lucene 的源码和 hibernate 的源码,我也尝试阅读过,简直是天书,又比如说 netty,单单熟练使用它就已很难,何论源码。一方面跟个人阅历有关,一方面跟框架实现难度有关,很难盖棺定论得出方法论。个人建议是,明确自己需要解决什么问题去阅读源码,不必为了装逼而读源码。

贴下之前几个系列的源码解读链接:

【RPC 系列】 https://www.cnkirito.moe/categories/RPC/

【Spring Security 系列】https://www.cnkirito.moe/categories/Spring-Security/

【OAuth2 系列】https://www.cnkirito.moe/categories/Spring-Security-OAuth2/

三境之外

除了学习技术的三种境界,还有一些其他个人的感悟。比如类比学习法,一开始学习 spring-data-jpa 时效率比较慢,这对于我是一个比较新的技术,但当我后来再接触 spring-data-redis,spring-data-neo4j 时,虽然同样是第一次接触这些数据访问层,但有了之前 spring-data-jpa 的参考,可以说是事半功倍。关于视频,博客,书,文档可以说关系很微妙,从视频到文档,越来越不直观,但学习效率越来越高,这些没有高低贵贱之分,私以为都是很好的学习方法。怎么提升代码技巧?说真的方法论归方法论,重点还是代码行数锻炼出来的代码敏感度,这是看书,看代码,写博客,看方法论学不来的,不多说了,滚去写代码了[抱拳]。

分享到 评论

Docker Network—Bridge 模式

概述

Docker 强大的原因之一在于多个 Docker 容器之间的互相连接。涉及到连接,就引出了网络通信的几种模式。Docker 默认提供了 5 种网络驱动模式。

  • bridge: 默认的网络驱动模式。如果不指定驱动程序,bridge 便会作为默认的网络驱动模式。当应用程序运行在需要通信的独立容器(standalone containers)中时,通常会选择 bridge 模式。
  • host:移除容器和 Docker 宿主机之间的网络隔离,并直接使用主机的网络。host 模式仅适用于 Docker 17.06+。
  • overlay:overlay 网络将多个 Docker 守护进程连接在一起,并使集群服务能够相互通信。您还可以使用 overlay 网络来实现 swarm 集群和独立容器之间的通信,或者不同 Docker 守护进程上的两个独立容器之间的通信。该策略实现了在这些容器之间进行操作系统级别路由的需求。
  • macvlan:Macvlan 网络允许为容器分配 MAC 地址,使其显示为网络上的物理设备。 Docker 守护进程通过其 MAC 地址将流量路由到容器。对于希望直连到物理网络的传统应用程序而言,使用 macvlan 模式一般是最佳选择,而不应该通过 Docker 宿主机的网络进行路由。
  • none:对于此容器,禁用所有联网。通常与自定义网络驱动程序一起使用。none 模式不适用于集群服务。

通过在 Docker 上安装和使用第三方网络插件可以算作额外的扩展方式。

默认网络

1
2
3
4
5
kiritodeMacBook-Pro:~ kirito$ docker network ls
NETWORK ID NAME DRIVER SCOPE
15315759c263 bridge bridge local
d72064d9febf host host local
83ea989d3fec none null local

这 3 个网络包含在 Docker 实现中。运行一个容器时,可以使用 –network 参数指定在哪种网络模式下运行该容器。

这篇文章重点介绍 bridge 模式。 所有 Docker 安装后都存在的 docker0 网络,这在 Docker 基础中有过介绍。除非使用 docker run –network=选项另行指定,否则 Docker 守护进程默认情况下会将容器连接到 docker0 这个网络。

创建自定义的网络

使用如下命令就可以创建一个名称为 my-net ,网络驱动模式为 bridge 的自定义网络。

1
$ docker network create my-net

再次查看存在的网络可以发现上述命令执行之后产生的变化:

1
2
3
4
5
6
kiritodeMacBook-Pro:~ kirito$ docker network ls
NETWORK ID NAME DRIVER SCOPE
15315759c263 bridge bridge local
d72064d9febf host host local
73e32007f19f my-net bridge local
83ea989d3fec none null local

使用 busybox 测试容器连通性

BusyBox 是一个集成了一百多个最常用 Linux 命令和工具(如 cat、echo、grep、mount、telnet 、ping、ifconfig 等)的精简工具箱,它只需要几 MB 的大小,很方便进行各种快速验证,被誉为“Linux 系统的瑞士军刀”。

我们使用 busybox 来测试容器间的网络情况。(一开始我尝试使用 ubuntu 作为基础镜像来构建测试容器,但 ubuntu 镜像删减了几乎所有的常用工具,连同 ping,ifconfig 等命令都需要额外安装软件,而 busybox 则不存在这些问题。)

使用默认网桥 docker0

1
2
3
4
kiritodeMacBook-Pro:~ kirito$ docker run --name box1 -it --rm busybox sh
/ # ifconfig
eth0 Link encap:Ethernet HWaddr 02:42:AC:11:00:07
inet addr:172.17.0.7 Bcast:172.17.255.255 Mask:255.255.0.0

—rm 指令可以让我们在退出容器时自动销毁该容器,这样便于测试。查看自身的 ip 为 172.17.0.7,接下来创建第二个容器 box2。

1
2
3
4
kiritodeMacBook-Pro:~ kirito$ docker run --name box2 -it --rm busybox sh
/ # ifconfig
eth0 Link encap:Ethernet HWaddr 02:42:AC:11:00:08
inet addr:172.17.0.8 Bcast:172.17.255.255 Mask:255.255.0.0

查看自身的 ip 为 172.17.0.8。

在 box2 中执行 ping 命令测试与 box1 的连通性:

1
2
3
4
5
6
7
8
9
10
11
使用 IP
/ # ping 172.17.0.8
PING 172.17.0.8 (172.17.0.8): 56 data bytes
64 bytes from 172.17.0.8: seq=0 ttl=64 time=0.107 ms
64 bytes from 172.17.0.8: seq=1 ttl=64 time=0.116 ms
64 bytes from 172.17.0.8: seq=2 ttl=64 time=0.114 ms
64 bytes from 172.17.0.8: seq=3 ttl=64 time=0.126 ms

使用容器名称
/ # ping box1
无响应

我们发现使用默认网桥 docker0 的桥接模式下,ip 是通的,但是无法使用容器名作为通信的 host。

使用自定义网桥 my-net

1
2
3
4
kiritodeMacBook-Pro:~ kirito$ docker run --name box3 -it --rm  --network my-net busybox sh
/ # ifconfig
eth0 Link encap:Ethernet HWaddr 02:42:AC:15:00:02
inet addr:172.21.0.2 Bcast:172.21.255.255 Mask:255.255.0.0

使用 —network 指定使用的网络模式,my-net 便是在此之前我们通过 docker network create 命令新创建的网络。新启动一个 shell 创建 box4

1
2
3
4
kiritodeMacBook-Pro:~ kirito$ docker run -it --name box4 --rm --network my-net busybox sh
/ # ifconfig
eth0 Link encap:Ethernet HWaddr 02:42:AC:15:00:03
inet addr:172.21.0.3 Bcast:172.21.255.255 Mask:255.255.0.0

在 box4 中执行 ping 命令测试与 box3 的连通性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
使用 IP
/ # ping 172.21.0.2
PING 172.21.0.2 (172.21.0.2): 56 data bytes
64 bytes from 172.21.0.2: seq=0 ttl=64 time=0.203 ms
64 bytes from 172.21.0.2: seq=1 ttl=64 time=0.167 ms
64 bytes from 172.21.0.2: seq=2 ttl=64 time=0.169 ms
64 bytes from 172.21.0.2: seq=3 ttl=64 time=0.167 ms

使用容器名称
/ # ping box3
PING box3 (172.21.0.2): 56 data bytes
64 bytes from 172.21.0.2: seq=0 ttl=64 time=0.229 ms
64 bytes from 172.21.0.2: seq=1 ttl=64 time=0.170 ms
64 bytes from 172.21.0.2: seq=2 ttl=64 time=0.165 ms
64 bytes from 172.21.0.2: seq=3 ttl=64 time=0.168 ms

与默认的网络 docker0 不同的是,指定了自定义 network 的容器可以使用容器名称相互通信,实际上这也是 docker 官方推荐使用 —network 参数运行容器的原因之一。

对比自定义 bridge(my-net)与默认 bridge(docker0)

自定义 bridge 提供更好的隔离性和容器间的互操作性

连接到同一个自定义 bridge 网络的容器会自动将所有端口相互暴露,并且无法连接到容器之外的网络。这使得容器化的应用能轻松地相互通信,并且与外部环境产生了良好的隔离性。

例如一个包含了 web 应用,数据库,redis 等组件的应用程序。很有可能只希望对外界暴露 80 端口,而不允许外界访问数据库端口和 redis 端口,而又不至于让 web 应用本身无法访问数据库和 redis, 便可以使用自定义 bridge 网络轻松实现。如果在默认 bridge 网络上运行相同的应用程序,则需要使用 -p 或 —publish 标志打开 web 端口,数据库端口,redis 端口。这意味着 Docker 宿主机需要通过其他方式阻止对数据库端口,redis 端口的访问,无意增大了工作量。

自定义 bridge 提供容器间的自动 DNS 解析

这一点在上一节的实验中已经验证过了。默认 bridge 网络上的容器只能通过 IP 地址互相访问,除非使用在 docker run 时添加 —link 参数。这么做个人认为有两点不好的地方:

一:容器关系只要稍微复杂一些,便会对管理产生不便。

二: —link 参数在官方文档中已经被标记为过期的参数,不被建议使用。

在用户定义的桥接网络上,容器可以通过容器名称(--name 指定的名称)或别名来解析对方。可能有人说,在默认 bridge 模式下我可以去修改 /etc/hosts文件呀,但这显然不是合理的做法。

容器可以在运行中与自定义 bridge 网络连接和分离

在容器的生命周期中,可以在运行中将其与自定义网络连接或断开连接。 而要从默认 bridge 网络中移除容器,则需要停止容器并使用不同的网络选项重新创建容器。

每个自定义的 bridge 网络都会创建一个可配置的网桥

如果容器使用默认 bridge 网络,虽然可以对其进行配置,但所有容器都使用相同的默认设置,例如 MTU 和防火墙规则。另外,配置默认 bridge 网络隔离于 Docker 本身之外,并且需要重新启动 Docker 才可以生效。

自定义的 bridge 是使用 docker network create 创建和配置的。如果不同的应用程序组具有不同的网络要求,则可以在创建时分别配置每个用户定义的 bridge 网络,这无疑增加了灵活性和可控性。

使用默认 bridge 容器共享所有的环境变量

在 Docker 的旧版本中,两个容器之间共享环境变量的唯一方法是使用 —link 标志来进行链接。这种类型的变量共享对于自定义的网络是不存在的。但是,自定义网络有更好方式来实现共享环境变量:

  • 多个容器可以使用 Docker 卷来挂载包含共享信息的文件或目录。
  • 多个容器可以使用 docker-compose 一起启动,并且 docker-compose.yml 文件可以定义共享变量。
  • 使用集群服务而不是独立容器,并利用共享密钥和配置。

结合上述这些论述和官方文档的建议,使用 bridge 网络驱动模式时,最好添加使用 —network 来指定自定义的网络。

参考资料

https://docs.docker.com/network/bridge/#connect-a-container-to-the-default-bridge-network

https://www.ibm.com/developerworks/cn/linux/l-docker-network/index.html

分享到 评论

JAVA 拾遗--eqauls 和 hashCode 方法

缘起—lombok 引发的惨案

Lombok 是一种 Java™ 实用工具,可用于帮助开发人员消除 Java 的冗长,尤其是对于简单的 Java 对象(POJO)。它通过注解实现这一目的。

最近一个新项目中开始使用了 lombok,由于其真的是太简单易懂了,以至于我连文档都没看,直接就上手使用了,引发了一桩惨案。

实体类定义

1
2
3
4
5
6
@Data
public class Project {
private Long id;
private String projectName;
private List<Project> projects;
}

我在项目中设计了一个 Project 类,其包含了一个 List projects 属性,表达了项目间的依赖关系。@Data 便是 Lombok 提供的常用注解,我的本意是使用它来自动生成 getter/setter 方法。这样的实体类定义再简单不过了。

意外出现

使用 Project 类表达项目间的依赖关系是我的初衷,具体的分析步骤不在此赘述,对 Project 类的操作主要包括创建,打印,保存几个简单操作。运行初期,一切看似风平浪静,但经过长时间运行后,我意外的获得了如下的异常:

1
2
3
Exception in thread "Tmoe.cnkirito.dependency0" java.lang.StackOverflowError
at moe.cnkirito.dependency.model.Project.hashCode(Project.java:20)
at java.util.AbstracList.hashCode(AbstractList.java:541)

这让我感到很意外,我并没有对 Project 类进行什么复杂的操作,也没有进行什么递归操作,怎么会得到 StackOverflowError 这个错误呢?更令我百思不得其解的地方在于,怎么报错的日志中还出现了 hashCode 和 AbstractList 这两个家伙?等等…hashCode…emmmmm…我压根没有重写过它啊,怎么可能会报错呢….再想了想 Lombok 的 @Data 注解,我似乎发现了什么…emmmmm…抱着怀疑的态度翻阅了下 Lombok 的文档,看到了如下的介绍

@Data is a convenient shortcut annotation that bundles the features of @ToString, @EqualsAndHashCode, @Getter / @Setter and @RequiredArgsConstructor together: In other words, @Data generates all the boilerplate that is normally associated with simple POJOs (Plain Old Java Objects) and beans: getters for all fields, setters for all non-final fields, and appropriate toString, equals and hashCode implementations that involve the fields of the class, and a constructor that initializes all final fields, as well as all non-final fields with no initializer that have been marked with @NonNull, in order to ensure the field is never null.

原来 @Data 注解不仅帮我们实现了生成了@Getter / @Setter 注解,还包含了@ToString, @EqualsAndHashCode, 和 @RequiredArgsConstructor 注解,这其中的 @EqualsAndHashCode 注解似乎和我这次的惨案密切相关了。顺藤摸瓜,看看 @EqualsAndHashCode 的文档:

Any class definition may be annotated with @EqualsAndHashCode to let lombok generate implementations of the equals(Object other) and hashCode() methods. By default, it’ll use all non-static, non-transient fields

@EqualsAndHashCode 会自动生成 equals(Object other)hashCode() 两个方法,默认会使用所有非静态,非瞬时状态的字段。

回到我的案例中,也就是说,Lombok 会将 Project 类中的 List projects 当做是 hashCode 计算的一部分(同理,equals,toString 也会存在同样的问题),而如果我的项目中出现循环引用,这就会导致死循环,最终就会抛出 StackOverFlowError。

为了验证我的想法,简化的项目中的代码后,来测试下

1
2
3
4
5
6
7
8
public String testHashCode(){
Project project = new Project();
Project other = new Project();
other.setProjects(Arrays.asList(project));
project.setProjects(Arrays.asList(other));
System.out.println(project.hashCode());
return "success";
}

调用该代码后,复现了上述的异常。

1
2
3
Exception in thread "Tmoe.cnkirito.dependency0" java.lang.StackOverflowError
at moe.cnkirito.dependency.model.Project.hashCode(Project.java:20)
at java.util.AbstracList.hashCode(AbstractList.java:541)

紧接着,继续测试下 toString 和 eqauls 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
## 测试循环引用实体类中下的 toString 方法
java.lang.StackOverflowError: null
at java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:125) ~[na:1.8.0_161]
at java.lang.AbstractStringBuilder.appendNull(AbstractStringBuilder.java:493) ~[na:1.8.0_161]
at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:446) ~[na:1.8.0_161]
at java.lang.StringBuilder.append(StringBuilder.java:136) ~[na:1.8.0_161]
at com.qianmi.dependency.model.Project.toString(Project.java:18) ~[classes/:na]
## 测试循环引用实体类中下的 equals 方法
java.lang.StackOverflowError: null
at java.util.AbstractList.rangeCheckForAdd(AbstractList.java:604) ~[na:1.8.0_161]
at java.util.AbstractList.listIterator(AbstractList.java:325) ~[na:1.8.0_161]
at java.util.AbstractList.listIterator(AbstractList.java:299) ~[na:1.8.0_161]
at java.util.AbstractList.equals(AbstractList.java:518) ~[na:1.8.0_161]
at com.qianmi.dependency.model.Project.equals(Project.java:18) ~[classes/:na]

不出所料,都存在同样的问题。

这一案例可以稍微总结下,一是在使用新的技术框架(Lombok)之前没有看文档,对其特性不太了解,望文生义,认为 @Data 不会重写 hashCode 等方法,二是没有考虑到 hashCode,eqauls 等方法应该如何正确地覆盖。

回顾 JAVA 中最基础的方法: hashCode 和 equals

这两个方法说是 JAVA 最基础的方法一点不为过,但往往越基础的东西越容易被人忽视,让我想起了 JAVA 闲聊群中一位长者经常吐槽的一点:『现在的面试、群聊动不动就是高并发,JVM,中间件,却把基础给遗忘了』。 我感觉很幸运,在当初刚学 JAVA 时,便接触了一本神书《effective java》,一本号称怎么夸都不为过的书,它的序是这么写的

我很希望10年前就拥有这本书。可能有人认为我不需要任何Java方面的书籍,但是我需要这本书。

——Java 之父 James Gosling

其书中的第三章第 8 条,第 9 条阐述了 equals 和 hashCode 的一些重写原则,我将一些理论言简意赅的阐述在本节中,喜欢的话推荐去看原书哦。

第8条:覆盖equals时请遵守通用约定

什么时候应该覆盖Object.equals呢?如果类具有自己特有的“逻辑相等”概念(不同于对象等同的概念),而且超类还没有覆盖equals以实现期望的行为,这时我们就需要覆盖equals方法。这通常属于“值类(value class)”的情形。值类仅仅是一个表示值的类,例如Integer或者Date。程序员在利用equals方法来比较值对象的引用时,希望知道它们在逻辑上是否相等,而不是想了解它们是否指向同一个对象。为了满足程序员的要求,不仅必需覆盖equals方法,而且这样做也使得这个类的实例可以被用作映射表(map)的键(key),或者集合(set)的元素,使映射或者集合表现出预期的行为。

在覆盖equals方法的时候,你必须要遵守它的通用约定。下面是约定的内容,来自Object的规范[JavaSE6]:

equals方法实现了等价关系(equivalence relation)

  • 自反性(reflexive)。对于任何非null的引用值xx.equals(x)必须返回true
  • 对称性(symmetric)。对于任何非null的引用值xy,当且仅当y.equals(x)返回true时,x.equals(y)必须返回true
  • 传递性(transitive)。对于任何非null的引用值xyz。如果x.equals(y)返回true,并且y.equals(z)也返回true,那么x.equals(z)也必须返回true
  • 一致性(consistent)。对于任何非null的引用值xy,只要equals的比较操作在对象中所用的信息没有被修改,多次调用x.equals(x)就会一致地返回true,或者一致的返回false
  • 对于任何非null的引用值xx.equals(null)必须返回false

学过高数,离散的同学不会对上述的理论陌生,它们源自于数学理论,没了解过这些概念的同学也不必有所顾忌,因为你只需要养成习惯,在设计一个实体类时时刻惦记着上述几个关系,能符合的话大概就没有问题。结合所有这些要求,得出了以下实现高质量equals方法的诀窍:

  1. 使用==操作符检查“参数是否为这个对象的引用”。如果是,则返回true。这只不过是一种性能优化,如果比较操作有可能很昂贵,就值得这么做。

  2. 使用 instanceof 操作符检查“参数是否为正确的类型”。如果不是,则返回false。一般说来,所谓“正确的类型”是指equals方法所在的那个类。有些情况下,是指该类所实现的某个接口。如果类实现的接口改进了equals约定,允许在实现了该接口的类之间进行比较,那么就使用接口。集合接口(collection interface)如SetListMapMap.Entry具有这样的特性。

  3. 把参数转换成正确的类型。因为转换之前进行过instanceof测试,所以确保会成功。

  4. 对于该类中每个“关键(significant)域,检查参数中的域是否与该对象中对应的域相匹配”。如果这些测试全部成功,则返回true;否则返回false。如果第2步中的类型是个借口,就必须通过接口方法访问参数中的域;如果该类型是个类,也许就能够直接访问参数中的域,这要取决于它们的可访问性。

    对于既不是float也不是double类型的基本类型域,可以使用==操作符进行比较;对于对象引用域,可以递归地调用equals方法;对于float域,可以使用Float.compare方法;对于double域,则使用Double.compare。对于floatdouble域进行特殊的处理是有必要的,因为存在着Float.NaN-0.0f以及类似的double常量;详细信息请参考Float.equals的文档。对于数组域,则要把以上这些指导原则应用到每个元素上。如果数组域中的每个元素都很重要,就可以使用发行版本1.5中新增的其中一个Arrays.equals方法。

    有些对象引用域包含null可能是合法的,所以,为了避免可能导致NullPointerException异常,则使用下面的习惯用法来比较这样的域:

    1
    (field == null ? o.field == null : field.equals(o.field))

    如果field域和o.field通常是相同的对象引用,那么下面的做法就会更快一些:

    1
    (field == o.field || (field != null && field.equals(o.field)))
  5. 当你编写完成了equals方法之后,应该问自己三个问题:它是不是对称的、传递的、一致的?并且不要只是自问,还要编写单元测试来检验这些特性!如果答案是否定的,就要找出原因,再相应地修改equals方法的代码。当然,equals方法也必须满足其他两个特性(自反性和非空性),但是这两种特性通常会自动满足。

其他原则还包括:

  • 覆盖 equals 时总要覆盖 hashCode。(在下一节中介绍)
  • 不要企图让 equals 方法过于智能。如果只是简单地测试域中的值是否相等,则不难做到遵守equals约定。如果想过度地去寻求各种等价关系,则很容易陷入麻烦之中。把任何一种别名形式考虑到等价的范围内,往往不会是个好主意。例如,File类不应该视图把指向同一个文件的符号链接(symbolic link)当作相等的对象来看待。所幸File类没有这样做。
  • 不要将 equals 声明中的 Object 对象替换为其他的类型

第9条:覆盖equals时总要覆盖hashCode

一个很常见的错误根源在于没有覆盖hashCode方法。在每个覆盖了equals方法的类中,也必须覆盖hashCode方法。如果不这样做的话,就会违反Object.hashcode的通用约定,从而导致该类无法结合所有基于散列的集合一起正常工作,这样的集合包括HashMapHashSetHashtable

下面是约定的内容,摘自Object规范[JavaSE6]:

  • 在应用程序的执行期间,只要对象的equals方法的比较操作所用到的信息没有被修改,那么对同一个对象调用多次,hashCode方法都必须始终如一地返回同一个整数。在同一个应用程序的多次执行过程中,每次执行所返回的整数可以不一致。
  • 如果两个对象根据equals(Object)方法比较是相等的,那么调用这两个对象中任意一个对象的hashCode方法都必须产生同样的整数结果。
  • 如果两个对象根据equals(Object)方法比较是不相等的,那么调用这两个对象中任意一个对象的hashCode方法,则不一定要产生不同的整数结果。但是程序员应该知道,给不相等的对象产生截然不同的整数结果,有可能提高散列表(hash table)的性能。

因没有覆盖hashCode而违反的关键约定是第二条:相等的对象必须具有相等的散列码(hash code)。根据类的equals方法,两个截然不同的实例在逻辑上有可能是相等的,但是,根据Object类的hashCode方法,它们仅仅是两个没有任何共同之处的对象。因此,对象的hashCode方法返回两个看起来是随机的整数,而不是根据第二个约定所要求的那样,返回两个相等的整数。

默默看完书中的文字,是不是觉得有点哲学的韵味呢,写好一手代码真的不容易。

实战中如何重写 hashCode 和 equals?

hashCode 和 equals 很重要,在使用中,与之密切相关的一般是几个容器类:HashMap 和 HashSet,意味着当我们将一个类作为其中的元素时,尤其需要考量下 hashCode 和 equals 的写法。

话不多数,即刻介绍。对了,你指望我手敲 hashCode 和 equals 吗?不存在的,程序员应该优雅的偷懒,无论你是 eclipse 玩家还是 idea 玩家,都能找到对应的快捷键,帮你自动重写这两个方式,我们要做的就是对参数的选择做一些微调。例如使用 idea 生成下面这个类的 hashCode 和 equals 方法,设置前提:将所有字段当做关键(significant)域。

1
2
3
4
5
6
7
8
9
public class Example {
private int a;
private float b;
private double c;
private BigDecimal d;
private char e;
private byte f;
private String g;
}

方法一:Intellij Default

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;

Example example = (Example) o;

if (a != example.a) return false;
if (Float.compare(example.b, b) != 0) return false;
if (Double.compare(example.c, c) != 0) return false;
if (e != example.e) return false;
if (f != example.f) return false;
if (!d.equals(example.d)) return false;
return g.equals(example.g);
}

@Override
public int hashCode() {
int result = super.hashCode();
long temp;
result = 31 * result + a;
result = 31 * result + (b != +0.0f ? Float.floatToIntBits(b) : 0);
temp = Double.doubleToLongBits(c);
result = 31 * result + (int) (temp ^ (temp >>> 32));
result = 31 * result + d.hashCode();
result = 31 * result + (int) e;
result = 31 * result + (int) f;
result = 31 * result + g.hashCode();
return result;
}

这可能是大家最熟悉的方法,先来分析下 equals 的写法。看样子的确是遵循了《effective java》中提及的 java1.6 规范的,值得注意的点再强调下:Float 和 Double 类型的比较应该使用各自的静态方法 Float.compare 和 Double.compare。

hashCode 方法则更加有趣一点,你可能会有如下的疑问:

  • Double.doubleToLongBits 是干嘛用的?
  • 为啥是 31?
  • 为什么还有 ^,>>> 这些运算符号?

带着疑问来看看下面的解释。

一个好的散列函数通常倾向于“为不相等的对象产生不相等的散列码”。这正是上一节中hashCode约定中第三条的含义。理想情况下,散列函数应该把集合中不相等的实例均匀地分布到所有可能的散列值上。要想完全达到这种理想的情形是非常困难的。但相对接近这种理想情形则并不太苦难。《effective java》给出了一种简单的解决办法:

  1. 把某个非零的常数值,比如说17,保存在一个名为resultint类型的变量中。

  2. 对于对象中每个关键域f(指equals方法中涉及的每个域),完成以下步骤:

    a. 为该域计算int类型的散列码c:

    i. 如果该域是boolean类型,则计算(f ? 1 : 0).

    ii. 如果该域是bytecharshort或者int类型,则计算(int)f

    iii. 如果该域是long类型,则计算(int)(f ^ (f >>> 32))

    iv. 如果该域是float类型,则计算Float.floatToIntBits(f)

    v. 如果该域是double类型,则计算Double.doubleToLongBits(f),然后按照步骤2.a.iii,为得到的long类型值计算散列值。

    vi. 如果该域是一个对象引用,并且该域的equals方法通过递归地调用equals的方式来比较这个域,则同样为这个域递归地调用hashCode。如果需要更复杂的比较,则为这个域计算一个“范式(canonical representation)”,然后针对这个范式调用hashCode。如果这个域的值为null,则返回0(或者其他某个常数,但通常是0)。

    vii. 如果该域是一个数组,则要把每一个元素当做单独的域来处理。也就是说,递归地应用上述规则,对每个重要的元素计算一个散列码,然后根据步骤2.b中的做法把这些散列值组合起来。如果数组域中的每个元素都很重要,可以利用发行版本1.5中增加的其中一个Arrays.hashCode方法。

    b. 按照下面的公式,把步骤2.a中计算得到的散列码c合并到result中:

    1
    2
    >    result = 31 * result + c;
    >
  1. 返回result。

  2. 写完了hashCode方法之后,问问自己“相等的实例是否都具有相等的散列码”。要编写单元测试来验证你的推断。如果相等实例有着不相等的散列码,则要找出原因,并修正错误。

在散列码的计算过程中,可以把冗余域(redundant field)排除在外。换句话说,如果一个域的值可以根据参与计算的其他域值计算出来,则可以把这样的域排除在外。必须排除equals比较计算中没有用到的任何域,否则很有可能违反hashCode约定的第二条。

上述步骤1中用到了一个非零的初始值,因此步骤2.a中计算的散列值为0的那些初始域,会影响到散列值。如果步骤1中的初始值为0,则整个散列值将不受这些初始域的影响,因为这些初始域会增加冲突的可能性。值17则是任选的。

步骤2.b中的乘法部分使得散列值依赖于域的顺序,如果一个类包含多个相似的域,这样的乘法运算就会产生一个更好的散列函数。例如,如果String散列函数省略了这个乘法部分,那么只是字母顺序不同的所有字符串都会有相同的散列码。之所以选择31,是因为它是一个奇素数。如果乘数是偶数,并且乘法溢出的话,信息就会丢失,因为与2相乘等价于位移运算。使用素数的好处并不很明显,但是习惯上都使用素数来计算散列结果。31有个很好的特性,即用位移和减法来代替乘法,可以得到更好的性能,31 * i == (i << 5) - i。现代的VM可以自动完成这种优化。

是不是几个疑惑都解开了呢?

方法二:Objects.hash 和 Objects.equals

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
Example example = (Example) o;
return a == example.a &&
Float.compare(example.b, b) == 0 &&
Double.compare(example.c, c) == 0 &&
e == example.e &&
f == example.f &&
Objects.equals(d, example.d) &&
Objects.equals(g, example.g);
}

@Override
public int hashCode() {
return Objects.hash(super.hashCode(), a, b, c, d, e, f, g);
}

JAVA 是一个与时俱进的语言,有问题从自身解决,便利了开发者,如《effective java》所言,在 jdk1.6 中上述那些原则只是一纸空文。错误同真理的关系,就象睡梦同清醒的关系一样。一个人从错误中醒来,就会以新的力量走向真理。在 jdk1.7 中便造就了诸多的方法 Objects.hash 和 Objects.equals 帮助你智能的实现 hashCode 和 equals 方法。很明显,代码量上比方法一少了很多,并且有了 jdk 的原生支持,心里也更加有底了。

方法三:Lombok 的 @EqualsAndHashCode

前面已经提到了 Lombok 的这个注解,在此详细介绍下这个注解的用法,方便大家写出规范的 hashCode 和 equals 方法。

  1. 此注解会生成equals(Object other)hashCode()方法。
  2. 它默认使用非静态,非瞬态的属性
  3. 可通过参数exclude排除一些属性
  4. 可通过参数of指定仅使用哪些属性
  5. 它默认仅使用该类中定义的属性且不调用父类的方法
  6. 可通过callSuper=true解决上一点问题。让其生成的方法中调用父类的方法。

使用 Lombok 很便捷,整个代码也很清爽

1
2
3
4
5
6
7
8
9
10
11
12
13
@Data
@EqualsAndHashCode(of = {"a","b","c","d","e","f","g"})//默认就是所有参数
public class Example {

private int a;
private float b;
private double c;
private BigDecimal d;
private char e;
private byte f;
private String g;

}

如果想知道编译过后的庐山真面目,也可以在 target 包中找到 Example.java 生成的 Example.class,:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
public boolean equals(Object o) {
if (o == this) {
return true;
} else if (!(o instanceof Example)) {
return false;
} else {
Example other = (Example)o;
if (!other.canEqual(this)) {
return false;
} else if (this.getA() != other.getA()) {
return false;
} else if (Float.compare(this.getB(), other.getB()) != 0) {
return false;
} else if (Double.compare(this.getC(), other.getC()) != 0) {
return false;
} else {
Object this$d = this.getD();
Object other$d = other.getD();
if (this$d == null) {
if (other$d != null) {
return false;
}
} else if (!this$d.equals(other$d)) {
return false;
}

if (this.getE() != other.getE()) {
return false;
} else if (this.getF() != other.getF()) {
return false;
} else {
Object this$g = this.getG();
Object other$g = other.getG();
if (this$g == null) {
if (other$g != null) {
return false;
}
} else if (!this$g.equals(other$g)) {
return false;
}

return true;
}
}
}
}

protected boolean canEqual(Object other) {
return other instanceof Example;
}

public int hashCode() {
int PRIME = true;
int result = 1;
int result = result * 59 + this.getA();
result = result * 59 + Float.floatToIntBits(this.getB());
long $c = Double.doubleToLongBits(this.getC());
result = result * 59 + (int)($c >>> 32 ^ $c);
Object $d = this.getD();
result = result * 59 + ($d == null ? 43 : $d.hashCode());
result = result * 59 + this.getE();
result = result * 59 + this.getF();
Object $g = this.getG();
result = result * 59 + ($g == null ? 43 : $g.hashCode());
return result;
}

大致和前两种行为一致,这里选择素数从 31 替换成了 59,没有太大差异。

总结

我在开发时也曾考虑一个问题:一个数据库持久化对象到底怎么正确覆盖 hashCode 和 equals?以订单为例,是用主键 id 来判断,还是 流水编号 orderNo 来判断,可能没有准确的答案,各有各的道理,但如果将它丢进 HashSet,HashMap 中就要额外注意,hashCode 和 equals 会影响它们的行为!

这次 Lombok 发生的惨案主要还是由于不合理的 hashCode 和 equals(也包括了 toString)方法导致的,循环引用这种问题虽然没有直接在《effective java》中介绍,但一个引用,一个集合类是不是应该作为 hashCode 和 equals 的关键域参与计算,还是值得开发者仔细推敲的。本文还介绍了一些 hashCode 和 equals 的通用原则,弱弱地推荐 Lombok 便捷开发,强烈安利《effective java》一书。

分享到 评论