最近在做一个微信预约洗车的项目,其中有个功能是预约完成后给用户发一个模板消息,发送模板消息需要AccessToken以及json格式的消息内容,接口如下。
发送模板消息
接口调用请求说明
http请求方式: POST https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=ACCESS_TOKEN
POST数据说明
POST数据示例如下:
{ "touser":"OPENID", "template_id":"ngqIpbwh8bUfcSsECmogfXcV14J0tQlEpBO27izEYtY", "url":"http://weixin.qq.com/download", "data":{ "first": { "value":"恭喜你购买成功!", "color":"#173177" }, "keynote1":{ "value":"巧克力", "color":"#173177" }, "keynote2": { "value":"39.8元", "color":"#173177" }, "keynote3": { "value":"2014年9月22日", "color":"#173177" }, "remark":{ "value":"欢迎再次购买!", "color":"#173177" } } }
返回码说明
在调用模板消息接口后,会返回JSON数据包。正常时的返回JSON数据包示例:
{ "errcode":0, "errmsg":"ok", "msgid":200228332 }
我而同事已经写过这个功能了,索性就直接拿来用了。但是在使用的过程中,发现第一次可以成功发送模板消息,第二次就返回 errcode 40001,token验证失败。
关于微信AccessToken的介绍:
access_token是公众号的全局唯一票据,公众号调用各接口时都需使用access_token。开发者需要进行妥善保存。access_token的存储至少要保留512个字符空间。access_token的有效期目前为2个小时,需定时刷新,重复获取将导致上次获取的access_token失效。(注:获取access_token接口的每日调用限额为2000次)
初步怀疑是不是别的地方更新了AccessToken,于是我打开他的代码,如下(伪代码):
public String getAccessToken(){
String token = (String)request.getSession().get(Const.ACCESS_TOKEN);
if(token 为空){
toekn = getTokenFormWx();
request.getSession().add(Const.ACCESS_TOKEN,token).
return token;
}
return token;
}
这样写看起来好像没什么问题,也不是每次都去获取一个新的access_token。但他忽略了一点,session并不是只有一份的,系统为每个会话都创建一个单独的session,最后调用getAccessToken的会话让其他会话的session中的access_token都失效了。
我决定动手把代码修改了一下,因为access_token的有效时间是7200秒,当时想着也放在redis里面好了,可以利用redis的自动过期来保证access_token的有效性,但是项目中没有使用redis,加进来也是大材小用了,最后想想还是放在了ServletContext里面。
ServletContext,是一个全局的储存信息的空间,服务器开始,其就存在,服务器关闭,其才释放。request,一个用户可有多个;session,一个用户一个;而servletContext,所有用户共用一个。所以,为了节省空间,提高效率,ServletContext中,要放必须的、重要的、所有用户需要共享的线程又是安全的一些信息。
于是就有了下面这段代码(伪)
public String getAccessToken(){
Map cacheMap = request.getServletContext().getAttr(Const.WX_TOKEN_MAP);
if(cacheMap==null || System.currentTimeMillis()-(Date)cacheMap.get(Const.WX_TOKEN_TIME).getTime()>1000*7000){
cacheMap = new HashMap<>();
String token = getTokenFormWx();
if(token 为空){
throw new RuntimeException("AccessToken is null");
}
cacheMap.put(Const.WX_TOKEN_VAL,token);
cacheMap.put(Const.WX_TOKEN_TIME,new Date());
}
return (String)cacheMap.get(Const.WX_TOKEN_VAL);
}
这样看起来好像是比之前的代码好了一点,不会为没一个会话都创建一个access_token,而且保证了时效性。但其实还是存在一点问题的,假如有两个线程同时调用了这一个方法,其中第一个线程进了if在调用getTokenFormWx()的时候因为网络或者其他原因等在这里了,第二个线程来了还是进了if,并且成功的调用getTokenFormWx()返回了token给调用者处理业务逻辑,这时候第一个线程执行完毕,刷新了token,这样就导致了第二个线程的token已经失效,在处理业务逻辑的时候必然失败。
我们有没有办法避免这个问题呢?当然是有的。
你想我直接使用synchronized好了,加在方法上,这样就不会错了。于是方法就变成了这样
public synchronized String getAccessToken(){
Map cacheMap = request.getServletContext().getAttr(Const.WX_TOKEN_MAP);
if(cacheMap==null || System.currentTimeMillis()-(Date)cacheMap.get(Const.WX_TOKEN_TIME).getTime()>1000*4800){
cacheMap = new HashMap<>();
String token = getTokenFormWx();
if(token 为空){
throw new RuntimeException("AccessToken is null");
}
cacheMap.put(Const.WX_TOKEN_VAL,token);
cacheMap.put(Const.WX_TOKEN_TIME,new Date());
}
return (String)cacheMap.get(Const.WX_TOKEN_VAL);
}
这样是能解决问题,但是解决问题代价也太大了,每一个线程想要获取这个token就得等其他线程全部获取完才能拿到,大大降低了效率,不可行的。所以再次改动代码,变成了下面这样。
public String getAccessToken(){
Map cacheMap = request.getServletContext().getAttr(Const.WX_TOKEN_MAP);
if(cacheMap==null || System.currentTimeMillis()-(Date)cacheMap.get(Const.WX_TOKEN_TIME).getTime()>1000*4800){
synchronized(this){
if(cacheMap==null || System.currentTimeMillis()-(Date)cacheMap.get(Const.WX_TOKEN_TIME).getTime()>1000*4800){
cacheMap = new HashMap<>();
String token = getTokenFormWx();
if(token 为空){
throw new RuntimeException("AccessToken is null");
}
cacheMap.put(Const.WX_TOKEN_VAL,token);
cacheMap.put(Const.WX_TOKEN_TIME,new Date());
}
}
}
return (String)cacheMap.get(Const.WX_TOKEN_VAL);
}
当第一个线程进了if之后,执行synchronized里面的代码,等待在了getTokenFormWx(),第二个线程也进了if,但由于加了synchronized,所以会等待在那里,等第一个线程处理完它才能执行,第一个线程执行完毕之后返回token去执行业务逻辑,第二个线程进入synchronized代码块,执行这里面的if判断,由于第一个线程已经成功获取token并且刷新了ServletContext中的cacheMap,条件已经不满足,所以第二个线程是无法执行这个if里面的代码了,到此我们就设计了一个线程安全的获取access_token方案。
看样子好像一切都ok了,但是在测试后还是会出现一样的问题。
我又仔细检查了两遍代码,还是没有发现有问题的地方。找不到错误的地方,我决定开始试错。
第一次,我把https://api.weixin.qq.com/cgi...改成https://api.weixin.qq.com/cgi...
参数access_token放入post请求参数里面,其他参数放进request body里面。
结果:第一次就返回了40001 access_token无效。
第二次,我把https://api.weixin.qq.com/cgi...改成https://api.weixin.qq.com/cgi...
参数access_token放入post请求参数里面并使用trim()去除空格,其他参数放进request body里面。
结果:第一次就返回了40001 access_token无效。
第三次,我把https://api.weixin.qq.com/cgi...
其他参数放进request body里面。
结果:一切ok。。。。
为什么会多了空格?我也很想知道,但由于调试了太久时间,已经很晚了,而第二天就是假期,所以我也就没有深究了。
那为什么第二次和第三次都对ACCESS_TOKEN进行了去空格处理,为什么返回的结果却不一样呢?
这就得不得不说一下Http协议了,但这里不需要讲太多,所以我们只说一下Http协议之请求消息Request。
客户端发送一个HTTP请求到服务器的请求消息包括以下格式:
请求行(request line)、请求头部(header)、空行和请求数据四个部分组成。
图片描述
Get请求例子(java按得票排序)
GET https://segmentfault.com/t/java?type=votes HTTP/1.1
Host: segmentfault.com
Connection: keep-alive
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.104 Safari/537.36 Core/1.53.2372.400 QQBrowser/9.5.10548.400
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,/;q=0.8
Referer: https://segmentfault.com/t/java
Accept-Encoding: gzip, deflate, sdch, br
Accept-Language: zh-CN,zh;q=0.8
Cookie: 这个我就不贴出来了
Post请求例子(添加笔记)
POST https://segmentfault.com/api/notes/add?_=6e0a1202503bc4d86e63672cff567b81 HTTP/1.1
Host: segmentfault.com
Connection: keep-alive
Content-Length: 139
Accept: application/json, text/javascript, /; q=0.01
Origin: https://segmentfault.com
X-Requested-With: XMLHttpRequest
User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.104 Safari/537.36 Core/1.53.2372.400 QQBrowser/9.5.10548.400
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Referer: https://segmentfault.com/record
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.8
Cookie: 这个真的不能贴
title=%E6%B5%8B%E8%AF%95%E7%AC%94%E8%AE%B0&text=%E6%B5%8B%E8%AF%95%E7%AC%94%E8%AE%B0&id=&draftId=1220000008931250&isPrivate=0&language=text
对比一下你发现了什么?
get请求参数在url后面,使用?当作标志,多个参数使用&分割 类似?a=1&b=2
post参数在请求头部空一行的后面 类似 a=1&b=2
那post提交的json串在哪个位置呢?
其实你已经知道啦,也是在请求头部空一行的后面 不过是以json的格式,而服务器内部使用&分割参数,使得开发者可以使用getParameter获取提交的参数,而其他类型的参数(例如json串和xml)开发者可以使用getInputStream来读取到参数然后自己解析。
那post请求能否把参数写在url后面呢?就像 post?a=1&b=2
答案是可以的,服务器可以成功解析到。
那get请求能把参数写在request body里面吗?
答案是否定的,服务器对get请求只解析url后面的,request body里面的他不关心。
那你发送模板消息的参数为什么写在request body里面就不行呢?
我也不知道微信内部是怎么做的,但是我觉得吧,微信之所以要把access_token写在url后面,因为这个接口request body里面是模板消息的json串 如果再把access_token加进去 数据大概会是这样
access_toke=xxxxxxxxxxx {"touser":"OPENID","template_id":"ngqIpbwh8bUfcSsECmogfXcV14J0tQlEpBO27izEYtY", "url":"http://weixin.qq.com/download", ... }
微信方面也不好分割这个串,于是他们觉得要这个access_token写在url后面,他们获取到url后再手动分割处理,request body里面就只放纯json串,解析起来也很方便。这就是为什么我第二次操作失败的原因啦。
第一次写技术类得文章,文笔不好多多见谅。