上一篇《微信开发学习总结(一)——微信开发环境搭建》我们已经完成了微信开发的准备工作,准备工作完成之后,就要开始步入正题了。
在开始做之前,先简单介绍了微信公众平台的基本原理。
微信服务器就相当于一个转发服务器,终端(手机、Pad等)发起请求至微信服务器,微信服务器然后将请求转发给我们的应用服务器。应用服务器处理完毕后,将响应数据回发给微信服务器,微信服务器再将具体响应信息回复到微信App终端。
通信协议为:HTTP
数据传输格式为:XML
具体的流程如下图所示:
来一张更加直观的图吧:
我们需要做的事情,就是对微信服务器转发的HTTP请求做出响应。具体的请求内容,我们按照特定的XML格式去解析,处理完毕后,也要按照特定的XML格式返回。
在微信公众平台开发者文档上,关于公众号接入这一节内容在接入指南上写的比较详细的,文档中说接入公众号需要3个步骤,分别是:
1、填写服务器配置
2、验证服务器地址的有效性
3、依据接口文档实现业务逻辑
其实,第3步已经不能算做公众号接入的步骤,而是接入之后,开发人员可以根据微信公众号提供的接口所能做的一些开发。
第1步中服务器配置包含服务器地址(URL)、Token和EncodingAESKey。
服务器地址即公众号后台提供业务逻辑的入口地址,目前只支持80端口,之后包括接入验证以及任何其它的操作的请求(例如消息的发送、菜单管理、素材管理等)都要从这个地址进入。接入验证和其它请求的区别就是,接入验证时是get请求,其它时候是post请求;
Token可由开发者可以任意填写,用作生成签名(该Token会和接口URL中包含的Token进行比对,从而验证安全性);
EncodingAESKey由开发者手动填写或随机生成,将用作消息体加解密密钥。本例中全部以未加密的明文消息方式,不涉及此配置项。
第2步,验证服务器地址的有效性,当点击“提交”按钮后,微信服务器将发送一个http的get请求到刚刚填写的服务器地址,并且携带四个参数:
接到请求后,我们需要做如下三步,若确认此次GET请求来自微信服务器,原样返回echostr参数内容,则接入生效,否则接入失败。
1. 将token、timestamp、nonce三个参数进行字典序排序
2. 将三个参数字符串拼接成一个字符串进行sha1加密
3. 开发者获得加密后的字符串可与signature对比,标识该请求来源于微信
下面我们用Java代码来演示一下这个验证过程
使用IDE(Eclipse或者IntelliJ IDEA)创建一个JavaWeb项目,这里我使用的是IntelliJ IDEA,项目目录结构如下图所示:
编写一个servlevt,在其中的doGet方法中定义校验方法,具体代码如下:
1 package me.gacl.wx.web.servlet; 2 3 import javax.servlet.ServletException; 4 import javax.servlet.annotation.WebServlet; 5 import javax.servlet.http.HttpServlet; 6 import javax.servlet.http.HttpServletRequest; 7 import javax.servlet.http.HttpServletResponse; 8 import java.io.IOException; 9 import java.security.MessageDigest; 10 import java.security.NoSuchAlgorithmException; 11 import java.util.Arrays; 12 13 /** 14 * Created by xdp on 2016/1/25. 15 * 使用@WebServlet注解配置WxServlet,urlPatterns属性指明了WxServlet的访问路径 16 */ 17 @WebServlet(urlPatterns="/WxServlet") 18 public class WxServlet extends HttpServlet { 19 20 /** 21 * Token可由开发者可以任意填写,用作生成签名(该Token会和接口URL中包含的Token进行比对,从而验证安全性) 22 * 比如这里我将Token设置为gacl 23 */ 24 private final String TOKEN = "gacl"; 25 26 protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { 27 28 } 29 30 protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { 31 System.out.println("开始校验签名"); 32 /** 33 * 接收微信服务器发送请求时传递过来的4个参数 34 */ 35 String signature = request.getParameter("signature");//微信加密签名signature结合了开发者填写的token参数和请求中的timestamp参数、nonce参数。 36 String timestamp = request.getParameter("timestamp");//时间戳 37 String nonce = request.getParameter("nonce");//随机数 38 String echostr = request.getParameter("echostr");//随机字符串 39 //排序 40 String sortString = sort(TOKEN, timestamp, nonce); 41 //加密 42 String mySignature = sha1(sortString); 43 //校验签名 44 if (mySignature != null && mySignature != "" && mySignature.equals(signature)) { 45 System.out.println("签名校验通过。"); 46 //如果检验成功输出echostr,微信服务器接收到此输出,才会确认检验完成。 47 //response.getWriter().println(echostr); 48 response.getWriter().write(echostr); 49 } else { 50 System.out.println("签名校验失败."); 51 } 52 53 } 54 55 /** 56 * 排序方法 57 * 58 * @param token 59 * @param timestamp 60 * @param nonce 61 * @return 62 */ 63 public String sort(String token, String timestamp, String nonce) { 64 String[] strArray = {token, timestamp, nonce}; 65 Arrays.sort(strArray); 66 StringBuilder sb = new StringBuilder(); 67 for (String str : strArray) { 68 sb.append(str); 69 } 70 71 return sb.toString(); 72 } 73 74 /** 75 * 将字符串进行sha1加密 76 * 77 * @param str 需要加密的字符串 78 * @return 加密后的内容 79 */ 80 public String sha1(String str) { 81 try { 82 MessageDigest digest = MessageDigest.getInstance("SHA-1"); 83 digest.update(str.getBytes()); 84 byte messageDigest[] = digest.digest(); 85 // Create Hex String 86 StringBuffer hexString = new StringBuffer(); 87 // 字节数组转换为 十六进制 数 88 for (int i = 0; i < messageDigest.length; i++) { 89 String shaHex = Integer.toHexString(messageDigest[i] & 0xFF); 90 if (shaHex.length() < 2) { 91 hexString.append(0); 92 } 93 hexString.append(shaHex); 94 } 95 return hexString.toString(); 96 97 } catch (NoSuchAlgorithmException e) { 98 e.printStackTrace(); 99 } 100 return ""; 101 } 102 }
我这里用的Servlet3.0,使用Servlet3.0的好处就是可以直接使用@WebServlet注解映射Servlet的访问路径,不再需要在web.xml文件中进行配置.
将WxStudy项目部署到Tomcat服务器中运行,直接启动项目,然后用ngrok将本地8080端口映射到外网(如何使用ngrok请参考博客《微信开发学习总结(一)——微信开发环境搭建》)。如下图所示:
测试是否可以通过http://xdp.ngrok.natapp.cn地址正常访问,测试结果如下:
可以看到,我们的项目已经可以被外网正常访问到了。
进入微信测试公众号管理界面,在接口配置信息中填入映射的外网地址和token,如下图所示:
点击提交按钮,页面会提示配置成功,
IDE的控制台中输出了校验通过的信息,如下图所示:
到此,我们的公众号应用已经能够和微信服务器正常通信了,也就是说我们的公众号已经接入到微信公众平台了。
我们的公众号和微信服务器对接成功之后,接下来要做的就是根据我们的业务需求调用微信公众号提供的接口来实现相应的逻辑了。在使用微信公众号接口中都需要一个access_token。
关于access_token,在微信公众平台开发者文档上的获取接口调用凭据有比较详细的介绍:access_token是公众号的全局唯一票据,公众号调用各接口时都需使用access_token,开发者需要妥善保存access_token的存储至少要保留512个字符空间。access_token的有效期目前为2个小时,需定时刷新,重复获取将导致上次获取的access_token失效。并且每天调用获取access_token接口的上限是2000次。
总结以上说明,access_token需要做到以下两点:
1.因为access_token有2个小时的时效性,要有一个机制保证最长2个小时重新获取一次。
2.因为接口调用上限每天2000次,所以不能调用太频繁。
关于access_token的获取方式,在微信公众平台开发者文档上有说明,公众号可以调用一个叫"获取access token"的接口来获取access_token。
获取access token接口调用请求说明
http请求方式: GET
请求的URL地址:https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&secret=APPSECRET
我们可以看到,调用过程中需要传递appID和AppSecret,appID和AppSecret是在申请公众号的时候自动分配给公众号的,相当于公众号的身份标示,使用微信公众号的注册帐号登录到腾讯提供的微信公众号管理后台就可以看到自己申请的公众号的AppID和AppSecret,如下图所示:
这是我申请公众号测试帐号时分配到的AppID和AppSecret。
这里采用的方案是这样的,定义一个默认启动的servlet,在init方法中启动一个Thread,这个进程中定义一个无限循环的方法,用来获取access_token,当获取成功后,此进程休眠7000秒(7000秒=1.944444444444444小时),否则休眠3秒钟继续获取。流程图如下:
下面正式开始在工程中实现以上思路,因为返回的数据都是json格式,这里会用到阿里的fastjson库,为构造请求和处理请求后的数据序列化和反序列化提供支持。
1.定义一个AccessToken实体类
1 package me.gacl.wx.entry; 2 3 /** 4 * AccessToken的数据模型 5 * Created by xdp on 2016/1/25. 6 */ 7 public class AccessToken { 8 9 //获取到的凭证 10 private String accessToken; 11 //凭证有效时间,单位:秒 12 private int expiresin; 13 14 public String getAccessToken() { 15 return accessToken; 16 } 17 18 public void setAccessToken(String accessToken) { 19 this.accessToken = accessToken; 20 } 21 22 public int getExpiresin() { 23 return expiresin; 24 } 25 26 public void setExpiresin(int expiresin) { 27 this.expiresin = expiresin; 28 } 29 }
2.定义一个AccessTokenInfo类,用于存放获取到的AccessToken,代码如下:
1 package me.gacl.wx.Common; 2 3 import me.gacl.wx.entry.AccessToken; 4 5 /** 6 * Created by xdp on 2016/1/25. 7 */ 8 public class AccessTokenInfo { 9 10 //注意是静态的 11 public static AccessToken accessToken = null; 12 }
3.编写一个用于发起https请求的工具类NetWorkHelper,代码如下:
1 package me.gacl.wx.util; 2 3 import javax.net.ssl.*; 4 import java.io.BufferedReader; 5 import java.io.InputStream; 6 import java.io.InputStreamReader; 7 import java.net.URL; 8 import java.security.cert.CertificateException; 9 import java.security.cert.X509Certificate; 10 11 /** 12 * 访问网络用到的工具类 13 */ 14 public class NetWorkHelper { 15 16 /** 17 * 发起Https请求 18 * @param reqUrl 请求的URL地址 19 * @param requestMethod 20 * @return 响应后的字符串 21 */ 22 public String getHttpsResponse(String reqUrl, String requestMethod) { 23 URL url; 24 InputStream is; 25 String resultData = ""; 26 try { 27 url = new URL(reqUrl); 28 HttpsURLConnection con = (HttpsURLConnection) url.openConnection(); 29 TrustManager[] tm = {xtm}; 30 31 SSLContext ctx = SSLContext.getInstance("TLS"); 32 ctx.init(null, tm, null); 33 34 con.setSSLSocketFactory(ctx.getSocketFactory()); 35 con.setHostnameVerifier(new HostnameVerifier() { 36 @Override 37 public boolean verify(String arg0, SSLSession arg1) { 38 return true; 39 } 40 }); 41 42 43 con.setDoInput(true); //允许输入流,即允许下载 44 45 //在android中必须将此项设置为false 46 con.setDoOutput(false); //允许输出流,即允许上传 47 con.setUseCaches(false); //不使用缓冲 48 if (null != requestMethod && !requestMethod.equals("")) { 49 con.setRequestMethod(requestMethod); //使用指定的方式 50 } else { 51 con.setRequestMethod("GET"); //使用get请求 52 } 53 is = con.getInputStream(); //获取输入流,此时才真正建立链接 54 InputStreamReader isr = new InputStreamReader(is); 55 BufferedReader bufferReader = new BufferedReader(isr); 56 String inputLine; 57 while ((inputLine = bufferReader.readLine()) != null) { 58 resultData += inputLine + "\n"; 59 } 60 System.out.println(resultData); 61 62 } catch (Exception e) { 63 e.printStackTrace(); 64 } 65 return resultData; 66 } 67 68 X509TrustManager xtm = new X509TrustManager() { 69 @Override 70 public X509Certificate[] getAcceptedIssuers() { 71 return null; 72 } 73 74 @Override 75 public void checkServerTrusted(X509Certificate[] arg0, String arg1) 76 throws CertificateException { 77 78 } 79 80 @Override 81 public void checkClientTrusted(X509Certificate[] arg0, String arg1) 82 throws CertificateException { 83 84 } 85 }; 86 }
getHttpsResponse方法是请求一个https地址,参数requestMethod为字符串“GET”或者“POST”,传null或者“”默认为get方式。
4.定义一个默认启动的servlet,在init方法中启动一个新的线程去获取accessToken
1 package me.gacl.wx.web.servlet; 2 3 import com.alibaba.fastjson.JSON; 4 import com.alibaba.fastjson.JSONObject; 5 import me.gacl.wx.Common.AccessTokenInfo; 6 import me.gacl.wx.entry.AccessToken; 7 import me.gacl.wx.util.NetWorkHelper; 8 9 import javax.servlet.ServletException; 10 import javax.servlet.annotation.WebInitParam; 11 import javax.servlet.annotation.WebServlet; 12 import javax.servlet.http.HttpServlet; 13 14 /** 15 * 用于获取accessToken的Servlet 16 * Created by xdp on 2016/1/25. 17 */ 18 @WebServlet( 19 name = "AccessTokenServlet", 20 urlPatterns = {"/AccessTokenServlet"}, 21 loadOnStartup = 1, 22 initParams = { 23 @WebInitParam(name = "appId", value = "wxbe4d433e857e8bb1"), 24 @WebInitParam(name = "appSecret", value = "ccbc82d560876711027b3d43a6f2ebda") 25 }) 26 public class AccessTokenServlet extends HttpServlet { 27 28 @Override 29 public void init() throws ServletException { 30 System.out.println("启动WebServlet"); 31 super.init(); 32 33 final String appId = getInitParameter("appId"); 34 final String appSecret = getInitParameter("appSecret"); 35 36 //开启一个新的线程 37 new Thread(new Runnable() { 38 @Override 39 public void run() { 40 while (true) { 41 try { 42 //获取accessToken 43 AccessTokenInfo.accessToken = getAccessToken(appId, appSecret); 44 //获取成功 45 if (AccessTokenInfo.accessToken != null) { 46 //获取到access_token 休眠7000秒,大约2个小时左右 47 Thread.sleep(7000 * 1000); 48 //Thread.sleep(10 * 1000);//10秒钟获取一次 49 } else { 50 //获取失败 51 Thread.sleep(1000 * 3); //获取的access_token为空 休眠3秒 52 } 53 } catch (Exception e) { 54 System.out.println("发生异常:" + e.getMessage()); 55 e.printStackTrace(); 56 try { 57 Thread.sleep(1000 * 10); //发生异常休眠1秒 58 } catch (Exception e1) { 59 60 } 61 } 62 } 63 64 } 65 }).start(); 66 } 67 68 /** 69 * 获取access_token 70 * 71 * @return AccessToken 72 */ 73 private AccessToken getAccessToken(String appId, String appSecret) { 74 NetWorkHelper netHelper = new NetWorkHelper(); 75 /** 76 * 接口地址为https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&secret=APPSECRET,其中grant_type固定写为client_credential即可。 77 */ 78 String Url = String.format("https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s", appId, appSecret); 79 //此请求为https的get请求,返回的数据格式为{"access_token":"ACCESS_TOKEN","expires_in":7200} 80 String result = netHelper.getHttpsResponse(Url, ""); 81 System.out.println("获取到的access_token="+result); 82 //使用FastJson将Json字符串解析成Json对象 83 JSONObject json = JSON.parseObject(result); 84 AccessToken token = new AccessToken(); 85 token.setAccessToken(json.getString("access_token")); 86 token.setExpiresin(json.getInteger("expires_in")); 87 return token; 88 } 89 }
AccessTokenServlet采用注解的方式进行配置
至此代码实现完毕,将项目部署,看到控制台输出如下:
为了方便看效果,可以把休眠时间设置短一点,比如10秒获取一次,然后将access_token输出。
下面做一个测试jsp页面,并把休眠时间设置为10秒,这样过10秒刷新页面,就可以看到变化
1 <%-- Created by IntelliJ IDEA. --%> 2 <%@ page contentType="text/html;charset=UTF-8" language="java" %> 3 <%@ page import="me.gacl.wx.Common.AccessTokenInfo"%> 4 <html> 5 <head> 6 <title>title> 7 head> 8 <body> 9 微信学习 10 <hr/> 11 access_token为:<%=AccessTokenInfo.accessToken.getAccessToken()%> 12 body> 13 html>
10秒钟后刷新页面,access_token变了,如下图所示:
经过上述的三步,我们开发前的准备工作已经完成了,接下来要做的就是接收微信服务器发送的消息并做出响应
从微信公众平台接口消息指南中可以了解到,当用户向公众帐号发消息时,微信服务器会将消息通过POST方式提交给我们在接口配置信息中填写的URL,而我们就需要在URL所指向的请求处理类WxServlet的doPost方法中接收消息、处理消息和响应消息。
编写处理消息的工具栏,工具类代码如下:
1 package me.gacl.wx.util; 2 3 import org.dom4j.Document; 4 import org.dom4j.Element; 5 import org.dom4j.io.SAXReader; 6 7 import javax.servlet.http.HttpServletRequest; 8 import java.io.InputStream; 9 import java.text.DateFormat; 10 import java.text.SimpleDateFormat; 11 import java.util.Date; 12 import java.util.HashMap; 13 import java.util.List; 14 import java.util.Map; 15 16 /** 17 * 消息处理工具类 18 * Created by xdp on 2016/1/26. 19 */ 20 public class MessageHandlerUtil { 21 22 /** 23 * 解析微信发来的请求(XML) 24 * @param request 25 * @return map 26 * @throws Exception 27 */ 28 public static MapparseXml(HttpServletRequest request) throws Exception { 29 // 将解析结果存储在HashMap中 30 Map map = new HashMap(); 31 // 从request中取得输入流 32 InputStream inputStream = request.getInputStream(); 33 System.out.println("获取输入流"); 34 // 读取输入流 35 SAXReader reader = new SAXReader(); 36 Document document = reader.read(inputStream); 37 // 得到xml根元素 38 Element root = document.getRootElement(); 39 // 得到根元素的所有子节点 40 List elementList = root.elements(); 41 42 // 遍历所有子节点 43 for (Element e : elementList) { 44 System.out.println(e.getName() + "|" + e.getText()); 45 map.put(e.getName(), e.getText()); 46 } 47 48 // 释放资源 49 inputStream.close(); 50 inputStream = null; 51 return map; 52 } 53 54 // 根据消息类型 构造返回消息 55 public static String buildXml(Map map) { 56 String result; 57 String msgType = map.get("MsgType").toString(); 58 System.out.println("MsgType:" + msgType); 59 if(msgType.toUpperCase().equals("TEXT")){ 60 result = buildTextMessage(map, "孤傲苍狼在学习和总结微信开发了,构建一条文本消息:Hello World!"); 61 }else{ 62 String fromUserName = map.get("FromUserName"); 63 // 开发者微信号 64 String toUserName = map.get("ToUserName"); 65 result = String 66 .format( 67 " " + 68 " ", 74 fromUserName, toUserName, getUtcTime(), 75 "请回复如下关键词:\n文本\n图片\n语音\n视频\n音乐\n图文"); 76 } 77 78 return result; 79 } 80 81 /** 82 * 构造文本消息 83 * 84 * @param map 85 * @param content 86 * @return 87 */ 88 private static String buildTextMessage(Map" + 69 " " + 70 " %s " + 71 "" + 72 " " + 73 " map, String content) { 89 //发送方帐号 90 String fromUserName = map.get("FromUserName"); 91 // 开发者微信号 92 String toUserName = map.get("ToUserName"); 93 /** 94 * 文本消息XML数据格式 95 * 96 97 98 1348831860 99100 101 1234567890123456 102 103 */ 104 return String.format( 105 "" + 106 " ", 111 fromUserName, toUserName, getUtcTime(), content); 112 } 113 114 private static String getUtcTime() { 115 Date dt = new Date();// 如果不需要格式,可直接用dt,dt就是当前系统时间 116 DateFormat df = new SimpleDateFormat("yyyyMMddhhmm");// 设置显示格式 117 String nowTime = df.format(dt); 118 long dd = (long) 0; 119 try { 120 dd = df.parse(nowTime).getTime(); 121 } catch (Exception e) { 122 123 } 124 return String.valueOf(dd); 125 } 126 }" + 107 " " + 108 " %s " + 109 "" + 110 " " + "
为了方便解析微信服务器发送给我们的xml格式的数据,这里我们借助于开源框架dom4j去解析xml(这里使用的是dom4j-2.0.0-RC1.jar)
WxServlet的doPost方法的代码如下:
1 /** 2 * 处理微信服务器发来的消息 3 */ 4 protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { 5 // TODO 接收、处理、响应由微信服务器转发的用户发送给公众帐号的消息 6 // 将请求、响应的编码均设置为UTF-8(防止中文乱码) 7 request.setCharacterEncoding("UTF-8"); 8 response.setCharacterEncoding("UTF-8"); 9 System.out.println("请求进入"); 10 String result = ""; 11 try { 12 Mapmap = MessageHandlerUtil.parseXml(request); 13 System.out.println("开始构造消息"); 14 result = MessageHandlerUtil.buildXml(map); 15 System.out.println(result); 16 if(result.equals("")){ 17 result = "未正确响应"; 18 } 19 } catch (Exception e) { 20 e.printStackTrace(); 21 System.out.println("发生异常:"+ e.getMessage()); 22 } 23 response.getWriter().println(result); 24 }
到此,我们的WxServlet已经可以正常处理用户的请求并做出响应了.接下来我们测试一下我们开发好的公众号应用是否可以正常和微信用户交互
将WxStudy部署到Tomcat服务器,启动服务器,记得使用ngrok将本地Tomcat服务器的8080端口映射到外网,保证接口配置信息的URL地址:http://xdp.ngrok.natapp.cn/WxServlet可以正常与微信服务器通信
登录到我们的测试公众号的管理后台,然后用微信扫描一下测试号的二维码,如下图所示:
关注成功后,我们开发好的公众号应用会先给用户发一条提示用户操作的文本消息,微信用户根据提示操作输入"文本",我们的公众号应用接收到用户请求后就给用户回复了一条我们自己构建好的文本消息,如下图所示:
我们的公众号应用响应给微信用户的文本消息的XML数据如下:
1 <xml> 2 <ToUserName>ojADgs0eDaqh7XkTM9GvDmdYPoDw]]>ToUserName> 3 <FromUserName>gh_43df3882c452]]>FromUserName> 4 <CreateTime>1453755900000CreateTime> 5 <MsgType>text]]>MsgType> 6 <Content>孤傲苍狼在学习和总结微信开发了,构建一条文本消息:Hello World!]]>Content> 7 xml>
测试公众号的管理后台也可以看到关注测试号的用户列表,如下图所示:
通过这个简单的入门程序,我们揭开了微信开发的神秘面纱了.
本篇的内容就这么多了。本文的内容和代码参考了用java开发微信公众号:公众号接入和access_token管理(二)这篇博客,在此对作者"风的姿态"表示感谢。
下载测试项目部署运行时,由于项目中使用的是Servlet3.0,所以要求部署的Tomcat必须是7.x,以前也写过几篇关于Servlet3.0的博客,大家有兴趣的话可以去看看,本篇博文对应的测试项目代码下载