2

最近在做一个微信预约洗车的项目,其中有个功能是预约完成后给用户发一个模板消息,发送模板消息需要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<String,Object> 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<String,Object> 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<String,Object> 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串,解析起来也很方便。这就是为什么我第二次操作失败的原因啦。

第一次写技术类得文章,文笔不好多多见谅。


整点bug
696 声望24 粉丝

[链接]