
最近在做微信的扫码支付,遇到一个问题:如何在用户扫码支付完成之后,客户端立即得到通知,进行下一步的跳转?
首先想到的策略可能是客户端轮询查询订单状态,根据返回结果进行跳转
这个方式有明显的缺点,轮询时间设置短,频繁发送请求,对服务器以及数据库都会产生压力;轮询时间过长,用户等待时间长,体验很差;
针对这个问题想到了微信网页版的扫码登录(扫码完成后,立即登录),现在研究一下它的原理并实现相同的功能
微信扫码登录原理

根据图片中,前端二维码页面发送一个网络请求,但是这个请求并没有立即返回

一段时间没有扫描后,后端返回408,前端重新发起一个相同的网络请求,并继续pending
据此猜测大概实现原理如下:
- 进入网站-生成一个唯一标识(比如UUID)
- 跳转到二维码页面(二维码中的链接包含次UUID)
- 二维码页面向服务端发起请求,查询二维码是被扫登录
- 服务器收到请求,查询。如果未扫登录,进入等待(wait),不立即返回
- 一旦被扫,立即返回(notify)
- 页面收到结果,做后续处理
步骤大概就是如此,但是有个问题,步骤3如果请求超时,如何处理?处理方式是,一段固定时间后,返回408(timeout)
UUID缓存
1
| public static Map<String, ScanPool> cacheMap = new ConcurrentHashMap<String, ScanPool>();
|
一定要使用ConcurrentHashMap否则多线程操作集合会报错ConcurrentModificationException
单线程中出现该异常的原因是,对一个集合遍历的同时,又对该集合进行了增删的操作
多线程中更易出现该异常,当你在一个线程中对一数据集合进行遍历,正赶上另外一个线程对该数据集合进行增删操作时便会出现该异常
缓存还要设置自动清理功能,防止增长过大
生成二维码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| @RequestMapping("/qrcode/{uuid}") @ResponseBody String createQRCode(@PathVariable String uuid, HttpServletResponse response) { System.out.println("生成二维码");
String text = "http://2b082e46.ngrok.io/login/" + uuid; int width = 300; int height = 300; String format = "png"; //将UUID放入缓存 ScanPool pool = new ScanPool(); PoolCache.cacheMap.put(uuid, pool); try { Map<EncodeHintType, Object> hints = new HashMap<EncodeHintType, Object>(); hints.put(EncodeHintType.CHARACTER_SET, "utf-8"); hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H); //容错率 BitMatrix bitMatrix = new MultiFormatWriter().encode(text, BarcodeFormat.QR_CODE, width, height, hints); MatrixToImageWriter.writeToStream(bitMatrix, format, response.getOutputStream()); } catch (WriterException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } return null; }
|
生成二维码,并将UUID放入缓存中
此处需要注意,二维码url必须是外网可以访问地址,此处可以使用内网穿透工具
验证是否登录
前端发起请求,验证该二维码是否已经被扫登录
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| @RequestMapping("/pool") @ResponseBody String pool(String uuid) { System.out.println("检测[" + uuid + "]是否登录");
ScanPool pool = PoolCache.cacheMap.get(uuid);
if (pool == null) { return "timeout"; }
//使用计时器,固定时间后不再等待扫描结果--防止页面访问超时 new Thread(new ScanCounter(pool)).start();
boolean scanFlag = pool.getScanStatus();
if (scanFlag) { return "success"; } else { return "fail"; } }
|
获得状态
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| public synchronized boolean getScanStatus() { try { if (!isScan()) { //如果还未扫描,则等待 this.wait(); } if (isScan()) { return true; } } catch (InterruptedException e) { // TODO Auto-generated catch block e.printStackTrace(); } return false; }
public synchronized void notifyPool() { try { this.notifyAll(); } catch (Exception e) { e.printStackTrace(); } }
|
新开线程防止页面访问超时
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| class ScanCounter implements Runnable {
public Long timeout = 27000L;
//传入的对象 private ScanPool scanPool;
public ScanCounter(ScanPool scanPool) { this.scanPool = scanPool; }
@Override public void run() { try { Thread.sleep(timeout); } catch (InterruptedException e) { e.printStackTrace(); } notifyPool(scanPool); }
public synchronized void notifyPool(ScanPool scanPool) { scanPool.notifyPool(); } }
|

定时清理uuid
为防止cacheMap不断增加的问题,需要在静态代码块中开启线程定时清理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| public class PoolCache { //缓存超时时间 80秒 private static Long timeOutSecond = 80L;
//每1分钟清理一次缓存 private static Long cleanIntervalSecond = 60L;
public static Map<String, ScanPool> cacheMap = new ConcurrentHashMap<String, ScanPool>();
static { new Thread(new Runnable() {
@Override public void run() { while (true) { try { Thread.sleep(cleanIntervalSecond * 1000); } catch (InterruptedException e) { e.printStackTrace(); } clean(); } }
public void clean() { System.out.println("缓存清理...");
if (cacheMap.keySet().size() > 0) { Iterator<String> iterator = cacheMap.keySet().iterator(); while (iterator.hasNext()) { String key = iterator.next(); ScanPool pool = cacheMap.get(key); if (System.currentTimeMillis() - pool.getCreateTime() > timeOutSecond * 1000) { cacheMap.remove(key); // 这一行很关键!用于当清理完成,前端请求还在pending时,立即返回结果 pool.notifyPool(); } } } } }).start(); }
}
|
扫码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| @RequestMapping("/login/{uuid}") @ResponseBody String login(@PathVariable String uuid) {
ScanPool pool = PoolCache.cacheMap.get(uuid);
if (pool == null) { return "timeout,scan fail"; } // 设置被扫状态,唤起线程 pool.scanSuccess();
return "扫码完成,登录成功"; }
|
扫码成功,设置扫码状态,唤起线程
1 2 3 4 5 6 7 8
| public synchronized void scanSuccess() { try { setScan(true); this.notifyAll(); } catch (Exception e) { e.printStackTrace(); } }
|

手机扫码后

对比完整代码很容易看实现原理