前言
最近在做微信的扫描支付的时候,遇到一个问题:如何在用户扫码支付完成之后,客户端立即得到通知,进行下一步的跳转?
解决方案
首先想到的策略是客户端轮循查询订单的状态,根据返回的结果进行跳转
这个方案有明显的缺点,轮循时间设置短,频繁发送请求,对服务器以及数据库都会产生压力;轮循时间过长,用户等待时间长,体验很差;
针对这个问题想到了微信网页版的扫码登录(扫描完成后,立即登录),现研究一下它的原理并实现相同的功能
微信扫描登录原理
可以看到图片中,前端二维码页面发送一个网络请求,但是这个请求并没有立即返回
一段时间没有扫描后,后端返回408,前端重新发起一个相同的网络请求,并继续挂起 ( pending )
据此猜测大概实现原理如下:
- 进入网站后生成一个 ( 比如
UUID
)
- 跳转到二维码页面 ( 二维码中的链接包含此
UUID
)
- 二维码页面向服务器发起请求,查询二维码是否被扫登录
- 服务器收到请求后查询,如果未扫登录,进入等待(
wait
),不立即返回
- 一旦被扫,立即返回 (
notify
)
- 页面收到结果,做后续处理
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 26
| @RequestMapping("/qrcode/{uuid}") @ResponseBody public void createQRCode(@PathVariable String uuid, HttpServletResponse response) { String text = "http://222.186.174.121:41408/login/" + uuid; int width = 300; int height = 300; String format = "png"; ScanPool pool = new ScanPool(); PoolCache.cacheMap.put(uuid, pool); System.out.println("UUID放入缓存 成功"); 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(); }
System.out.println("根据UUID生成二维码 成功"); }
|
生成二维码,并将 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
| public synchronized boolean getScanStatus() { try { if (!isScan()) { this.wait(); } if (isScan()) { return true; } } catch (InterruptedException e) { 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
| class ScanCounter implements Runnable {
public Long timeout = 27000L;
private ScanPool scanPool;
public ScanCounter(ScanPool scanPool) { this.scanPool = scanPool; }
public void run() { try { Thread.sleep(timeout); } catch (InterruptedException e) { e.printStackTrace(); } notifyPool(scanPool); }
public synchronized void notifyPool(ScanPool scanPool) { scanPool.notifyPool(); } }
|
扫码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| @RequestMapping("/login/{uuid}") @ResponseBody String login(@PathVariable String uuid) {
ScanPool pool = PoolCache.cacheMap.get(uuid);
if (pool == null) { return "timeout,scan fail"; }
pool.scanSuccess();
System.out.println("扫码完成,登录成功");
return "扫码完成,登录成功"; }
|
扫码成功,设置扫码状态,唤起线程
1 2 3 4 5 6 7 8
| public synchronized void scanSuccess() { try { setScan(true); this.notifyAll(); } catch (Exception e) { e.printStackTrace(); } }
|
手机扫码后