上一篇主要介绍了微信的网页开发。这篇来介绍一下微信后台开发。对于一般的微信运营者,微信公众平台提供了配置自动回复和自定义菜单的功能。但是对于开发人员来说,很多时候这些基础的功能并不能满足我们的需求,比如我们如果想要根据用户的不同属性在用户关注的时候推送不同的图文,基础的自动回复功能就无法满足,这个时候就需要引入微信后台开发。
接入微信后台开发的步骤如下:
-
在公众平台的开发-基本配置里填写服务器配置。
-
URL
表示服务器的回调地址,微信验证服务器有效性的请求及后续事件回调都会发到这个URL上。 -
Token
表示服务器秘钥,用来生成签名,保存在服务器端。 -
EncodingAESKey
表示随机码,随机生成即可。 -
消息加解密方式
根据业务需要选择,一般使用明文模式即可。
- 验证服务器地址有效性,微信会发送一个带参数的GET请求到步骤1填写的URL上,以此来验证服务器的有效性。这个验证只有当提交服务器配置的时候才会触发,所以要先把应用部署起来再去提交步骤1的服务器配置。微信验证服务器地址时带的参数如下:
-
signature
微信加密签名,signature结合了开发者填写的token参数和请求中的timestamp参数、nonce参数。 -
timestamp
时间戳。 -
nonce
随机数。 -
echostr
随机字符串。
开发者需要对请求进行校验,来确认请求是否来自微信服务器,如果确认成功,则将echostr返回,服务器配置既提交成功。校验的方式如下:
1). 将token、timestamp、nonce三个参数进行字典序排序生成字符串str1。
2). 将str1进行sha1加密产生str2。
3). 比对str2和signature,如果一致,则可却认为请求来自微信服务器,返回echostr即可。
/**
* 微信签名验证
*
* @param signature
* @param timestamp
* @param nonce
* @param echostr
* @return
*/
public static String checkSign(String signature, String timestamp, String nonce, String echostr) {
ApiLogger.weChatLogger.info("微信进行接口验证" + StringUtils.join(signature + "; ", timestamp + "; ", nonce + "; ",
echostr + "; "));
String[] strings = {timestamp, nonce, SIGN_TOKEN};
//将timestamp,nonce及token进行字典排序
List stringList = Arrays.asList(strings);
Collections.sort(stringList);
String signStr = String.join("", stringList);
//将排序后的串进行签名
String signVal = SignUtil.encode(signStr, SignTypeEnum.SHA1);
if (StringUtils.equalsIgnoreCase(signVal, signature)) {
ApiLogger.weChatLogger.info("微信验证通过" + echostr);
return echostr;
} else {
ApiLogger.weChatLogger.warn("微信验证失败, 请求中的签名为 " + signature + "实际签名为: " + signVal);
return "failed";
}
}
服务器验证成功之后就可以按照具体的接口文档来做开发了。注意,一旦提交了服务器配置,公众平台网页端的自动回复功能和自定义菜单功能就不可用了。
微信后台开发又主要分为两部分,被动处理微信事件以及主动调用微信接口。
被动处理微信事件
微信对于公众号主动触达用户管理的非常严格,除了每个月限量的群发机会以外,几乎没有别的手段可以主动发送消息给用户(只有格式内容要求非常严格的模版消息可以做到)。但是微信会将用户触发的事件以POST的形式发送到公众号配置的服务器url上,事件主要分为消息事件、交互事件和卡券事件。开发者在接受到这些事件后,返回响应的xml格式,即时地回复消息给用户,也可以同时做一些业务逻辑,比如用户关注信息入库之类,不过建议使用异步的方式来处理业务逻辑,避免影响消息的推送,因为如果微信服务器在五秒内收不到响应会断掉连接,并且重新发起请求,总共尝试三次。这里还有一种介于被动和主动间的方式,就是服务器在收到某些事件后,在一定时间内(48小时)主动调用客服消息接口给用户发送客服消息。如果发送给用户的消息依赖于业务逻辑,那么建议使用客服消息。
微信事件回调都是以xml的格式POST到回调地址的,所以首先需要解析xml数据。这里推荐dom4j,可以很容易地将xml解析为map对象:
@SuppressWarnings("unchecked")
public static Map parseXml(String xml) throws DocumentException {
Element ele = DocumentHelper.parseText(xml).getRootElement();
Map result = new HashMap<>();
//遍历所有节点并存入map
ele.elements().forEach(e -> result.put(((Element) e).getName(), ((Element) e).getText()));
return result;
}
然后根据xml中的MsgType
属性来判断事件类型,可能出现的值有:event, text, image, voice, video, shortvideo, location, link, news等,上面说到的交互事件和卡券事件都是event类型,而其它的值都是消息事件,比如text就是用户发送文本消息给公众号的消息事件。
接受到事件后,需要做的就根据需要的消息类型组装响应的xml数据,并且返回给微信服务器。目前可以回复给用户的信息有文本消息,图片消息,语音消息,视频消息,音乐消息和图文消息。比如我们现在需要在用户关注的时候推送一个图文信息给用户,那么我们就需要组装一个图文消息的xml返回给微信服务器。这里有一点,微信服务器发送给我们的xml和我们返回给微信服务器的xml都有四个公共属性,ToUserName,FromUserName,CreateTime和MsgType,所以可以创建一个抽象基类,然后所有的微信消息都由该基类导出。我们在组建返回对象xml的时候,只需要把接受到的xml中的ToUserName设置为返回对象的FromUserName,FromUserName设置为ToUserName,其它属性按照官方文档设置即可。
将对象转化为xml可以使用XStream,以下例子是将微信图文对象转化为xml的代码,WeChatNews和WeChatArticle这两个类都是自定义的类,用来表示微信图文和微信图文里的文章,List
public static XStream xStream = new XStream();
/**
* 图文消息对象转换成xml
*
* @param news 图文消息对象
* @return xml
*/
public static String newsMessageToXml(WeChatNews news) {
xStream.alias("xml", news.getClass());
xStream.alias("item", new WeChatArticle().getClass());
return xStream.toXML(news);
}
由于xml内容中可能出现<
或者&
等符号,导致xml解析器解析错误,所以最好将返回对象xml的值都放在CDATA中。可以通过对XStream进行扩展:
private static final CDATA_START = "";
/**
* 扩展xstream,使其支持CDATA块
*/
public static XStream xstream = new XStream(new XppDriver() {
public HierarchicalStreamWriter createWriter(Writer out) {
return new PrettyPrintWriter(out) {
protected void writeText(QuickWriter writer, String text) {
writer.write(CDATA_START);
writer.write(text);
writer.write(CDATA_END);
}
};
}
});
P.S. 这里再来吐槽一下微信的官方文档。我在开发的时候遇到过几处文档有误或者文档缺失的问题:
比如当用户通过微信支付成功后的关注公众号选项关注时,微信会发送一个subscribe事件到回调地址,并且带有eventKey(格式为last_trade_no_xxxxxxxxxxx),而文档中根本没有这个类型的事件的描述。文档中只有关注事件和通过推广二维码关注的事件是subscribe事件,但是前者没有eventKey,后者eventKey的格式为qrscene_xxxxxx。文档明显漏了这一关注类型。
又比如微信偶尔会发一个不带参数的GET请求到回调地址,这个就很莫名其妙了,我翻遍了文档没有发现相关说明。这种请求只能在代码中屏蔽掉,问了微信的技术说是内部bug,不知何时能解决。搜了搜网上也有遇到这个问题的。
主动调用微信接口
微信后台开发另外一块很大的内容就是主动调用微信接口来实现一些业务逻辑。调用微信接口首先需要微信基础服务access_token。这个access_token和上一篇讲的网页授权access_token不一样,这个token是调用微信接口的令牌。该令牌需要通过请求微信接口获得:
https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&secret=APPSECRET
注意,调用前需要先在微信开发-基本配置里配置IP白名单,这样才能调用获取access_token接口。
正常返回如下:
{"access_token":"ACCESS_TOKEN","expires_in":7200}
由于获取令牌的接口每天的调用次数有限(100000次),所以需要将获取到access_token保存起来,并且由于令牌两小时会过期,需要提供主动/被动的刷新机制。由于线上一般都是分布式的应用部署方式,所以为了避免多台服务器同时去更新access_token,需要使用分布式锁。分布式锁的实现方式一般有利用数据库乐观锁的,利用redis/memcache的原子性操作的,以及利用zookeeper的最小节点的。这里讲一下我在代码中使用的redis的实现分布式锁的大致步骤。
- 使用setnx("MyKey", 当前时间+过期超时时间) ,如果返回1,则获取锁成功,那么就可以去更新access_token,并且返回最新的access_token;如果返回0则没有获取到锁,转向2。
- get("MyKey")获取值老锁过期时间oldExpireTime ,并将这个值与当前的系统时间进行比较,如果大于当前系统时间,说明锁还未过期,别的请求可能正在更新access_token,直接返回空;如果小于当前系统时间,则认为这个锁已经过期,那么可以允许别的请求重新获取,转向3。
- 计算新锁过期时间newExpireTime=当前时间+锁过期时间(一个常量),然后使用getset("MyKey", newExpireTime),设置锁的新过期时间为newExpireTime,这个操作同时会返回当前MyKey的值currentExpireTime。
- 判断锁当前过期时间currentExpireTime与老锁过期时间oldExpireTime是否相等,如果相等,说明获取锁成功,那么可以接着更新access_token,并且返回access_token。如果不相等,说明这个锁又被别的请求获取走了,因为在这个getset操作前已经有别的请求执行了getset,所以currentExpireTime发生了变化。那么当前请求可以直接返回空。
- 在更新access_token后,检查分布式锁是否过期,如果过期则使用delete释放锁,否则保留;这里注意一点,不要在更新access_token后直接删除锁,否则会在高并发时,出现在第2步进程获取老锁时为空或者第3步getset获取当前锁过期时间为空的情况。
调用上述获取access_token的服务应尝试三次,直到返回access_token为止。如果返回为空,则等待一秒后再获取,如果三次后如果还未获取到access_token,则抛出异常。由于大多数情况,有效的access_token应该在缓存中可以直接取到,不需要通过分布式锁更新,所以不要在同步整个获取access_token的服务,负责可能会导致高并发下的阻塞导致性能瓶颈。更不能只将更新access_token的逻辑同步,这样会导致重复更新access_token,而失去了分布式锁的意义。
拿到access_token后就可以根据微信文档来调用响应的接口了。这里拿获取用户信息接口来举例。该接口请求说明如下:
http请求方式: GET
https://api.weixin.qq.com/cgi-bin/user/info?access_token=ACCESS_TOKEN&openid=OPENID&lang=zh_CN
带上access_token以及用户的openid,就可以向微信请求用户信息了。HttpClient建议使用RestTemplate,方便简洁。
这里又有一个微信文档的坑,获取用户信息接口实际返回的数据比文档中要多一些属性(应该是2018/3/6晚上新增了subscribe_scene, qr_scene和qr_scene_str字段,文档并没有及时更新。)所以这里需要在转化json到对象时,忽略对象中没有的属性,可以通过配置ObjectMapper对象来实现:
public class CustomObjectMapper {
public final static ObjectMapper om = new ObjectMapper();
static {
om.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
}
}
调用微信接口的代码:
//微信API接口域名
private static final String API_HOST = "https://api.weixin.qq.com/cgi-bin/";
//10秒链接超时
private static final int CONNECT_TIMEOUT = 10 * 1000;
//1分钟接收数据接收时间
private static final int READ_TIMEOUT = 60 * 1000;
private RestTemplate restTemplate;
public WeChatServiceImpl() {
HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory();
requestFactory.setConnectTimeout(CONNECT_TIMEOUT);
requestFactory.setReadTimeout(READ_TIMEOUT);
this.restTemplate = new RestTemplate(requestFactory);
this.restTemplate.getMessageConverters().add(0, new StringHttpMessageConverter(StandardCharsets.UTF_8));
}
/**
* 通过微信api获取微信用户信息
*
* @param openId
* @return
*/
private WeChatUser getWeChatUser(String openId) {
String url = String.format("%suser/info?access_token=%s&openid=%s&lang=zh_CN", API_HOST, weChatTokenService
.getToken().getAccessToken(), openId);
ApiLogger.weChatLogger.info("调用微信接口获取微信用户信息:" + url);
try {
String result = restTemplate.getForObject(url, String.class);
ApiLogger.weChatLogger.debug("调用微信接口获取微信用户信息:" + result);
return CustomObjectMapper.om.readValue(result, WeChatUser.class);
} catch (Exception e) {
String msg = "调用微信接口获取微信用户信息失败:" + url;
ApiLogger.weChatLogger.error(msg, e);
throw new WeChatEx(msg, e);
}
}
同时,也可以直接到微信公众平台的开发者工具中,使用接口调试工具直接调试和请求接口。比如生成自定义菜单这种一次性的调用就非常适合在接口调试工具中直接调用。微信开放了很多有用的接口,比如生成推广二维码的接口,拉取关注用户的接口,发送模版消息的接口,发送客服消息的接口等等,具体可参照官方文档。
花了两篇文章的篇幅,大概地讲了一下微信的网页开发和后台开发,也提到了一些我在开发中遇到的难点和注意点,希望对大家有用。微信开发不仅仅包括这两块,还有微信jssdk的使用,微信支付,微信开放平台开发等等。大家有兴趣的话也可以多看看官方文档,虽然里面有不少坑。