一个AccessToken引起的思考

最近在作一个微信预定洗车的项目,其中有个功能是预定完成后给用户发一个模板消息,发送模板消息须要AccessToken以及json格式的消息内容,接口以下。javascript

发送模板消息 html

接口调用请求说明java

http请求方式: POST
 
https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=ACCESS_TOKEN

POST数听说明web

POST数据示例以下:redis

{
       "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数据包。正常时的返回JSON数据包示例:segmentfault

{
       "errcode":0,
       "errmsg":"ok",
       "msgid":200228332
   }

我而同事已经写过这个功能了,索性就直接拿来用了。可是在使用的过程当中,发现第一次能够成功发送模板消息,第二次就返回 errcode 40001,token验证失败。api

关于微信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串,解析起来也很方便。这就是为何我第二次操做失败的缘由啦。

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

相关文章
相关标签/搜索