理解比特币

1. 比特币是什么?

比特币是基于区块链技术的一种数字货币实现,比特币网络是历史上首个经过大规模、长时间检验的数字货币系统。

比特币网络是一个分布式的点对点网络,网络中的矿工通过“挖矿”来完成对交易记录的记账过程,维护网络的正常运作。

区块链网络提供了一个公共可见的记账本,该记账本并非记录每个账户的余额,而是用来记录发生过的交易的历史信息。

比特币网络在功能上具有如下特点:

  • 去中心化:意味着没有任何独立个体可以对网络中的交易进行破坏,任何交易请求都需要大多数参与者的共识
  • 匿名性:比特币网络中账户是匿名的,无法从交易信息关联到具体的个体,但也意味着很难进行审计
  • 通胀预防:比特币的发行需要通过挖矿计算来进行,发行量每四年减半,总量上限2100万枚,无法被超发

比特币事件:

2. 比特币如何运作?

2.1 比特币核心程序:客户端

​ 比特币是一个软件,是软件就需要运行。

客户端的功能模块:

  • 钱包
  • 完整区块链:保留了完整的区块链账本数据
  • 网络路由:
  • 挖矿(可以独立部署)

2.2 比特币的发行:挖矿

​ “挖矿”是一种算法,(字面意思,投入某种工作,得到一种“宝贝”)。

挖矿在比特币软件中的用途:

  • 抢夺区块打包权
  • 验证交易事物
  • 奖励发行新币
  • 广播新区块

​ 比特币是一个对等网络,每个节点都可以独立维护自己的数据副本,那么如何保证彼此之间的数据一致呢?

​ 这就带有一个约定的规则,大家共同按照这个规则来竞争数据的打包权,打包完成后广播给别人,别人只要验证一下就行,没问题就存入自己的数据文件中。

​ 比特币采用工作量证明(PoW)的一种算法。谁先计算出符合难度的数字,谁就抢到了打包权。

1. 难度值

​ 首先,既然大家都在竞争,那计算的数字必须要符合一个难度,这个难度就是门槛。

​ 作为难度1的目标值。在比特币诞生初期,当时全网算力,大约10分钟得到一个符合难度1要求的值,这也是每个10分钟一个区块的来源、。

  • difficulty : 难度级别
  • nonce:随机数
  • bits:存储难度的十六进制目标值

全网 算力越强,difficulty 难度值就越大,bits 的目标值就越小。

新的难度值计算公式:新难度值 = 当前难度值 * (最近2016个区块的实际出块时间/ 20160分钟)

2. 挖矿计算

挖矿的计算公式:

1
SHA256( SHA256(version + prev_hash + merkle_root + ntime + nbits + nonce)) < Target 难度目标值
名称 含义
version 区块的版本号
prev_hash 前一个区块的哈希值
merkle_root 准备打包的交易事物哈希树的根植,也就是梅克尔根 (可以改变)
ntime 区块时间戳 (可以改变)
nbits 当前难度
nonce 随机值 (可以改变)

挖矿就是重复计算区块头的哈希值,不断修改该参数,直到与难度目标值匹配的一个过程。

​ 矿工的奖励,是作为一条交易事物包含在区块的交易事务中,相当于系统给矿工转账了一笔比特币,这种交易事物由于特殊性,通常称为 coinbase 交易。这个交易一般是位于区块中的第一条,比特币系统也是通过这种挖矿奖励的方式发行新的比特币,就像央行发行新钞一样。

3. 区块广播

​ 矿工挖出区块后,就进行网络广播,传递给相邻的节点,节点接收到新的区块后会进行一系列的验证,比如区块数据格式是否正确;区块头的哈希值小于目标难度了区块时间戳是否在允许范围之内;区块中第一个交易(且只有第一个)是coinbase交易;区块中的交易事物是否有效等。全部通过就会把新的区块数据纳入到自己的区块链账本中。如果是挖矿接地那收到消息,就会理解停止当前的挖矿计算,转而进行下一个区块的竞争。

​ 如果同一时间内多个矿工都计算出符合条件的值,都拥有了打包权,那以谁为准?比特币中的解决方案,就是让节点自己选择,最终传播最广、处于最长链中的区块将保留,因此到底谁的区块会被保留下来,就要看运气了。

​ 挖矿的作用,除了发行新的比特币外,主要就是维持网络共识,让每个节点对区块链书籍保持最终一致性。

4. 挖矿方式

2.3 比特币的钱包:核心钱包和轻钱包

​ 钱包,是属于比特币系统中的一个前端工具,最基本的功能就是用来管理用户的比特币地址、发起转账交易、查看交易记录等。

比特币地址的生成过程

page65image2621648.jpg

  1. 首先使用随机数发生器生成一个私钥
  2. 私钥经过 SECP256K1(是一种特定的椭圆曲线算法) 算法生成公钥。 (不能通过公钥生成私钥)
  3. 公钥接下来先使用 SHA256 ,再使用 RIPEMD160 哈希算法,计算出公钥哈希。比特币的代码通过2次哈斯来计算地址值,进一步确保哈希后的数据的唯一性。
  4. 将一个地址版本号链接到公钥哈希,然后对其进行两次 SHA256 运算,将计算结果取前面4字节作为公钥哈希的校验值
  5. 将版本号与公钥哈希以及校验值连接起来,然后进行 BASE58 编码转换,最终得到比特币地址。

简化过程:

page66image2817472.jpg

SPV钱包的大致过程

  1. 首先下载完整的区块头数据,注意是区块头,而不是所有的区块链数据,这样可以大大减少需要获取的账本数据量,区块头中包含中区块的梅克尔根,SPV方式主要就是靠它来实现的。
  2. 如果要验证某笔支付交易,则计算出这笔交易事物的哈希值 txHash。
  3. 找到txHash所在的区块,验证一下所在区块的区块头是否包含在账本数据中。
  4. 获得所在区块中计算梅克尔根所需要的哈希值
  5. 计算出梅克尔根
  6. 若计算结果与所在区块的梅克尔根相等,则支付交易是存在的。
  7. 根据该区块所处的高度位置,还可以确定该交易得到了多少个确认。

2.4 比特币的账户模型:UTXO

​ 比特币中交易事物的数据结构,UTXO(Unspent Transaction Output,“未花费事务输出”)。

page72image3005968.jpg

​ 上图展示了比特币中的交易事物结构,在比特币的交易事物数据中,存储的就是这样的输入和输出,相当于仓库中的进出流水账,并且“输入”和“输出”彼此对应,或者更准确地说,“输入”就是指向之前的“输出”。

  1. 001号交易为 Coinbase 交易,也就是挖矿交易,这个交易中,“输入”部分没有用对应的“输出”,而是由系统直接奖励发行比特币,矿工 Alice 得到12.5个比特币的奖励,放在001号交易的“输出”部分。此时,对于Alice来说,拥有了这12.5个比特币的支配权,这个12.5个比特币的输出可以作为下一笔交易的“输入”,顾名思义,这逼“输出”就称之为是Alice的未花费输出,也就是Alice的UTXO的意思。
  2. 002号交易中,ALice转账6比特币到Bob的地址,Alice找到了自己的UTXO(如果Alice不止一笔UTXO,可以根据一定的规则去选用。比如将小金额的先花费掉)。由于只需要转账6比特币,可以UTXO中却有12.5个,因此需要找零6.5个到自己的地址中,由此产生了002号中的交易输出

UTXO

  1. 比特币的交易不是通过账户的增减来实现的,而是一笔笔关联的输入/输出交易事物。
  2. 每一笔的交易都要花费”输入”,然后产出”输出“,这个产生的”输出“就是所谓的”未花费过的交易输出“,也就是UTXO。每一笔交易事物都有一个唯一的标号,称为交易事物ID,这是通过哈希算法计算而来的,当需要引用某一笔交易事物中的”输出“时,主要提供交易事物ID和所处”输出“列表中的序号就可以了。
  3. 由于没有账户的概念,因此当“输入”部门的金额大于所需的“输出”时,必须给自己找零,这个找零也是作为交易的一部分包含在“输出”中。

怎么证明哪一条UTXO是属于谁?

​ 在比特币中,是使用输入脚本和输出脚本程序实现的,有时候也称为“锁定脚本”和”解锁脚本”。简单地说,就是通过“锁定脚本”,利用私钥签名解锁自己的某一条UTXO(也就是之前的“输出”),然后使用对方的公钥锁定新的“输出”,成功后,这笔新的“输出”就成为了对方的UTXO。

​ 同样,对方也可以使用“锁定脚本”和“解锁脚本”来实现转账。这个脚本程序其实本质上就可以看成是比特币的数字合约,这也是为什么比特币被称为可以变成数字货币的原因。

3. 比特币存在什么问题?

3.1 软分叉和硬分叉

软分叉:新版本接地那认为老版本节点发出的区块/交易合法

硬分叉:新版本节点认为老版本节点发出的区块/交易不合法

![page353image2752512.jpg](/Users/wengcheng/Library/Application Support/typora-user-images/page353image2752512.jpg) ![page353image2758752.jpg](/Users/wengcheng/Library/Application Support/typora-user-images/page353image2758752.jpg)

3.2 51%的攻击

当打包权在自己手中能干什么:

  1. 修改自己的交易记录,从而实现双花
  2. 阻止区块确认部分或者全部交易
  3. 阻止其他矿工开采到区块

51%攻击实现不了的:

  1. 修改他人的交易记录(没有他人的密钥)
  2. 凭空产生比特币((其他节点不会通过确认,达不成网络共识)
  3. 改版每区块的比特币发行数量
  4. 把不属于自己的比特币发给别人或自己
  5. 修改历史区块数据

3.3 轻钱包的易攻击性

SPV,用户只需要保存所有的区块头,由于只保留区块头,因此用户自己不能验证交易,需要从区块链某处找到相符的交易,才能得知认可情况。

3.4 私钥丢失

3.5 重放攻击:交易延展性

​ 在自然界,改变某些材料的形状,但是本质不会改变,就叫延展性。

​ 延展性攻击后,会产生新的事物ID,会导致以下后果:

  1. 接收方无法通过原始的事物ID来查询这笔转账
  2. 被修改过的交易会与其余在网络中传播的原始交易争抢进入区块,一旦抢先进入新的区块,原始交易就会被网络中的节点拒绝
  3. 阻止原始的交易进入区块

3.6 网络拥堵:大量交易的确认延迟

3.7 不断增长的区块数据

diary-20190829

​ 早上开车的时候,听余秋雨讲儒释道,道家的思想是做”真人“,释,也就是佛的思想是做”觉者“,儒的思想是做”君子“。成人之美,不成人之恶,这是君子和小人最大的区别。君子坦荡荡,小人长戚戚。

​ 今天白天,把今天插旗的日常开发完成了。小程序前后端调试真是不方便,老是忘记前端调用那个接口。由于这次改动涉及多个接口,调试的时候老是出现接口遗漏的情况。

​ 针对插旗2.8的需求,还是针对用户注册流程做了调整,并且,公众号注册的时候也要实现用户注册功能。

​ 今天周会的时候,听刚锐分享了薅羊毛的事情。才发现,原来我们身边有许多不知道的灰色产业链。往往都是脱离了监管的灰色产业,才会给那么胆大的人谋利。就想最近很火的《全裸导演》中关于日本情色产业。

​ 明天要和少庆一起过下2.8的整体方案。算上今天,正式开始拜读杨绛的《走到人生边上》。

diary-20190828

​ 早上上班路上,听了余秋雨的“每个人都要有自己的艺术感觉”。好吧,我赞同余秋雨的观点,学校里背诵的诗词和课文,现在已经不知道忘记到哪里去了。想想,对我的艺术感觉并没有什么作用。还是说我根本就没有。只能怪我书读得太少了,培养不起我的艺术感觉。等大圣长大点,我要他和我一起多读书。多读书总是好的。

​ 早上站会之前,终于把杨绛的“我们仨”看完了。看到结果,1997年,女儿走了;1998年,丈夫也走了。独留她一人独自思念。眼泪又不争气的留下来了。留她一个人生活那么久,思念一定非常苦。我希望我和阿登不要太煎熬。佛教曰:人生有八苦,生、老、病、死、爱别离、怨憎会、求不得、五阴炽盛。尽然,别离不可避免,只能希望痛苦能少一点、少一点。

​ 本来今天上午是计划在新的 Nginx 上测试的,但是 SA 的同事,拖到下班都迟迟没有完成 Nginx 的初始化。不知道明天能不能联调,不能再拖下去了。多一天,就要多付一天的机器钱。

​ 今天,冯超针对支付并加入的接口做性能测试,发现接口性能一直上不去。一直从200,100,50。后台才发现,单机的 JDBC 配置了30。测试的并发量远远大于 30,并且这个接口中有大量的数据库操作,一个请求大量持有 JDBC 连接,所以性能一直上不去,瓶颈在数据库连接上。要优化的话,只能减少单台机器上的JDBC连接了。或者,进行读写分离。或者,通过异步消息,将其他逻辑转移到其他机器上处理。

​ 最后,这个接口一致降到了50并发,然后配置 Hystrix 。配置的时候才发现,对 Hystrix 的配置了解太少了。打算稍微了解下。《Spring Cloud 微服务实战》看了一部分,稍微了解了 Hystrix 的处理流程。明天再把详细的参数了解下。

​ 晚上,看了《命运之夜——天之杯II :迷失之蝶》。最后一部要2020年上映。虽然已经快30了,看了还是感觉很燃。间桐樱这个角色真的可怜,从小被送出去,还被名义上的哥哥欺辱。希望第三部,卫宫士郎能救下樱吧。还是希望是个完美结局吧。牺牲少数人,拯救大多数人。我这种思想败坏得人,估计是为了喜欢的人,屠戮上万人的吧。

diary-20190827

​ 8月25日(周日)晚上,为了把周六追的小说(炼狱天使)看完,一直到凌晨1点才上床睡觉。期间,阿登命令我上床睡觉,我因为想把小说看完,就拒绝了她。她很生气,把卧室的房门给锁了,让我一个人在客厅看小说。好吧,因为这件事我把她给惹毛了,一连好几天都在生我的气。今天是第二天。

​ 我不善于交际,因此,每天都是公司-家,两点一线。平时和其他人也没有什么来往。我经常和阿登吐槽我两的交际圈太小,不过她应该比我好。因为她周末还不时的和同事出去玩,而我却没有,即使有我,一般也不会出去。

​ 这样的环境,游戏和小说就成了我的最爱。大学的时候还因为游戏,被阿登给甩了,失去了才懂得珍惜。第一次躺在阿登宿舍楼下的小树下,一边喝酒一边等着她的出现,想远远再看她一眼。后来等久了,开始给认识的人打电话聊天。(现在想想,估计那时候是发酒疯了)应该是他们告诉了阿登,然后阿登找到了躺在树下的我。那天晚上,把着阿登的时候,我才发现离开了她,我会多么痛苦。后来,我死皮赖脸的由追求她,然后工大的某个下午,我两复合了。

​ 有了大圣之后,现在游戏基本不玩了。但是小说还在看,网上的快餐小说看了一本又一本。孤寂的我把看小说变成生活中的一部分了。虽然看了很多,但是自己的内心世界并不强大。下班开车回家,看着沿途的风景,内心更空荡,在这个城市中生活,并没有更多的幸福感。

​ 我告诉阿登,我想看一些文学作品,希望能丰富我的精神世界。她推荐我看徐秋雨、钱钟书、杨绛的书。

​ 早上开车的路上,在喜马拉雅上听余秋雨讲“学会苦难中寻找幸福”,他说“幸福感是自己挖掘的,如果你没有幸福感,很可能是生活方式不对”。好吧,我开始尝试改变自己的生活习惯,尽量少看快餐小说,多看看文学作品,尝试每天晚上写日记,记录生活中的点滴。

​ 杨绛的“我们仨”看了大半了,女儿60岁就死了,然后钱钟书也去了,她一个人独自活到了100多岁。看着开头,女儿和丈夫相继去世,我的眼泪忍不住留下来了。只剩下她一个人开始思念他们仨。

​ 都说陪伴是最长情的告白。以前我告诉阿登,我要比她先走,这样我就不用过离开她的日子了。现在想想,自己真是自私,怎么可以让她忍受那种煎熬呢。我要陪着她,陪着她离开。为了以后能想起生活中的点滴,我开始写日记,多记录她和大圣。希望日后能回想起现在的点点滴滴。

使用WxJava开发微信公众号

公众号(服务号)配置

利用 weixin-java-mp 配置 appId 和 secret 。

  1. 添加pom.xml依赖

    1
    2
    3
    4
    5
    <dependency>
    <groupId>com.github.binarywang</groupId>
    <artifactId>weixin-java-mp</artifactId>
    <version>3.3.8.B</version>
    </dependency>
  2. 添加application.yml配置

    1
    2
    3
    4
    5
    6
    7
    wx:  
    mp:
    configs:
    - appId: xxx #(一个公众号的appid)(测试平台账号)
    secret: xxx
    token: test_token #(接口配置里的Token值)
    aesKey: iKVJDUh1yWR7PWB83D7z93HLn48wILNMU4Ls8A681pK #(接口配置里的EncodingAESKey值)
  3. Java配置

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
@Data
@ConfigurationProperties(prefix = "wx.mp")
public class WxMpProperties {
private List<MpConfig> configs;

@Data
public static class MpConfig {
/**
* 设置微信公众号的appid
*/
private String appId;
/**
* 设置微信公众号的app secret
*/
private String secret;
/**
* 设置微信公众号的token
*/
private String token;
/**
* 设置微信公众号的EncodingAESKey
*/
private String aesKey;
}
}
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
@Configuration
@EnableConfigurationProperties(WxMpProperties.class)
public class WxMpServiceConfiguration {
@Autowired
private WxMpProperties properties;

@Bean
public WxMpService wxMpService() {
final List<WxMpProperties.MpConfig> configs = this.properties.getConfigs();
if (configs == null) {
throw new RuntimeException("大哥,拜托先看下项目首页的说明(readme文件),添加下相关配置,注意别配错了!");
}

WxMpService service = new WxMpServiceImpl();
Map<String, WxMpConfigStorage> collect = configs
.stream().map(a -> {
WxMpInMemoryConfigStorage configStorage = new WxMpInMemoryConfigStorage();
configStorage.setAppId(a.getAppId());
configStorage.setSecret(a.getSecret());
configStorage.setToken(a.getToken());
configStorage.setAesKey(a.getAesKey());

return configStorage;
}).collect(Collectors.toMap(WxMpInMemoryConfigStorage::getAppId, a -> a, (o, n) -> o));
service.setMultiConfigStorages(collect);
return service;
}
}
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
@RestController
@RequestMapping("xxx/{appid}")
public class WxMpPortalController {

@Autowired
private WxMpServiceAdapter wxAdapter;

@GetMapping(produces = "text/plain;charset=utf-8")
public String authGet(@PathVariable String appid,
@RequestParam(name = "signature", required = false) String signature,
@RequestParam(name = "timestamp", required = false) String timestamp,
@RequestParam(name = "nonce", required = false) String nonce,
@RequestParam(name = "echostr", required = false) String echostr) {

if (StringUtils.isAnyBlank(signature, timestamp, nonce, echostr)) {
throw new IllegalArgumentException("请求参数非法,请核实!");
}

if (!wxAdapter.switchover(appid)) {
throw new IllegalArgumentException(String.format("未找到对应appid=[%s]的配置,请核实!", appid));
}

if (wxAdapter.checkSignature(timestamp, nonce, signature)) {
return echostr;
}

return "非法请求";
}
}

access_token

​ 同小程序一样。公众号大部分接口都是需要 access_token 。

me.chanjar.weixin.mp.api.impl.BaseWxMpServiceImpl#executeInternal,每次发送请求的时候也会获取 access_token。

1
2
3
4
5
6
7
8
9
10
public <T, E> T executeInternal(RequestExecutor<T, E> executor, String uri, E data) throws WxErrorException {
......
String accessToken = getAccessToken(false);
String uriWithAccessToken = uri + (uri.contains("?") ? "&" : "?") + "access_token=" + accessToken;
try {
T result = executor.execute(uriWithAccessToken, data);
return result;
} catch (WxErrorException e) {
......
}

me.chanjar.weixin.mp.api.WxMpService#getAccessToken(boolean) 针对不同的api,有3种实现类

  • me.chanjar.weixin.mp.api.impl.WxMpServiceHttpClientImpl
  • me.chanjar.weixin.mp.api.impl.WxMpServiceJoddHttpImpl
  • me.chanjar.weixin.mp.api.impl.WxMpServiceOkHttpImpl

WxMpServiceHttpClientImpl 为例:

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
@Override
public String getAccessToken(boolean forceRefresh) throws WxErrorException {
if (!this.getWxMpConfigStorage().isAccessTokenExpired() && !forceRefresh) {
return this.getWxMpConfigStorage().getAccessToken();
}

Lock lock = this.getWxMpConfigStorage().getAccessTokenLock();
lock.lock();
try {
String url = String.format(WxMpService.GET_ACCESS_TOKEN_URL,
this.getWxMpConfigStorage().getAppId(), this.getWxMpConfigStorage().getSecret());
try {
HttpGet httpGet = new HttpGet(url);
if (this.getRequestHttpProxy() != null) {
RequestConfig config = RequestConfig.custom().setProxy(this.getRequestHttpProxy()).build();
httpGet.setConfig(config);
}
try (CloseableHttpResponse response = getRequestHttpClient().execute(httpGet)) {
String resultContent = new BasicResponseHandler().handleResponse(response);
WxError error = WxError.fromJson(resultContent, WxType.MP);
if (error.getErrorCode() != 0) {
throw new WxErrorException(error);
}
WxAccessToken accessToken = WxAccessToken.fromJson(resultContent);
this.getWxMpConfigStorage().updateAccessToken(accessToken.getAccessToken(), accessToken.getExpiresIn());
return this.getWxMpConfigStorage().getAccessToken();
} finally {
httpGet.releaseConnection();
}
} catch (IOException e) {
throw new RuntimeException(e);
}
} finally {
lock.unlock();
}
}

​ 公众号中,存储 token 的接口是 me.chanjar.weixin.mp.api.WxMpConfigStorage。它的实现类有

  • me.chanjar.weixin.mp.api.WxMpInMemoryConfigStorage 基于本地内存实现

  • me.chanjar.weixin.mp.api.WxMpInRedisConfigStorage 基于 JedisPool 实现

    因为项目中使用 RedisTemplate,因此参考 WxMpInRedisConfigStorage 重写了一个基于 RedisTemplate 实现的 WxMpConfigStorage 实现类。

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
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
public class CqWxMpInRedisConfigStorage extends WxMpInMemoryConfigStorage {

private static final String ACCESS_TOKEN_KEY = "cq:wx:access_token:";
private static final String UPDATE_ACCESS_TOKEN_LOOK_KEY = "cq:wx:access_token:lock:updating";
private String accessTokenKey;

private RedisTemplate redisTemplate;

public CqWxMpInRedisConfigStorage(RedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}

/**
* 每个公众号生成独有的存储key.
*/
@Override
public void setAppId(String appId) {
super.setAppId(appId);
this.accessTokenKey = ACCESS_TOKEN_KEY.concat(appId);
}


private String getTicketRedisKey(TicketType type) {
return String.format("cq:wx:ticket:key:%s:%s", this.appId, type.getCode());
}

@Override
public String getAccessToken() {
return (String) redisTemplate.opsForValue().get(this.accessTokenKey);
}

/**
* token 是否已经过期
*
* @return
*/
@Override
public boolean isAccessTokenExpired() {
return !redisTemplate.hasKey(accessTokenKey);
}

@Override
public synchronized void updateAccessToken(String accessToken, int expiresInSeconds) {
String lockStr = UPDATE_ACCESS_TOKEN_LOOK_KEY;
RedisLock lock = new RedisLock(redisTemplate);
if (lock.lock(lockStr)) {
try {
redisTemplate.opsForValue().set(this.accessTokenKey, accessToken);
redisTemplate.expire(this.accessTokenKey, expiresInSeconds - 200, TimeUnit.SECONDS);
} catch (Exception e) {
log.error("获取锁" + lockStr + "异常", e);
throw new RuntimeException(e);
} finally {
lock.unlock(lockStr);
}
} else {
log.error("updateAccessToken: get lock " + lockStr + " failed");
}
}

@Override
public void expireAccessToken() {
redisTemplate.expire(this.accessTokenKey, 0, TimeUnit.SECONDS);
}

@Override
public String getTicket(TicketType type) {
return (String) redisTemplate.opsForValue().get(this.getTicketRedisKey(type));
}

@Override
public boolean isTicketExpired(TicketType type) {
return !redisTemplate.hasKey(this.getTicketRedisKey(type));
}

@Override
public synchronized void updateTicket(TicketType type, String jsapiTicket, int expiresInSeconds) {
String lockStr = UPDATE_ACCESS_TOKEN_LOOK_KEY;
RedisLock lock = new RedisLock(redisTemplate);
if (lock.lock(lockStr)) {
try {
redisTemplate.opsForValue().set(this.getTicketRedisKey(type), jsapiTicket);
redisTemplate.expire(this.getTicketRedisKey(type), expiresInSeconds - 200, TimeUnit.SECONDS);
} catch (Exception e) {
log.error("updateTicket", e);
throw new RuntimeException(e);
} finally {
lock.unlock(lockStr);
}
} else {
log.error("updateTicket: get lock " + lockStr + " failed");
}
}

@Override
public void expireTicket(TicketType type) {
redisTemplate.expire(this.getTicketRedisKey(type), 0, TimeUnit.SECONDS);
}

@Data
public static class AccessToken {
String accessToken;
}
}

​ 并且在配置 WxMpService 的时候,使用自定义 CqWxMpInRedisConfigStorage 替换 WxMpInMemoryConfigStorage。

接入测试平台

​ 由于微信公众号(服务号)认证成本较高,因此提供了 微信公众平台接口调试工具

测试号信息 提供了测试号的 appID 和 appsecret,需要配置在后端应用中。

接口配置信息 配置后端的接口。

消息管理

服务号通知配置

​ 在使用公众号 消息管理 前,需要在 开发 》基础配置 》服务器配置 配置开发者配置。

接收事件推送

关注/取消关注事件

​ 用户在关注与取消关注公众号时,微信会把这个事件推送到开发者填写的URL。方便开发者给用户下发欢迎消息或者做帐号的解绑。为保护用户数据隐私,开发者收到用户取消关注事件时需要删除该用户的所有信息。

推送XML数据包示例:

1
2
3
4
5
6
7
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[FromUser]]></FromUserName>
<CreateTime>123456789</CreateTime>
<MsgType><![CDATA[event]]></MsgType>
<Event><![CDATA[subscribe]]></Event>
</xml>
参数 描述
ToUserName 开发者微信号
FromUserName 发送方帐号(一个OpenID)
CreateTime 消息创建时间 (整型)
MsgType 消息类型,event
Event 事件类型,subscribe(订阅)、unsubscribe(取消订阅)

注意点

  1. 由于微信公众号的UnionID机制,一个用户同时关注公众号和小程序,会有两个OpenID和一个UnionID。但是前提是需要在 微信开放平台 上同时绑定 公众号和小程序。

Java示例

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
@Configuration
@EnableConfigurationProperties(WxMpProperties.class)
public class WxMpServiceConfiguration {
......
@Autowired
private LogHandler logHandler;
@Autowired
private UnsubscribeHandler unsubscribeHandler;
@Autowired
private SubscribeHandler subscribeHandler;

@Bean
public WxMpMessageRouter messageRouter(WxMpService wxMpService) {
final WxMpMessageRouter newRouter = new WxMpMessageRouter(wxMpService);

// 记录所有事件的日志 (异步执行)
newRouter.rule().handler(this.logHandler).next();

// 关注事件
newRouter.rule().async(false).msgType(WxConsts.XmlMsgType.EVENT)
.event(WxConsts.EventType.SUBSCRIBE).handler(this.subscribeHandler)
.end();

// 取消关注事件
newRouter.rule().async(false).msgType(WxConsts.XmlMsgType.EVENT)
.event(WxConsts.EventType.UNSUBSCRIBE)
.handler(this.unsubscribeHandler).end();

return newRouter;
}
}
1
2
3
4
5
6
public abstract class AbstractHandler implements WxMpMessageHandler {}

public abstract class AbstractBuilder {
public abstract WxMpXmlOutMessage build(String content,
WxMpXmlMessage wxMessage, WxMpService service);
}
1
2
3
4
5
6
7
8
9
10
@Component
public class LogHandler extends AbstractHandler {
@Override
public WxMpXmlOutMessage handle(WxMpXmlMessage wxMessage,
Map<String, Object> context, WxMpService wxMpService,
WxSessionManager sessionManager) {
log.info("\n接收到请求消息,内容:{}", JSONObject.toJSONString(wxMessage));
return null;
}
}
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
@Component
public class SubscribeHandler extends AbstractHandler {
@Override
public WxMpXmlOutMessage handle(WxMpXmlMessage wxMessage,
Map<String, Object> context,
WxMpService weixinService,
WxSessionManager sessionManager) throws WxErrorException {
log.info("新关注用户 OPENID: " + wxMessage.getFromUser());
// 获取微信用户基本信息
try {
WxMpUser userWxInfo = weixinService.getUserService().userInfo(wxMessage.getFromUser(), null);
if (userWxInfo != null) {
// 可以添加关注用户到本地数据库
}
} catch (WxErrorException e) {
if (e.getError().getErrorCode() == 48001) {
log.info("该公众号没有获取用户信息权限!");
}
}

WxMpXmlOutMessage responseResult = null;
try {
responseResult = this.handleSpecial(wxMessage);
} catch (Exception e) {
log.error(e.getMessage(), e);
}

if (responseResult != null) {
return responseResult;
}

try {
return new TextBuilder().build("感谢关注", wxMessage, weixinService);
} catch (Exception e) {
log.error(e.getMessage(), e);
}

return null;
}

/**
* 处理特殊请求,比如如果是扫码进来的,可以做相应处理
*/
private WxMpXmlOutMessage handleSpecial(WxMpXmlMessage wxMessage)
throws Exception {
//TODO
return null;
}
}
1
2
3
4
5
6
7
8
9
10
public class TextBuilder extends AbstractBuilder {
@Override
public WxMpXmlOutMessage build(String content, WxMpXmlMessage wxMessage,
WxMpService service) {
WxMpXmlOutTextMessage m = WxMpXmlOutMessage.TEXT().content(content)
.fromUser(wxMessage.getToUser()).toUser(wxMessage.getFromUser())
.build();
return m;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
@Component
public class UnsubscribeHandler extends AbstractHandler {
@Override
public WxMpXmlOutMessage handle(WxMpXmlMessage wxMessage,
Map<String, Object> context, WxMpService wxMpService,
WxSessionManager sessionManager) {
String openId = wxMessage.getFromUser();
// 取消订阅只能返回 openId
log.info("取消关注用户 OPENID: " + openId);
return null;
}
}

客服消息

​ 当用户和公众号产生特定动作的交互时(具体动作列表请见下方说明),微信将会把消息数据推送给开发者,开发者可以在一段时间内(目前修改为48小时)调用客服接口,通过POST一个JSON数据包来发送消息给普通用户。此接口主要用于客服等有人工消息处理环节的功能,方便开发者为用户提供更加优质的服务。

​ 目前允许的动作列表如下(公众平台会根据运营情况更新该列表,不同动作触发后,允许的客服接口下发消息条数不同,下发条数达到上限后,会遇到错误返回码,具体请见返回码说明页):

1
2
3
4
5
6
1、用户发送信息
2、点击自定义菜单(仅有点击推事件、扫码推事件、扫码推事件且弹出“消息接收中”提示框这3种菜单类型是会触发客服接口的)
3、关注公众号
4、扫描二维码
5、支付成功
6、用户维权

另外,请开发者注意,本接口中所有使用到media_id的地方,现在都可以使用素材管理中的永久素材media_id了。

Java示例

me.chanjar.weixin.mp.api.WxMpKefuService#sendKefuMessage

1
2
3
// 发送客服消息,发送图片
adapter.getKefuService().sendKefuMessage(WxMpKefuMessage.IMAGE()
.mediaId(mediaId).toUser(wxMessage.getFromUser()).build());

模板消息

​ 模板消息仅用于公众号向用户发送重要的服务通知,只能用于符合其要求的服务场景中,如信用卡刷卡通知,商品购买成功通知等。不支持广告等营销类消息以及其它所有可能对用户造成骚扰的消息。

​ 在模版消息发送任务完成后,微信服务器会将是否送达成功作为通知,发送到开发者中心中填写的服务器配置地址中。

关于使用规则,请注意:

1
2
3
4
5
1、所有服务号都可以在功能->添加功能插件处看到申请模板消息功能的入口,但只有认证后的服务号才可以申请模板消息的使用权限并获得该权限;
2、需要选择公众账号服务所处的2个行业,每月可更改1次所选行业;
3、在所选择行业的模板库中选用已有的模板进行调用;
4、每个账号可以同时使用25个模板。
5、当前每个账号的模板消息的日调用上限为10万次,单个模板没有特殊限制。【2014年11月18日将接口调用频率从默认的日1万次提升为日10万次,可在MP登录后的开发者中心查看】。当账号粉丝数超过10W/100W/1000W时,模板消息的日调用上限会相应提升,以公众号MP后台开发者中心页面中标明的数字为准。

注意点

  1. 如果模板消息要支持跳转小程序的话,需要在公众号上添加小程序关联。小程序 > 小程序管理

Java示例

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
public Boolean sendTemplateMsg(WxMpTemplateMessageBO templateMessageBO) throws WxErrorException {

WxMpTemplateMessage.MiniProgram miniProgram = null;
if (templateMessageBO.getMiniProgram() != null) {
miniProgram = new WxMpTemplateMessage.MiniProgram();
miniProgram.setAppid(templateMessageBO.getMiniProgram().getAppid());
miniProgram.setPagePath(templateMessageBO.getMiniProgram().getPagePath());
}

WxMpTemplateMessage templateMessage = WxMpTemplateMessage.builder()
.toUser(templateMessageBO.getToUser())
.templateId(templateMessageBO.getTemplateId())
.url(templateMessageBO.getUrl())
.miniProgram(miniProgram)
.data(templateMessageBO.getData().stream().map(e -> {
WxMpTemplateData data = new WxMpTemplateData();
data.setColor(e.getColor());
data.setName(e.getName());
data.setValue(e.getValue());
return data;
}).collect(Collectors.toList()))
.build();

String result = wxService.getTemplateMsgService().sendTemplateMsg(templateMessage);
log.info("WxMpServiceAdapter.sendTemplateMsg result:{}", result);
return StringUtils.isNotEmpty(result);
}
参数 是否必填 说明
touser 接收者openid
template_id 模板ID
url 模板跳转链接(海外帐号没有跳转能力)
miniprogram 跳小程序所需数据,不需跳小程序可不用传该数据
appid 所需跳转到的小程序appid(该小程序appid必须与发模板消息的公众号是绑定关联关系,暂不支持小游戏)
pagepath 所需跳转到小程序的具体页面路径,支持带参数,(示例index?foo=bar),要求该小程序已发布,暂不支持小游戏
data 模板数据
color 模板内容字体颜色,不填默认为黑色

注:url和miniprogram都是非必填字段,若都不传则模板无跳转;若都传,会优先跳转至小程序。开发者可根据实际需要选择其中一种跳转方式即可。当用户的微信客户端版本不支持跳小程序时,将会跳转至url。

在调用模板消息接口后,会返回JSON数据包。正常时的返回JSON数据包示例:

1
2
3
4
5
{
"errcode":0,
"errmsg":"ok",
"msgid":200228332
}

素材管理

​ 公众号经常有需要用到一些临时性的多媒体素材的场景,例如在使用接口特别是发送消息时,对多媒体文件、多媒体消息的获取和调用等操作,是通过media_id来进行的。素材管理接口对所有认证的订阅号和服务号开放。通过本接口,公众号可以新增临时素材(即上传临时多媒体文件)。

注意点:

  1. 临时素材media_id是可复用的。

  2. 媒体文件在微信后台保存时间为3天,即3天后media_id失效。

  3. 上传临时素材的格式、大小限制与公众平台官网一致。

    1. 图片(image): 2M,支持PNG\JPEG\JPG\GIF格式`
    2. 语音(voice):2M,播放长度不超过60s,支持AMR\MP3格式
    3. 视频(video):10MB,支持MP4格式
    4. 缩略图(thumb):64KB,支持JPG格式
  4. 需使用https调用本接口。

参数说明

参数 是否必须 说明
access_token 调用接口凭证
type 媒体文件类型,分别有图片(image)、语音(voice)、视频(video)和缩略图(thumb)
media form-data中媒体文件标识,有filename、filelength、content-type等信息

返回说明

正确情况下的返回JSON数据包结果如下:

1
{"type":"TYPE","media_id":"MEDIA_ID","created_at":123456789}
参数 描述
type 媒体文件类型,分别有图片(image)、语音(voice)、视频(video)和缩略图(thumb,主要用于视频与音乐格式的缩略图)
media_id 媒体文件上传后,获取标识
created_at 媒体文件上传时间戳

错误情况下的返回JSON数据包示例如下(示例为无效媒体类型错误):

1
{"errcode":40004,"errmsg":"invalid media type"}

Java示例

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
private String getImageMediaId() {
String mediaId = (String) redisTemplate.opsForValue().get(CQ_OREVER_MATERIAL);
// 获取声音或者图片永久素材
try (InputStream inputStream = adapter.getMaterialService().materialImageOrVoiceDownload(mediaId)) {
if (inputStream != null) {
return mediaId;
}
} catch (Exception e) {
log.error(" getMaterialService.materialImageOrVoiceDownload error", e);
}

// 新增非图文永久素材
try {
WxMpMaterial material = createWxMpMaterial();
WxMpMaterialUploadResult result = adapter.getMaterialService()
.materialFileUpload("image", material);
return result.getMediaId();
} catch (Exception e) {
log.error(" getMaterialService.materialFileUpload error", e);
}
return null;
}

private WxMpMaterial createWxMpMaterial() throws IOException {
WxMpMaterial material = new WxMpMaterial();
material.setName("微信小程序二维码");
material.setFile(new ClassPathResource("qrcode_for_service.jpg").getFile());
return material;
}

me.chanjar.weixin.mp.api.WxMpMaterialService#materialFileBatchGet

1
2
// 分页获取其他媒体素材列表
WxMpMaterialFileBatchGetResult materialFileBatchGet(String type, int offset, int count) throws WxErrorException;

使用WxJava开发微信小程序

小程序服务配置

利用 weixin-java-miniapp 配置 appId 和 secret 。

  1. 添加pom.xml依赖

    1
    2
    3
    4
    5
    <dependency>
    <groupId>com.github.binarywang</groupId>
    <artifactId>weixin-java-miniapp</artifactId>
    <version>3.3.0</version>
    </dependency>
  2. 添加application.yml配置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    debug: false
    logging:
    level:
    org.springframework.web: info
    cn.binarywang.wx.miniapp: info
    me.chanjar.weixin: info
    wx:
    miniapp:
    configs:
    - appid: appid
    secret: appidsecret
    token: # cqtest (测试环境)
    aesKey: # RGAioK00rOP3TKnNVMVtBvCarimzvq4AFqltvAT0TiF
    msgDataFormat: JSON
  3. Java配置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    @Data
    @ConfigurationProperties(prefix = "wx.miniapp")
    public class WxMaProperties {
    private List<Config> configs;
    @Data
    public static class Config {
    //设置微信小程序的appid
    private String appid;

    //设置微信小程序的Secret
    private String secret;

    //设置微信小程序消息服务器配置的token
    private String token;

    //设置微信小程序消息服务器配置的EncodingAESKey
    private String aesKey;

    //消息格式,XML或者JSON
    private String msgDataFormat;
    }
    }
    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
     @Configuration
    @EnableConfigurationProperties(WxMaProperties.class)
    public class WxMaServiceConfiguration {
    @Autowired
    private WxMaProperties properties;
    //按照appid分wxMaService
    private Map<String, WxMaService> maServices = new HashMap<>();

    @PostConstruct
    public void init() {
    maServices = this.properties.getConfigs().stream()
    .map(a -> {
    WxMaInMemoryConfig config = new WxMaInMemoryConfig();
    config.setAppid(a.getAppid());
    config.setSecret(a.getSecret());
    config.setToken(a.getToken());
    config.setAesKey(a.getAesKey());
    config.setMsgDataFormat(a.getMsgDataFormat());
    WxMaService service = new WxMaServiceImpl();
    service.setWxMaConfig(config);
    return service;
    }).collect(Collectors.toMap(s -> s.getWxMaConfig().getAppid(), a -> a));
    }

    public WxMaService getMaService(String appid) {
    WxMaService wxService = maServices.get(appid);
    if (wxService == null) {
    throw new IllegalArgumentException(String.format("未找到对应appid=[%s]的配置,请核实!", appid));
    }
    return wxService;
    }
    }

注意

  1. 使用之前,需要保证使用 JRE/JDK 8u151 之后的版本,或者替换两个jar,否则加密时会出现 java.security.InvalidKeyException: Illegal key size 的问题。详见

access_token

接口调用凭证,调用绝大多数后台接口时都需使用 access_token,开发者需要进行妥善保存。

weixin-java-miniapp 对 access_token 做了相应的封装,每次调用接口的时候都会主动获取 access_token 。

cn.binarywang.wx.miniapp.api.impl.WxMaServiceImpl#executeInternal

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
private <T, E> T executeInternal(RequestExecutor<T, E> executor, String uri, E data) throws WxErrorException {
......
if (uri.contains("access_token=")) {
throw new IllegalArgumentException("uri参数中不允许有access_token: " + uri);
}
// 获取 access_token
String accessToken = getAccessToken(false);
String uriWithAccessToken = uri + (uri.contains("?") ? "&" : "?") + "access_token=" + accessToken;

try {
T result = executor.execute(uriWithAccessToken, data);
log.debug("\n【请求地址】: {}\n【请求参数】:{}\n【响应数据】:{}", uriWithAccessToken, dataForLog, result);
return result;
} catch (WxErrorException e) {
......
}
}

@Override
public String getAccessToken(boolean forceRefresh) throws WxErrorException {
Lock lock = this.getWxMaConfig().getAccessTokenLock();
try {
lock.lock();
// 判断 access_token 是否超时
if (this.getWxMaConfig().isAccessTokenExpired() || forceRefresh) {
String url = String.format(WxMaService.GET_ACCESS_TOKEN_URL, this.getWxMaConfig().getAppid(),
this.getWxMaConfig().getSecret());
try {
HttpGet httpGet = new HttpGet(url);
if (this.getRequestHttpProxy() != null) {
RequestConfig config = RequestConfig.custom().setProxy(this.getRequestHttpProxy()).build();
httpGet.setConfig(config);
}
try (CloseableHttpResponse response = getRequestHttpClient().execute(httpGet)) {
String resultContent = new BasicResponseHandler().handleResponse(response);
WxError error = WxError.fromJson(resultContent);
if (error.getErrorCode() != 0) {
throw new WxErrorException(error);
}
WxAccessToken accessToken = WxAccessToken.fromJson(resultContent);
// 更新新的 access_token
this.getWxMaConfig().updateAccessToken(accessToken.getAccessToken(),
accessToken.getExpiresIn());
} finally {
httpGet.releaseConnection();
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
} finally {
lock.unlock();
}

return this.getWxMaConfig().getAccessToken();
}

注意

weixin-java-miniapp 中使用 cn.binarywang.wx.miniapp.config.WxMaConfig 记录着 access_token ,在 access_token 超时之前都可以直接从 WxMaConfig 中获取。需要注意的是,每次请求的access_token都是不一样的,即使在超时时间内,获取到的也是新的 access_token 。

weixin-java-miniappcn.binarywang.wx.miniapp.config.WxMaConfig 的实现类只有 cn.binarywang.wx.miniapp.config.WxMaInMemoryConfig类,是基于本地内存存储的,**在生产环境多机器环境中应该将这些配置持久化到一个独立的地方(可以使用Redis)**。

​ 比如,继承 WxMaInMemoryConfig ,利用 Redis 重写 getAccessTokenupdateAccessToken 等方法。然后在初始化 WxMaService 的时候使用自定义的 WxMaConfig

用户信息

小程序登录

微信小程序提供的登录流程时序

img

说明:

  1. 微信小程序 调用 wx.login() 获取 临时登录凭证code ,并回传到开发者服务器。
  2. 开发者服务器 调用 auth.code2Session 接口,换取 用户唯一标识 OpenID会话密钥 session_key

之后开发者服务器可以根据用户标识来生成自定义登录态,用于后续业务逻辑中前后端交互时识别用户身份。

注意

  1. 会话密钥 session_key 是对用户数据进行 加密签名 的密钥。为了应用自身的数据安全,开发者服务器不应该把会话密钥下发到小程序,也不应该对外提供这个密钥
  2. 临时登录凭证 code 只能使用一次

Java后台登陆

​ 从上面的登陆流程可以看见,开发者服务器在小程序和微信服务器之间充当了中专、代理的功能。

​ 在这个登陆流程中,起到了置换登陆状态的功能。

​ 这里利用了 weixin-java-miniapp 实现微信小程序接口的调用。

1
2
3
4
5
<dependency>
<groupId>com.github.binarywang</groupId>
<artifactId>weixin-java-miniapp</artifactId>
<version>3.3.0</version>
</dependency>

​ 获取用户登陆后的session信息

1
2
final WxMaService wxService = wxMaServiceConfiguration.getMaService(appId);  
WxMaJscode2SessionResult session = wxService.getUserService().getSessionInfo(code);

​ 然后就可以使用自定义的登陆状态,封装微信返回的 session 信息。

消息

模板消息

微信官方文档·小程序·开放能力·消息·模板消息

  • 模板推送位置:服务通知
  • 模板下发条件:用户本人在微信体系内与页面有交互行为后触发
    • 支付:当用户 在小程序内完成过支付行为,可允许开发者向用户在7天内推送有限条数的模板消息(1次支付可下发3条,多次支付下发条数独立,互相不影响)
    • 提交表单:当用户在小程序内发生过提交表单行为且该表单声明为要发模板消息的,开发者需要向用户提供服务时,可允许开发者向用户在7天内推送有限条数的模板消息(1次提交表单可下发1条,多次提交下发条数独立,相互不影响)
  • 模板跳转能力:点击查看详情仅能跳转下发模板的该帐号的各个页面

注意

  1. 发送模板消息之前需要有相关的模板,获取该模板ID
  2. 发送模板消息的时候,还需要前端收集的 form_id (用户提交表单下产生的。也就是先收集用户产生的 form_id ,然后再利用 form_id 发送模板消息。form_id 的有限期是7天。

客服消息

微信官方文档·小程序·开放能力·消息·客服消息

​ 在页面使用客服消息

​ 需要将 button 组件 open-type 的值设置为 contact,当用户点击后就会进入客服会话,如果用户在会话中点击了小程序消息,则会返回到小程序,开发者可以通过 bindcontact 事件回调获取到用户所点消息的页面路径 path 和对应的参数 query

​ 后台接入消息服务

​ 用户向小程序客服发送消息、或者进入会话等情况时,开发者填写的服务器配置 URL (如果使用的是云开发,则是配置的云函数)将得到微信服务器推送过来的消息和事件,开发者可以依据自身业务逻辑进行响应。接入和使用方式请参考消息推送

​ 发送消息

​ 当用户和小程序客服产生特定动作的交互时(具体动作列表请见下方说明),微信将会把消息数据推送给开发者,开发者可以在一段时间内(目前为 48 小时)调用客服接口,通过调用 发送客服消息接口 来发送消息给普通用户。此接口主要用于客服等有人工消息处理环节的功能,方便开发者为用户提供更加优质的服务。

目前允许的动作列表如下,不同动作触发后,允许的客服接口下发消息条数和下发时限不同。

用户动作 允许下发条数限制 下发时限
用户发送消息 5 条 48 小时

注意

  1. 利用客服接口,可以针对一个请求,恢复多条内容。比如,用户发送了一个1,客服可以回复图片和文字两条消息。

Java后台消息

发送模板消息

cn.binarywang.wx.miniapp.api.WxMaMsgService#sendTemplateMsg

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
final WxMaService wxService = wxMaServiceConfiguration.getMaService(getAppId());
WxMaMsgService maMsgService = wxService.getMsgService();
WxMaTemplateMessage message = WxMaTemplateMessage.builder()
.toUser(messageBO.getToUser())
.templateId(messageBO.getTemplateId())
.formId(messageBO.getFormId())
.page(messageBO.getPage())
.color(messageBO.getColor())
.emphasisKeyword(messageBO.getEmphasisKeyword())
.data(messageBO.getData().stream()
.map(TemplateDataAssembler::buildWxMaTemplateMessage)
.collect(Collectors.toList()))
.build();

maMsgService.sendTemplateMsg(message);

发送客服消息

​ 用户发送客服消息,开发者需要填写服务器配置。

步骤一:填写服务器配置

登录小程序后台后,在「开发」-「开发设置」-「消息推送」中,管理员扫码启用消息服务,填写服务器地址(URL)、令牌(Token) 和 消息加密密钥(EncodingAESKey)等信息。

  • URL: 开发者用来接收微信消息和事件的接口 URL。开发者所填写的URL 必须以 http:// 或 https:// 开头,分别支持 80 端口和 443 端口。
  • Token: 可由开发者可以任意填写,用作生成签名(该 Token 会和接口 URL 中包含的 Token 进行比对,从而验证安全性)。
  • EncodingAESKey: 由开发者手动填写或随机生成,将用作消息体加解密密钥。

同时,开发者可选择消息加解密方式:明文模式(默认)、兼容模式和安全模式。可以选择消息数据格式:XML 格式(默认)或 JSON 格式。

填写服务器配置

模式的选择与服务器配置在提交后都会立即生效,请开发者谨慎填写及选择。切换加密方式和数据格式需要提前配置好相关代码,详情请参考 消息加解密说明

步骤二:验证消息的确来自微信服务器

开发者提交信息后,微信服务器将发送GET请求到填写的服务器地址URL上,GET请求携带参数如下表所示:

参数 描述
signature 微信加密签名,signature结合了开发者填写的token参数和请求中的timestamp参数、nonce参数。
timestamp 时间戳
nonce 随机数
echostr 随机字符串

开发者通过检验 signature 对请求进行校验(下面有校验方式)。若确认此次 GET 请求来自微信服务器,请原样返回 echostr 参数内容,则接入生效,成为开发者成功,否则接入失败。加密/校验流程如下:

  1. 将token、timestamp、nonce三个参数进行字典序排序
  2. 将三个参数字符串拼接成一个字符串进行sha1加密
  3. 开发者获得加密后的字符串可与signature对比,标识该请求来源于微信

验证URL有效性成功后即接入生效,成为开发者。

Java 实例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@RestController
@RequestMapping("xxx/{appid}")
public class WxMaPortalController {
@Autowired
WxMaServiceConfiguration wxMaServiceConfiguration;
@GetMapping(produces = "text/plain;charset=utf-8")
public String authGet(@PathVariable String appid,
@RequestParam(name = "signature", required = false) String signature,
@RequestParam(name = "timestamp", required = false) String timestamp,
@RequestParam(name = "nonce", required = false) String nonce,
@RequestParam(name = "echostr", required = false) String echostr) {
if (StringUtils.isAnyBlank(signature, timestamp, nonce, echostr)) {
throw new IllegalArgumentException("请求参数非法,请核实!");
}
final WxMaService wxService = wxMaServiceConfiguration.getMaService(appid);
if (wxService.checkSignature(timestamp, nonce, signature)) {
return echostr;
}
return "非法请求";
}
}

第三步:接收消息和事件

当某些特定的用户操作引发事件推送时(如用户向小程序客服发送消息、或者进入会话等情况),微信服务器会将消息(或事件)的数据包以 POST 请求发送到开发者配置的 URL,开发者可以依据自身业务逻辑进行响应。

微信服务器在将用户的消息发给开发者服务器地址后,微信服务器在五秒内收不到响应会断掉连接,并且重新发起请求,总共重试三次。如果在调试中,发现用户无法收到响应的消息,可以检查是否消息处理超时。关于重试的消息排重,有 msgid 的消息推荐使用 msgid 排重。事件类型消息推荐使用 FromUserName + CreateTime 排重。

服务器收到请求必须做出下述回复,这样微信服务器才不会对此作任何处理,并且不会发起重试,否则,将出现严重的错误提示。详见下面说明:

  1. 直接回复success(推荐方式)
  2. 直接回复空串(指字节长度为0的空字符串,而不是结构体中content字段的内容为空)
  3. 若接口文档有指定返回内容,应按文档说明返回

对于客服消息,一旦遇到以下情况,微信会在小程序会话中向用户下发系统提示“该小程序客服暂时无法提供服务,请稍后再试”:

  1. 开发者在5秒内未回复任何内容
  2. 开发者回复了异常数据

如果开发者希望增强安全性,可以在开发者中心处开启消息加密,这样,用户发给小程序的消息以及小程序被动回复用户消息都会继续加密,详见消息加解密说明

Java 实例代码

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
private  Map<String, WxMaService> maServices = new HashMap<>();
private Map<String, WxMaMessageRouter> routers = new HashMap<>() ;
private byte[] imageBuffer;

@PostConstruct
public void init() {
maServices = this.properties.getConfigs().stream()
.map(a -> {
WxMaInMemoryConfig config = new WxMaInMemoryConfig();
config.setAppid(a.getAppid());
config.setSecret(a.getSecret());
config.setToken(a.getToken());
config.setAesKey(a.getAesKey());
config.setMsgDataFormat(a.getMsgDataFormat());
WxMaService service = new WxMaServiceImpl();
service.setWxMaConfig(config);

// 配置事件处理器
routers.put(a.getAppid(), this.newRouter(service));
return service;
}).collect(Collectors.toMap(s -> s.getWxMaConfig().getAppid(), a -> a));

try(InputStream is = new ClassPathResource("qrcode_for_gh_ac1de8498046_258.jpg").getInputStream()){
byte[] byteArray = IOUtils.toByteArray(is);
imageBuffer=byteArray;
}catch (Exception e) {
throw new RuntimeException(e);
}
}

private WxMaMessageRouter newRouter(WxMaService service) {
final WxMaMessageRouter router = new WxMaMessageRouter(service);
router.rule().handler(logHandler).next()
.rule().async(false).content("1").handler(picHandler).end();
return router;
}

private final WxMaMessageHandler logHandler = (wxMessage, context, service, sessionManager) -> {
log.info("收到消息:" + wxMessage.toString());
};

private final WxMaMessageHandler picHandler = (wxMessage, context, service, sessionManager) -> {
try (ByteArrayInputStream bais=new ByteArrayInputStream(imageBuffer)){
InputStream is = bais;
WxMediaUploadResult uploadResult = service.getMediaService()
.uploadMedia("image", "jpg", is);
service.getMsgService().sendKefuMsg(
WxMaKefuMessage
.newImageBuilder()
.mediaId(uploadResult.getMediaId())
.toUser(wxMessage.getFromUser())
.build());

service.getMsgService().sendKefuMsg(WxMaKefuMessage.newTextBuilder().content(KEFU_TEXT)
.toUser(wxMessage.getFromUser()).build());
} catch (Exception e) {
log.error("WxMaMessageHandler error:", e);
}
};
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

@PostMapping(produces = "application/xml; charset=UTF-8")
public String post(@PathVariable String appid,
@RequestBody String requestBody,
@RequestParam("msg_signature") String msgSignature,
@RequestParam("encrypt_type") String encryptType,
@RequestParam("signature") String signature,
@RequestParam("timestamp") String timestamp,
@RequestParam("nonce") String nonce) {
log.info("\n接收微信请求:[msg_signature=[{}], encrypt_type=[{}], signature=[{}]," +
" timestamp=[{}], nonce=[{}], requestBody=[\n{}\n] ",
msgSignature, encryptType, signature, timestamp, nonce, requestBody);

final WxMaService wxService = wxMaServiceConfiguration.getMaService(appid);

final boolean isJson = Objects.equals(wxService.getWxMaConfig().getMsgDataFormat(),
WxMaConstants.MsgDataFormat.JSON);
if (StringUtils.isBlank(encryptType)) {
// 明文传输的消息
WxMaMessage inMessage;
if (isJson) {
inMessage = WxMaMessage.fromJson(requestBody);
} else {//xml
inMessage = WxMaMessage.fromXml(requestBody);
}

this.route(inMessage, appid);
return "success";
}

if ("aes".equals(encryptType)) {
// 是aes加密的消息
WxMaMessage inMessage;
if (isJson) {
inMessage = WxMaMessage.fromEncryptedJson(requestBody, wxService.getWxMaConfig());
} else {//xml
inMessage = WxMaMessage.fromEncryptedXml(requestBody, wxService.getWxMaConfig(),
timestamp, nonce, msgSignature);
}

this.route(inMessage, appid);
return "success";
}

throw new RuntimeException("不可识别的加密类型:" + encryptType);
}

private void route(WxMaMessage message, String appid) {
try {
wxMaServiceConfiguration.getRouters().get(appid).route(message);
} catch (Exception e) {
log.error(e.getMessage(), e);
}
}

Resource

引子

事件描述

​ 开发环境:Mac, dea,Spring boot 1.5.3.RELEASE

​ 在开发微信小程序客服消息的时候,需要针对context为1的内容回复一张图片。

​ 因为,微信小程序相关的配置都是写在Common模板下的,所以就把图片放在Common模块下的resource中。

image-20190522164029948

​ IDEA中,使用以下代码都能拿到图片对象。

1
InputStream is = ClassLoader.getSystemResourceAsStream("qrcode_for_gh_ac1de8498046_258.jpg");
1
InputStream is = WxMaServiceConfiguration.class.getResourceAsStream("/qrcode_for_gh_ac1de8498046_258.jpg");

​ 当项目部署到服务器上的tomcat的时候,以上两个方法就都找不到图片对象了。

1
2
3
4
5
6
7
-webroot
-WEB-INF
-classes
-application.properties
......
-lib
-xxx-common-1.0.0-SNAPSHOT.jar(jarz中包含了图片)

​ 为什么开发环境可以加载,部署到服务器上就加载不了了呢?

分析

ClassLoader.getSystemResourceAsStream

java.lang.ClassLoader

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
67
68
69
70
71
72
73
74
public abstract class ClassLoader {

public static InputStream getSystemResourceAsStream(String name) {
URL url = getSystemResource(name);
try {
return url != null ? url.openStream() : null;
} catch (IOException e) {
return null;
}
}

public static URL getSystemResource(String name) {
ClassLoader system = getSystemClassLoader();
if (system == null) {
return getBootstrapResource(name);
}
return system.getResource(name);
}

@CallerSensitive
public static ClassLoader getSystemClassLoader() {
initSystemClassLoader();
if (scl == null) {
return null;
}
SecurityManager sm = System.getSecurityManager();
if (sm != null) {
checkClassLoaderPermission(scl, Reflection.getCallerClass());
}
return scl;
}

private static synchronized void initSystemClassLoader() {
if (!sclSet) {
if (scl != null)
throw new IllegalStateException("recursive invocation");
sun.misc.Launcher l = sun.misc.Launcher.getLauncher();
if (l != null) {
Throwable oops = null;
scl = l.getClassLoader();
try {
scl = AccessController.doPrivileged(
new SystemClassLoaderAction(scl));
} catch (PrivilegedActionException pae) {
oops = pae.getCause();
if (oops instanceof InvocationTargetException) {
oops = oops.getCause();
}
}
if (oops != null) {
if (oops instanceof Error) {
throw (Error) oops;
} else {
// wrap the exception
throw new Error(oops);
}
}
}
sclSet = true;
}
}
private static URL getBootstrapResource(String name) {
URLClassPath ucp = getBootstrapClassPath();
Resource res = ucp.getResource(name);
return res != null ? res.getURL() : null;
}

static URLClassPath getBootstrapClassPath() {
// Bootstrap 从 Launcher 的方法中初始化
return sun.misc.Launcher.getBootstrapClassPath();
}
......
}

sun.misc.Launcher

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
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90

public class Launcher {
private static URLStreamHandlerFactory factory = new Launcher.Factory();
private static Launcher launcher = new Launcher();
// {$JAVA_HOME}/jre/lib 和 {$JAVA_HOME}/jre/classes,Bootstrap ClassLoader的路径
private static String bootClassPath = System.getProperty("sun.boot.class.path");
private ClassLoader loader;

......

public Launcher() {
Launcher.ExtClassLoader var1;
try {
// ExtClassLoader
var1 = Launcher.ExtClassLoader.getExtClassLoader();
} catch (IOException var10) {
throw new InternalError("Could not create extension class loader", var10);
}

try {
// AppClassLoader
this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
} catch (IOException var9) {
throw new InternalError("Could not create application class loader", var9);
}

Thread.currentThread().setContextClassLoader(this.loader);
......

}

public static URLClassPath getBootstrapClassPath() {
return Launcher.BootClassPathHolder.bcp;
}

......

private static class BootClassPathHolder {
// URLClassPath
static final URLClassPath bcp;
private BootClassPathHolder() {}

static {
URL[] var0;
if (Launcher.bootClassPath != null) {
var0 = (URL[])AccessController.doPrivileged(new PrivilegedAction<URL[]>() {
public URL[] run() {
File[] var1 = Launcher.getClassPath(Launcher.bootClassPath);
int var2 = var1.length;
HashSet var3 = new HashSet();

for(int var4 = 0; var4 < var2; ++var4) {
File var5 = var1[var4];
if (!var5.isDirectory()) {
var5 = var5.getParentFile();
}

if (var5 != null && var3.add(var5)) {
MetaIndex.registerDirectory(var5);
}
}

return Launcher.pathToURLs(var1);
}
});
} else {
var0 = new URL[0];
}

bcp = new URLClassPath(var0, Launcher.factory, (AccessControlContext)null);
bcp.initLookupCache((ClassLoader)null);
}
}

static class AppClassLoader extends URLClassLoader {
public static ClassLoader getAppClassLoader(final ClassLoader var0) throws IOException {
final String var1 = System.getProperty("java.class.path");
final File[] var2 = var1 == null ? new File[0] : Launcher.getClassPath(var1);
return (ClassLoader)AccessController.doPrivileged(new PrivilegedAction<Launcher.AppClassLoader>() {
public Launcher.AppClassLoader run() {
URL[] var1x = var1 == null ? new URL[0] : Launcher.pathToURLs(var2);
return new Launcher.AppClassLoader(var1x, var0);
}
});
}
......
}
}


从上面代码可以知道,java.lang.ClassLoader#getSystemClassLoader 获取是 sun.misc.Launcher.AppClassLoader

java.lang.ClassLoader#getBootstrapResource 方法,返回的是 sun.misc.URLClassPath 对象。这个对象是在rt.jar包中的。

https://blog.csdn.net/briblue/article/details/54973413 详细看下 这里的classloader内容。

XXX.class.getResourceAsStream

WebappClassLoader 可以加载 jar中的资源文件

WebappClassLoader
context:
delegate: false
repositories:
/WEB-INF/classes/
———-> Parent Classloader:
org.apache.catalina.loader.StandardClassLoader@2145b572

1
2
3
ClassLoader.getSystemClassLoader().getClass()

class sun.misc.Launcher$AppClassLoader

Async

Async 使用

@Async的使用非常简单。只需在配置类上加上 @EnableAsync 就可以直接使用。

​ 如果需要指定线程池执行,需要在配置类中指定线程池别名。

1
2
3
4
5
6
7
8
9
10
11
12
@Configuration
@EnableAsync
public class AsyncExecutorQualifiedByNameConfig {

@Bean
@Qualifier("userAccountCreateEventAsyncThreadPool")
public Executor userAccountCreateEventAsyncThreadPool() {
//线程池
ThreadPoolExecutor poolExecutor = ThreadPoolGenerator.newCallerRunSyncQueuePool("userAccountCreateEventAsyncThreadPool", 20, 50);
return poolExecutor;
}
}
1
2
3
4
5
// 指定线程池异步执行
@Async("userAccountCreateEventAsyncThreadPool")
public void onApplicationEvent(UserAccountCreateEvent event) {
......
}

实现分析

​ 一般情况下都是这些@EnableXXX注解里面有一个@Import注解,然后这个@Import注解会导入一个类,这个被导入的类一般是个配置类(类上面有@Configuration注解),然后里面配置了需要用到的类的Bean。

org.springframework.scheduling.annotation.EnableAsync:

1
2
3
4
5
6
7
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import(AsyncConfigurationSelector.class)
public @interface EnableAsync {
......
}

org.springframework.scheduling.annotation.AsyncConfigurationSelector:

​ 该类实现了ImportSelector接口,最终会调用到AsyncConfigurationSelector实现的selectImports方法,这个方法可能返回 ProxyAsyncConfiguration 类的全限定名或者 org.springframework.scheduling.aspectj.AspectJAsyncConfiguration 或者null,如果不是null的话,后面会解析这些类上面的注解(两个类都是配置类,即类上面有@Configuration注解),然后将读取到的BeanDefinitino注册到BeanFactory

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class AsyncConfigurationSelector extends AdviceModeImportSelector<EnableAsync> {
private static final String ASYNC_EXECUTION_ASPECT_CONFIGURATION_CLASS_NAME =
"org.springframework.scheduling.aspectj.AspectJAsyncConfiguration";
@Override
@Nullable
public String[] selectImports(AdviceMode adviceMode) {
switch (adviceMode) {
case PROXY:
return new String[] {ProxyAsyncConfiguration.class.getName()};
case ASPECTJ:
return new String[] {ASYNC_EXECUTION_ASPECT_CONFIGURATION_CLASS_NAME};
default:
return null;
}
}
}

Spring事件监听

1 背景

​ 在业务开发中,经常是多人协同开发。常有”业务A完成后,需要通知业务B”的场景,最平常的写法就是直接在业务A后面增加业务B的调用。这样的缺点就是业务逻辑耦合,一旦业务C也需要业务A的结果时,就不得不在业务A后面新增调用业务C的代码。

​ 针对这种监听事件,第一时间想到的就是”观察者模式”。使用该模式,新增业务C监听的时候,不需要改动业务A的代码。做到了业务代码的解耦。

​ Spring重写了Java自带的事件监听。

org.springframework.context.ApplicationEvent 继承 java.util.EventObject

org.springframework.context.ApplicationListener 继承 java.util.EventListener

2 使用

​ springframework 中有许多内建的 ApplicationEvent,这里就不做过多的描述,主要针对自定义事件的使用。

2.1 自定义事件监听

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
public static void main(String[] args) {
// 创建 Spring 应用上下文 GenericApplicationContext
GenericApplicationContext context = new GenericApplicationContext();
// 注册 ApplicationListener<MyApplicationEvent> 实现 MyApplicationListener
context.registerBean(MyApplicationListener.class); // registerBean 方法从 Spring 5 引入
// 初始化上下文
context.refresh();
// 发布自定义事件 MyApplicationEvent
context.publishEvent(new MyApplicationEvent("Hello World"));
// 关闭上下文
context.close();
// 再次发布事件
context.publishEvent(new MyApplicationEvent("Hello World Again"));
}

public static class MyApplicationEvent extends ApplicationEvent {
public MyApplicationEvent(String source) {
super(source);
}
}

public static class MyApplicationListener implements ApplicationListener<MyApplicationEvent> {
@Override
public void onApplicationEvent(MyApplicationEvent event) {
System.out.println(event.getClass().getSimpleName());
}
}

上述是测试Demo,注意:ApplicationListener 必须是 Spring 管理的 Bean,并且监听方法必须是 public

正式项目中,可以按照如下格式使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Component
public class MyApplicationListener implements ApplicationListener<MyApplicationEvent> {
@Override
public void onApplicationEvent(MyApplicationEvent event) {
System.out.println(event.getClass().getSimpleName());
}
}

public class MyApplicationEvent extends ApplicationEvent {
public MyApplicationEvent(String source) {
super(source);
}
}

@Service
public class MyApplicationEventService{
@Autowired
private ApplicationEventPublisher applicationEventPublisher;
public void publishEvent(){
applicationEventPublisher.publishEvent(new MyApplicationEvent("Hello MyApplicationEvent"));
}
}

2.2 注解实现自定义事件监听

​ 自Spring4.2引入了 org.springframework.context.event.EventListener 注解。使用注解的话,上面中的 MyApplicationListener, 就可以按照如下改写。

1
2
3
4
5
6
7
@Component
public class MyApplicationListener{
@EventListener(MyApplicationEvent.class)
public void onApplicationEvent(MyApplicationEvent event) {
System.out.println(event.getClass().getSimpleName());
}
}

​ 并且,@EventListener 还支持继承,可以在抽象类上使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public abstract class AbstractEventListener {
@EventListener(ContextRefreshedEvent.class)
public void onContextRefreshedEvent(ContextRefreshedEvent event) {
System.out.println("AbstractEventListener : " + event.getClass().getSimpleName());
}
}

public class MyEventListener extends AbstractEventListener {
@EventListener(ContextClosedEvent.class)
public boolean onContextClosedEvent(ContextClosedEvent event) {
System.out.println("MyEventListener : " + event.getClass().getSimpleName());
return true;
}
}

​ 同时,@EventListener 还支持多事件监听。

1
2
3
4
5
6
7
8
9
10
11
12
13
public static class MyMultiEventsListener {
// 无参数监听 {@link ContextRefreshedEvent} 和 {@link ContextClosedEvent} 事件
@EventListener({ContextRefreshedEvent.class, ContextClosedEvent.class})
public void onEvent() {
System.out.println("onEvent");
}

// 单一 {@link ApplicationContextEvent} 参数监听 {@link ContextRefreshedEvent} 和 {@link ContextClosedEvent} 事件
@EventListener({ContextRefreshedEvent.class, ContextClosedEvent.class})
public void onApplicationContextEvent(ApplicationContextEvent event) {
System.out.println("onApplicationContextEvent : " + event.getClass().getSimpleName());
}
}

ApplicationContextEvent 同时是以上两个类的父类。

2.3 泛型事件监听

​ Spring还提供了针对泛型事件监听。泛型事件,必须实现org.springframework.core.ResolvableTypeProvider

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
/**
* 泛型事件
*
* @param <T> 泛型类型
*/
public class GenericEvent<T>
extends ApplicationEvent implements ResolvableTypeProvider {

public GenericEvent(T source) {
super(source);
}

@Override
public ResolvableType getResolvableType() {
return ResolvableType.forClassWithGenerics(getClass(),
ResolvableType.forInstance(getSource()));
}

@Override
public T getSource() {
return (T) super.getSource();
}
}

public class UserEventListener implements ApplicationListener<GenericEvent<User>> {

@EventListener
public void onUser(User user) {
System.out.println("onUser : " + user);
}

@EventListener
public void onUserEvent(GenericEvent<User> event) {
System.out.println("onUserEvent : " + event.getSource());
}

@Override
public void onApplicationEvent(GenericEvent<User> event) {
System.out.println("onApplicationEvent : " + event.getSource());
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

public static void main(String[] args) {
// 创建 注解驱动 Spring 应用上下文
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
// 注册 UserEventListener,即实现 ApplicationListener ,也包含 @EventListener 方法
context.register(UserEventListener.class);
// 初始化上下文
context.refresh();
// 构造泛型事件
GenericEvent<User> event = new GenericEvent(new User("用户1"));
// 发送泛型事件
context.publishEvent(event);
// 发送 User 对象作为事件源
context.publishEvent(new User("用户2"));
// 关闭上下文
context.close();
}

​ 针对泛型事件,在通知的时候,可以直接传入 泛型对象,也可以构建GenericEvent对象。

2.4 异步事件监听

使用 @Async 注解可以快速实现异步调用。当然,需要提前使用 @EnableAsync ,开启异步功能。

1
2
3
4
5
6
7
8
9
@EnableAsync // 需要激活异步,否则 @Async 无效
public class MyAsyncEventListener {
@EventListener(ContextRefreshedEvent.class)
@Async
public Boolean ontextRefreshedEvent(ContextRefreshedEvent event) {
println(" MyAsyncEventListener : " + event.getClass().getSimpleName());
return true;
}
}

也可以实现实现 org.springframework.scheduling.annotation.AsyncConfigurer自定义异步调用的线程池。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Configuration
@EnableAsync
public class AopConfig implements AsyncConfigurer {

@Override
public Executor getAsyncExecutor() {
//线程池
ThreadPoolExecutor poolExecutor = ThreadPoolGenerator.newCallerRunSyncQueuePool("AsyncThreadPool", 20, 50);
return poolExecutor;
}

@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return null;
}
}

3 分析

3.1 Spring 事件发布

3.1.1 ApplicationEventMulticaster 注册 ApplicationListener

​ 在org.springframework.context.ApplicationListener的注释上,ApplicationListener是依赖java.util.EventListener的 Observer 设计模式(观察者模式)。并,关联了org.springframework.context.event.ApplicationEventMulticaster接口。

1
2
3
4
5
6
7
8
9
10
// Application Event 多路广播 
public interface ApplicationEventMulticaster {
void addApplicationListener(ApplicationListener<?> listener);
void addApplicationListenerBean(String listenerBeanName);
void removeApplicationListener(ApplicationListener<?> listener);
void removeApplicationListenerBean(String listenerBeanName);
void removeAllListeners();
void multicastEvent(ApplicationEvent event);
void multicastEvent(ApplicationEvent event, @Nullable ResolvableType eventType);
}

从以上接口看,ApplicationEventMulticaster的主要职责是:

  1. 关联 ApplicationListener
  2. 广播 ApplicationEvent

​ 从ApplicationEventMulticaster的实现类来看,只有一个抽象类org.springframework.context.event.AbstractApplicationEventMulticaster和唯一实现类org.springframework.context.event.SimpleApplicationEventMulticaster

​ 在抽象类AbstractApplicationEventMulticaster中,你会发现有两个成员变量defaultRetrieverretrieverCacheorg.springframework.context.event.AbstractApplicationEventMulticaster.ListenerCacheKeyorg.springframework.context.event.AbstractApplicationEventMulticaster.ListenerRetriever都是AbstractApplicationEventMulticaster的内部类。

1
2
3
4
5
6
public abstract class AbstractApplicationEventMulticaster
implements ApplicationEventMulticaster, BeanClassLoaderAware, BeanFactoryAware {
private final ListenerRetriever defaultRetriever = new ListenerRetriever(false);
final Map<ListenerCacheKey, ListenerRetriever> retrieverCache = new ConcurrentHashMap<>(64);
......
}

​ 内部类ListenerCacheKey包含了两个属性,sourceTypeeventType

1
2
3
4
5
6
private static final class ListenerCacheKey implements Comparable<ListenerCacheKey> {
private final ResolvableType eventType;
@Nullable
private final Class<?> sourceType;
.....
}

​ 内部类ListenerRetriever包含这ApplicationListener的Set。

1
2
3
4
5
6
private class ListenerRetriever {
public final Set<ApplicationListener<?>> applicationListeners = new LinkedHashSet<>();
public final Set<String> applicationListenerBeans = new LinkedHashSet<>();
private final boolean preFiltered;
...
}

​ 这时候,就知道
AbstractApplicationEventMulticaster#defaultRetriever维护这所有的ApplicationListener;
AbstractApplicationEventMulticaster#retrieverCache 是以ListenerCacheKey为key,ListenerRetriever 为Value 的 Map。并且 ListenerRetriever 维护着ApplicationListener的Set。

​ 就是将defaultRetriever进行分类,存在在retrieverCache中。

3.1.2 AbstractApplicationEventMulticaster 广播事件

ApplicationEventMulticaster#multicastEvent(ApplicationEvent)方法在SimpleApplicationEventMulticaster中被重写了。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
public void multicastEvent(final ApplicationEvent event, @Nullable ResolvableType eventType) {
ResolvableType type = (eventType != null ? eventType : resolveDefaultEventType(event));
for (final ApplicationListener<?> listener : getApplicationListeners(event, type)) {
Executor executor = getTaskExecutor();
if (executor != null) {
executor.execute(() -> invokeListener(listener, event));
}
else {
invokeListener(listener, event);
}
}
}

SimpleApplicationEventMulticaster在广播的时候,会先判断executor是否为空,为空才同步调用。可以通过SimpleApplicationEventMulticaster#setTaskExecutor方法初始化。

3.1.3 ApplicationEventMulticaster 与 ApplicationContext 之间的关系

​ Spring Framework 官方文档提到开发人员可使用ApplicationEventPublisher发布ApplicationEvent

1
2
3
4
5
6
7
@FunctionalInterface
public interface ApplicationEventPublisher {
default void publishEvent(ApplicationEvent event) {
publishEvent((Object) event);
}
void publishEvent(Object event);
}

​ 从接口上看,ApplicationEventPublisher貌似和ApplicationEventMulticaster 没有什么关联。只有发布的接口,没有关联ApplicationListener的接口。

​ 不过ApplicationEventPublisherApplicationContext继承,因此,无论哪种ApplicationContext都具备发布ApplicationEvent的能力。

​ 要如何得到ApplicationEventPublisher实例?官方文档提示,实现ApplicationEventPublisherAware接口。也可以直接使用@Autowired注解自动导入。

在 Spring Framework 中,Aware Bean 生命周期回调均有ApplicationContextAwareProcessor处理。

ApplicationEventPublisherApplicationContext继承,自动注入的ApplicationEventPublisher实例,就是ApplicationContext实例。AbstractApplicationContext完全实现了ApplicationEventPublisher接口。

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
/** Helper class used in event publishing */
@Nullable
private ApplicationEventMulticaster applicationEventMulticaster;

protected void publishEvent(Object event, @Nullable ResolvableType eventType) {
Assert.notNull(event, "Event must not be null");
if (logger.isTraceEnabled()) {
logger.trace("Publishing event in " + getDisplayName() + ": " + event);
}

ApplicationEvent applicationEvent;
if (event instanceof ApplicationEvent) {
applicationEvent = (ApplicationEvent) event;
}
else {
applicationEvent = new PayloadApplicationEvent<>(this, event);
if (eventType == null) {
eventType = ((PayloadApplicationEvent<?>) applicationEvent).getResolvableType();
}
}

if (this.earlyApplicationEvents != null) {
this.earlyApplicationEvents.add(applicationEvent);
}
else {
getApplicationEventMulticaster().multicastEvent(applicationEvent, eventType);
}

if (this.parent != null) {
if (this.parent instanceof AbstractApplicationContext) {
((AbstractApplicationContext) this.parent).publishEvent(event, eventType);
}
else {
this.parent.publishEvent(event);
}
}
}

ApplicationEventMulticaster getApplicationEventMulticaster() throws IllegalStateException {
if (this.applicationEventMulticaster == null) {
throw new IllegalStateException("ApplicationEventMulticaster not initialized - " +
"call 'refresh' before multicasting events via the context: " + this);
}
return this.applicationEventMulticaster;
}

applicationEventMulticaster属性由initApplicationEventMulticaster()方法初始化,并且该方法被refresh()调用。

​ 因此ConfigurableApplicationContext能够发布事件,管理事件,是复用了ApplicationEventMulticaster

总结:

ConfigurableApplicationContext继承 ApplicationEventPublisher

ApplicationEventPublisher拥有发布ApplicationEvent的接口。

AbstractApplicationContext实现 ConfigurableApplicationContext接口,增加成员变量ApplicationEventMulticaster,利用ApplicationEventMulticaster实现ApplicationEventPublisher的功能。

ApplicationEventMulticaster负责管理ApplicationListener

3.2 Spring 自定义事件

​ 根据官方文档的提示,自定义 Spring 事件需要扩展ApplicationEvent接口,然后由ApplicationEventPublisher#publishEvent(java.lang.Object)发布即可。

3.3 Spring 事件监听

​ Spring 事件监听接口 ApplicationListener是事件监听的常见手段。在此基础上,Spring 4.2开始引入@EventListener注解。

3.3.1 ApplicationListener监听原理

​ 根据上面的内容,以知发布事件,调用的是SimpleApplicationEventMulticaster#multicastEvent(ApplicationEvent)。并且SimpleApplicationEventMulticaster可关联Executor实现异步事件广播。然后,SimpleApplicationEventMulticaster默认采用同步广播。根据ApplicationEvent具体类型查找匹配的ApplicationListener列表,然后逐一同步或异步调用ApplicationListener#onApplicationEvent方法,实现ApplicationListener的监听。

3.3.2 @EventListener 方法实现原理

​ 从 Spring Framework 4.2 开始,框架层面在 AnnotationConfigUtils#registerAnnotationConfigProcessors(BeanDefinitionRegistry, Object)方法中增加了@EventListener方法处理的相关Bean的注册:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static final String EVENT_LISTENER_PROCESSOR_BEAN_NAME =
"org.springframework.context.event.internalEventListenerProcessor";
public static final String EVENT_LISTENER_FACTORY_BEAN_NAME =
"org.springframework.context.event.internalEventListenerFactory";

public static Set<BeanDefinitionHolder> registerAnnotationConfigProcessors(
BeanDefinitionRegistry registry, @Nullable Object source) {

......

if (!registry.containsBeanDefinition(EVENT_LISTENER_PROCESSOR_BEAN_NAME)) {
RootBeanDefinition def = new RootBeanDefinition(EventListenerMethodProcessor.class);
def.setSource(source);
beanDefs.add(registerPostProcessor(registry, def, EVENT_LISTENER_PROCESSOR_BEAN_NAME));
}

if (!registry.containsBeanDefinition(EVENT_LISTENER_FACTORY_BEAN_NAME)) {
RootBeanDefinition def = new RootBeanDefinition(DefaultEventListenerFactory.class);
def.setSource(source);
beanDefs.add(registerPostProcessor(registry, def, EVENT_LISTENER_FACTORY_BEAN_NAME));
}

return beanDefs;
}

​ 其中EventListenerMethodProcessor@EventListener方法的生命周期处理器,而DefaultEventListenerFactory则是@EventListener方法与ApplicationListener适配器的工程类。

EventListenerMethodProcessor作为SmartInitializingSingleton接口实现。

3.3.2.1 SmartInitializingSingleton生命周期回调
1
2
3
public interface SmartInitializingSingleton {
void afterSingletonsInstantiated();
}

​ 通过调用关系可以发现,SmartInitializingSingleton#afterSingletonsInstantiated()方法将在DefaultListableBeanFactory#preInstantiateSingletons()方法执行时调用。

​ 同时,从完整的Spring应用上下文生命周期分析,以上preInstantiateSingletons()方法处于AbstractApplicationContext#finishBeanFactoryInitialization(ConfigurableListableBeanFactory)调用周期的最后一个操作,并且也接近于refresh()方法的完成阶段。

3.3.2.2 EventListenerMethodProcessor的实现原理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Override
public void afterSingletonsInstantiated() {
List<EventListenerFactory> factories = getEventListenerFactories();
ConfigurableApplicationContext context = getApplicationContext();
String[] beanNames = context.getBeanNamesForType(Object.class);
for (String beanName : beanNames) {
......
try {
processBean(factories, beanName, type);
}
......
}
}

protected List<EventListenerFactory> getEventListenerFactories() {
Map<String, EventListenerFactory> beans = getApplicationContext().getBeansOfType(EventListenerFactory.class);
List<EventListenerFactory> factories = new ArrayList<>(beans.values());
AnnotationAwareOrderComparator.sort(factories);
return factories;
}

​ 按照 afterSingletonsInstantiated()方法的生命周期,getEventListenerFactories()方法根据类型在Spring应用上下文中查找所有EventListenerFactory Bean的操作不会引起过早初始化的问题。

EventListenerMethodProcessor#processBean方法相对简单,首先从指定Bean类型中超找所有标注@EventListener的方法,由于AnnotatedElementUtils#findMergedAnnotation语言的作用,所有方法筛选的规则是仅判断Bean中所有public方法是否标注@EventListener

​ 然后,EventListenerMethodProcessor将候选的@EventListener方法集合逐一经过EventListenerFactory实例列表(首参)的匹配。而默认情况下,EventListenerFactory实例列表仅为DefaultEventListenerFactory对象,故将@EventListener方法适配为ApplicationListener对象。

1
2
3
4
5
6
7
8
9
10
11
public class DefaultEventListenerFactory implements EventListenerFactory, Ordered {
......
public boolean supportsMethod(Method method) {
return true;
}

@Override
public ApplicationListener<?> createApplicationListener(String beanName, Class<?> type, Method method) {
return new ApplicationListenerMethodAdapter(beanName, type, method);
}
}

​ 因此,@EventListener方法实现的确与ApplicationListener有关。

Hello World

Welcome to Hexo! This is your very first post. Check documentation for more info. If you get any problems when using Hexo, you can find the answer in troubleshooting or you can ask me on GitHub.

Quick Start

Create a new post

1
$ hexo new "My New Post"

More info: Writing

Run server

1
$ hexo server

More info: Server

Generate static files

1
$ hexo generate

More info: Generating

Deploy to remote sites

1
$ hexo deploy

More info: Deployment