spring boot高性能实现二维码扫码登录(下)——订阅与发布机制版
前言
基于之前两篇(《spring boot高性能实现二维码扫码登录(上)——单服务器版》和《spring boot高性能实现二维码扫码登录(中)——Redis版》)的基础,我们使用消息队列的订阅与发布来实现二维码扫码登录的效果。
一、实现原理
1.参考微信的二维码登录机制
首先,请求后端拿到二维码。然后通过http长连接请求后端,并获取登录认证信息。这时,当二维码被扫,则记录seesion并跳转至内部页面。
如果没有扫码二维码,则线程会等到30秒(也有的说是20秒),如果再此期间,二维码被扫,则唤醒线程。如果二维码没有被扫,并且30秒等待结束,则前端页面再次请求服务器。
2.线程等待机制
我使用CountDownLatch来控制线程的等待和唤醒。控制器返回Callable<>对象来达到“非阻塞”的目的。
3.订阅与广播机制
参考:https://spring.io/guides/gs/messaging-redis/
使用redis的消息队列机制,当然使用别的中间件来做消息队列是可以的。这里是为了演示方便才使用redis,时间项目中我很少用redis做消息队列。
使用单例模式存储一个Map<>对象,用于保存登录状态。当在30秒内请求不到被扫的结果,则阻塞线程。当二维码被扫后,通过redis发送广播,当其中后端服务器(可以是多台服务器)接收到广播后,唤醒被请求的那台服务器的线程。
二、代码编写
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion> <groupId>com.demo</groupId>
<artifactId>auth</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging> <name>auth</name>
<description>二维码登录</description> <parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.0.RELEASE</version>
<relativePath /> <!-- lookup parent from repository -->
</parent> <properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
</properties> <dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency> <dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency> <!-- zxing -->
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
<version>3.3.0</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId>
<version>3.3.0</version>
</dependency> <!-- redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency> <!-- session -->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency> <dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
</dependency> </dependencies> <build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build> </project>
pom.xml
存储登录状态和接收广播的类:Receiver
package com.demo.auth; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch; public class Receiver { public static final String TOPIC_NAME = "login";
/**
* 存储登录状态
*/
private Map<String, CountDownLatch> loginMap = new ConcurrentHashMap<>(); /**
* 接收登录广播
*
* @param loginId
*/
public void receiveLogin(String loginId) { if (loginMap.containsKey(loginId)) {
CountDownLatch latch = loginMap.get(loginId);
if (latch != null) {
// 唤醒登录等待线程
latch.countDown();
}
}
} public CountDownLatch getLoginLatch(String loginId) {
CountDownLatch latch = null;
if (!loginMap.containsKey(loginId)) {
latch = new CountDownLatch(1);
loginMap.put(loginId, latch);
} else
latch = loginMap.get(loginId); return latch;
} public void removeLoginLatch(String loginId) {
if (loginMap.containsKey(loginId)) {
loginMap.remove(loginId);
}
}
}
bean配置类:
package com.demo.auth; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.listener.PatternTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter; @Configuration
public class BeanConfig { @Bean
public StringRedisTemplate template(RedisConnectionFactory connectionFactory) {
return new StringRedisTemplate(connectionFactory);
} @Bean
public RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory,
MessageListenerAdapter listenerAdapter) { RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory); // 订阅登录消息
container.addMessageListener(listenerAdapter, new PatternTopic(Receiver.TOPIC_NAME));
return container;
} @Bean
public MessageListenerAdapter listenerAdapter(Receiver receiver) {
// 方法名
String methodName = "receiveLogin";
return new MessageListenerAdapter(receiver, methodName);
} @Bean
public Receiver receiver() {
return new Receiver();
} }
控制器类:
package com.demo.auth; import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.concurrent.TimeUnit; import javax.imageio.ImageIO;
import javax.servlet.http.HttpSession; import org.apache.commons.codec.binary.Base64;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.SessionAttribute; import com.google.zxing.BarcodeFormat;
import com.google.zxing.EncodeHintType;
import com.google.zxing.MultiFormatWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel; /**
* 控制器
*
* @author 刘冬博客http://www.cnblogs.com/GoodHelper
*
*/
@Controller
public class MainController { private static final String LOGIN_KEY = "key.value.login."; @Autowired
private Receiver receiver; @Autowired
private StringRedisTemplate redisTemplate; @GetMapping({ "/", "index" })
public String index(Model model, @SessionAttribute(WebSecurityConfig.SESSION_KEY) String user) {
model.addAttribute("user", user);
return "index";
} @GetMapping("login")
public String login() {
return "login";
} /**
* 获取二维码
*
* @return
*/
@GetMapping("login/getQrCode")
public @ResponseBody Map<String, Object> getQrCode() throws Exception {
Map<String, Object> result = new HashMap<>(); String loginId = UUID.randomUUID().toString();
result.put("loginId", loginId); // app端登录地址
String loginUrl = "http://localhost:8080/login/setUser/loginId/";
result.put("loginUrl", loginUrl);
result.put("image", createQrCode(loginUrl)); ValueOperations<String, String> opsForValue = redisTemplate.opsForValue();
opsForValue.set(LOGIN_KEY + loginId, loginId, 5, TimeUnit.MINUTES);
return result;
} /**
* app二维码登录地址,这里为了测试才传{user},实际项目中user是通过其他方式传值
*
* @param loginId
* @param user
* @return
*/
@GetMapping("login/setUser/{loginId}/{user}")
public @ResponseBody Map<String, Object> setUser(@PathVariable String loginId, @PathVariable String user) { ValueOperations<String, String> opsForValue = redisTemplate.opsForValue();
String value = opsForValue.get(LOGIN_KEY + loginId); if (value != null) {
// 保存认证信息
opsForValue.set(LOGIN_KEY + loginId, user, 1, TimeUnit.MINUTES); // 发布登录广播消息
redisTemplate.convertAndSend(Receiver.TOPIC_NAME, loginId);
} Map<String, Object> result = new HashMap<>();
result.put("loginId", loginId);
result.put("user", user);
return result;
} /**
* 等待二维码扫码结果的长连接
*
* @param loginId
* @param session
* @return
*/
@GetMapping("login/getResponse/{loginId}")
public @ResponseBody Callable<Map<String, Object>> getResponse(@PathVariable String loginId, HttpSession session) { // 非阻塞
Callable<Map<String, Object>> callable = () -> { Map<String, Object> result = new HashMap<>();
result.put("loginId", loginId); try {
ValueOperations<String, String> opsForValue = redisTemplate.opsForValue();
String user = opsForValue.get(LOGIN_KEY + loginId);
// 长时间不扫码,二维码失效。需重新获二维码
if (user == null) {
result.put("success", false);
result.put("stats", "refresh");
return result;
} // 已登录
if (!user.equals(loginId)) {
// 登录成,认证信息写入session
session.setAttribute(WebSecurityConfig.SESSION_KEY, user);
result.put("success", true);
result.put("stats", "ok");
return result;
} // 等待二维码被扫
try {
// 线程等待30秒
receiver.getLoginLatch(loginId).await(30, TimeUnit.SECONDS);
} catch (Exception e) {
e.printStackTrace();
} result.put("success", false);
result.put("stats", "waiting");
return result; } finally {
// 移除登录请求
receiver.removeLoginLatch(loginId);
}
}; return callable;
} /**
* 生成base64二维码
*
* @param content
* @return
* @throws Exception
*/
private String createQrCode(String content) throws Exception {
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
Hashtable<EncodeHintType, Object> hints = new Hashtable<EncodeHintType, Object>();
hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.H);
hints.put(EncodeHintType.CHARACTER_SET, "utf-8");
hints.put(EncodeHintType.MARGIN, 1);
BitMatrix bitMatrix = new MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, 400, 400, hints);
int width = bitMatrix.getWidth();
int height = bitMatrix.getHeight();
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
for (int x = 0; x < width; x++) {
for (int y = 0; y < height; y++) {
image.setRGB(x, y, bitMatrix.get(x, y) ? 0xFF000000 : 0xFFFFFFFF);
}
}
ImageIO.write(image, "JPG", out);
return Base64.encodeBase64String(out.toByteArray());
}
} @GetMapping("/logout")
public String logout(HttpSession session) {
// 移除session
session.removeAttribute(WebSecurityConfig.SESSION_KEY);
return "redirect:/login";
}
}
登录处理类:
package com.demo.auth; import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; /**
* 登录配置 博客出处:http://www.cnblogs.com/GoodHelper/
*
*/
@Configuration
public class WebSecurityConfig implements WebMvcConfigurer { /**
* 登录session key
*/
public final static String SESSION_KEY = "user"; @Bean
public SecurityInterceptor getSecurityInterceptor() {
return new SecurityInterceptor();
} public void addInterceptors(InterceptorRegistry registry) {
InterceptorRegistration addInterceptor = registry.addInterceptor(getSecurityInterceptor()); // 排除配置
addInterceptor.excludePathPatterns("/error");
addInterceptor.excludePathPatterns("/login");
addInterceptor.excludePathPatterns("/login/**");
// 拦截配置
addInterceptor.addPathPatterns("/**");
} private class SecurityInterceptor extends HandlerInterceptorAdapter { @Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
HttpSession session = request.getSession();
if (session.getAttribute(SESSION_KEY) != null)
return true; // 跳转登录
String url = "/login";
response.sendRedirect(url);
return false;
}
}
}
application.properties:
# session
spring.session.store-type=redis
前端页面index.html和login.html保存和之前一直:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>二维码登录</title>
</head>
<body>
<h1>二维码登录</h1>
<h4>
<a target="_blank" href="http://www.cnblogs.com/GoodHelper/">from
刘冬的博客</a>
</h4>
<h3 th:text="'登录用户:' + ${user}"></h3>
<br />
<a href="/logout">注销</a>
</body>
</html>
index.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>二维码登录</title>
<script src="//cdn.bootcss.com/angular.js/1.5.6/angular.min.js"></script>
<script type="text/javascript">
/*<![CDATA[*/
var app = angular.module('app', []);
app.controller('MainController', function($rootScope, $scope, $http) {
//二维码图片src
$scope.src = null; //获取二维码
$scope.getQrCode = function() {
$http.get('/login/getQrCode').success(function(data) {
if (!data || !data.loginId || !data.image)
return;
$scope.src = 'data:image/png;base64,' + data.image
$scope.getResponse(data.loginId)
});
} //获取登录响应
$scope.getResponse = function(loginId) {
$http.get('/login/getResponse/' + loginId).success(function(data) {
if (!data) {
setTimeout($scope.getQrCode(), 1000);
return;
}
//一秒后,重新获取登录二维码
if (!data.success) {
if (data.stats == 'waiting') {
//一秒后再次调用
setTimeout(function() {
$scope.getResponse(loginId);
}, 1000);
} else {
//重新获取二维码
setTimeout(function() {
$scope.getQrCode(loginId);
}, 1000);
}
return;
} //登录成功,进去首页
location.href = '/'
}).error(function(data, status) {
//一秒后,重新获取登录二维码
setTimeout(function() {
$scope.getQrCode(loginId);
}, 1000);
})
} $scope.getQrCode(); });
/*]]>*/
</script>
</head>
<body ng-app="app" ng-controller="MainController">
<h1>扫码登录</h1>
<h4>
<a target="_blank" href="http://www.cnblogs.com/GoodHelper/">from
刘冬的博客</a>
</h4>
<img ng-show="src" ng-src="{{src}}" />
</body>
</html>
login.html
App.java:
package com.demo.auth; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication
public class App { public static void main(String[] args) {
SpringApplication.run(App.class, args);
} }
App.java
三、运行效果
如下图所示,请求后台,如果没有扫码结果,则等待30秒:

如果30后,二维码依然没有被扫,则返回http状态200的相应。前端则需再次发起请求:

如果长时间不扫(5分钟),则刷新二维码。
整个流程的运行效果如下图所示:

总结
使用Redis作为消息队列的目的是,发送和接受消息订阅。当然,如果是正式项目您最好使用性能高的消息队列中间件,我这里使用Redis是为了演示方便而已。
那么为什么要使用消息队列的订阅和广播呢?那是因为,如果有多台服务器,其中一台“对等”的服务器内存中里存储了登录的CountDownLatch来阻塞线程,而APP端扫码又访问了其他“对等”的服务器,如果不使用“广播机制”,那么阻塞线程的服务器就不会被唤醒,除非APP的请求刚好访问到被阻塞的那天服务器。
好了,关于扫码登录的博客就写完了。如果我这几篇博客中有不完善的地方或者是没有考虑到的地方,欢迎大家留言,谢谢。

如果你觉得我的博客对你有帮助,可以给我点儿打赏,左侧微信,右侧支付宝。
有可能就是你的一点打赏会让我的博客写的更好:)
spring boot高性能实现二维码扫码登录(下)——订阅与发布机制版的更多相关文章
- spring boot高性能实现二维码扫码登录(中)——Redis版
前言 本打算用CountDownLatch来实现,但有个问题我没有考虑,就是当用户APP没有扫二维码的时候,线程会阻塞5分钟,这反而造成性能的下降.好吧,现在回归传统方式:前端ajax每隔1秒或2秒发 ...
- spring boot高性能实现二维码扫码登录(上)——单服务器版
前言 目前网页的主流登录方式是通过手机扫码二维码登录.我看了网上很多关于扫码登录博客后,发现基本思路大致是:打开网页,生成uuid,然后长连接请求后端并等待登录认证相应结果,而后端每个几百毫秒会循环查 ...
- Spring Cloud OAuth2(二) 扩展登陆方式:账户密码登陆、 手机验证码登陆、 二维码扫码登陆
概要 基于上文讲解的spring cloud 授权服务的搭建,本文扩展了spring security 的登陆方式,增加手机验证码登陆.二维码登陆. 主要实现方式为使用自定义filter. Authe ...
- iOS Swift WisdomScanKit二维码扫码SDK,自定义全屏拍照SDK,系统相册图片浏览,编辑SDK
iOS Swift WisdomScanKit 是一款强大的集二维码扫码,自定义全屏拍照,系统相册图片编辑多选和系统相册图片浏览功能于一身的 Framework SDK [1]前言: 今天给大家 ...
- Spring Boot REST(二)源码分析
Spring Boot REST(二)源码分析 Spring 系列目录(https://www.cnblogs.com/binarylei/p/10117436.html) SpringBoot RE ...
- 使用 Docker 和 Nginx 打造高性能的二维码服务
使用 Docker 和 Nginx 打造高性能的二维码服务 本文将演示如何使用 Docker 完整打造一个基于 Nginx 的高性能二维码服务,以及对整个服务镜像进行优化的方法.如果你的网络状况良好, ...
- Spring Boot 2.X(二):集成 MyBatis 数据层开发
MyBatis 简介 概述 MyBatis 是一款优秀的持久层框架,支持定制化 SQL.存储过程以及高级映射.它采用面向对象编程的方式对数据库进行 CRUD 的操作,使程序中对关系数据库的操作更方便简 ...
- spring boot / cloud (十二) 异常统一处理进阶
spring boot / cloud (十二) 异常统一处理进阶 前言 在spring boot / cloud (二) 规范响应格式以及统一异常处理这篇博客中已经提到了使用@ExceptionHa ...
- Spring Boot 启动(二) 配置详解
Spring Boot 启动(二) 配置详解 Spring 系列目录(https://www.cnblogs.com/binarylei/p/10198698.html) Spring Boot 配置 ...
随机推荐
- 在 React 中使用 JSX 的好处
优点: 1.允许使用熟悉的语法来定义 HTML 元素树: 2.提供更加语义化且移动的标签: 3.程序结构更容易被直观化: 4.抽象了 React Element 的创建过程: 5.可以随时掌控 HTM ...
- 使用axios以及http-proxy-middleware代理处理跨域的问题
axios现在以及是尤大大推荐使用的了,官方不在维护vue-reresource. 由于是地第一次使用axios, 在使用过程中猜了很大的坑 首先我们使用vue-cli创建的项目, 访问接口肯定是跨域 ...
- 微信 Tinker 的一切都在这里,包括源码
最近半年以来,Android热补丁技术热潮继续爆发,各大公司相继推出自己的开源框架.Tinker在最近也顺利完成了公司的审核,并非常荣幸的成为github.com/Tencent上第一个正式公开的项目 ...
- 用开源的 ffmpeg 实现屏幕录像机
在我看来,FFmpeg 绝对是一个很酷的应用.那么,它究竟有什么用呢?简单地讲,FFmpeg 相当于一个屏幕录像机.你可以使用它将屏幕上的操作过程录制下来,然后再将其播放给别人看.我们可以利用它制作教 ...
- CSS3的[att$=val]选择器
1.实例源码 <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www. ...
- json格式数据整理
一,json的两种数据结构 1.1,对象 对象结构以"{"大括号开始,以"}"大括号结束.中间部分由0或多个以","分隔的"key ...
- div里面的图片垂直居中
外面一个div, 里面是一张大小不固定的图片, 或者span,div都行, 要实现里面元素水平垂直居中对齐 其实有很多实现的方法, 比如用js去计算每一个子元素的高度, 通过js去动态设置magin值 ...
- jquery的过滤学习
$("p").eq(1) 匹配下标为1的p标签 $("p").hasClass("a")匹配所有p标签class中包含a的 ...
- Ubuntu出现ERR_PROXY_CONNECTION_FAILED错误解决方案
我是Ubuntu新手,因为想查看国外的资料,然后安装了灯笼,结果打开谷歌浏览器出现了ERR_PROXY_CONNECTION_FAILED错误,未连接到互联网,代理服务器出现错误,然后Firefox也 ...
- 数列分块总结——题目总版(hzwer分块九题及其他题目)(分块)
闲话 莫队算法似乎还是需要一点分块思想的......于是我就先来搞分块啦! 膜拜hzwer学长神犇%%%Orz 这九道题,每一道都堪称经典,强力打Call!点这里进入 算法简述 每一次考试被炸得体无完 ...