您的位置:首页 > 编程语言 > Java开发

spring boot高性能实现二维码扫码登录(上)——单服务器版

2018-03-25 00:42 621 查看

前言

 

  目前网页的主流登录方式是通过手机扫码二维码登录。我看了网上很多关于扫码登录博客后,发现基本思路大致是:打开网页,生成uuid,然后长连接请求后端并等待登录认证相应结果,而后端每个几百毫秒会循环查询数据库或redis,当查询到登录信息后则响应长连接的请求。

然而,如果是小型应用则没问题,如果用户量,并发大则会出现非常严重的性能瓶颈。而问题的关键是使用了循环查询数据库或redis的方案。假设要优化这个方案可以使用java多线程的同步集合+CountDownLatch来解决。

 

一、环境

 

1.java 8(jdk1.8)

2.maven 3.3.9

3.spring boot 2.0

 

二、知识点

 

1.同步集合使用

2.CountDownLatch使用

3.http ajax

4.zxing二维码生成

 

三、流程及实现原理

 

1.打开网页,通过ajax请求获取二维码图片地址

2.页面渲染二维码图片,并通过长连接请求,获取后端的登录认证信息

3.事先登录过APP的手机扫码二维码,然后APP请求服务器端的API接口,把用户认证信息传递到服务器中。

4.后端收到APP的请求后,唤醒长连接的等待线程,并把用户认证信息写入session。

5.页面得到长连接的响应,并跳转到首页。

整个流程图下图所示

<?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>

<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  

 

首先,参照《玩转spring boot——简单登录认证》完成简单登录认证。在浏览器中输入http://localhost:8080页面时,由于未登录认证,则重定向到http://localhost:8080/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;
}
}
}

 

 

其次,新建控制器类:MainController

/**
* 控制器
*
* @author 刘冬博客http://www.cnblogs.com/GoodHelper
*
*/
@Controller
public class MainController {

@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";
}
}

 

新建两个html页面: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>
</body>
</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 || !data.success) {
setTimeout($scope.getQrCode(), 1000);
return;
}

//登录成功,进去首页
location.href = '/'

}).error(function(data, status) {
console.log(data)
console.log(status)
//一秒后,重新获取登录二维码
setTimeout($scope.getQrCode(), 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页面先请求后端服务器,获取登录uuid,然后获取到服务器的二维码后在页面渲染二维码。接着使用长连接请求并等待服务器的相应。

 

然后新建一个承载登录信息的类:LoginResponse

package com.demo.auth;

import java.util.concurrent.CountDownLatch;

/**
* 登录信息承载类
*
* @author 刘冬博客http://www.cnblogs.com/GoodHelper
*
*/
public class LoginResponse {

public CountDownLatch latch;

public String user;

// 省略 get set
}

 

 

最后修改MainController类,最终的代码如下:

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.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

import javax.imageio.ImageIO;
import javax.servlet.http.HttpSession;

import org.apache.commons.codec.binary.Base64;
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 Map<String, LoginResponse> loginMap = new ConcurrentHashMap<>();

@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<>();
result.put("loginId", UUID.randomUUID());

// app端登录地址
String loginUrl = "http://localhost:8080/login/setUser/loginId/";
result.put("loginUrl", loginUrl);
result.put("image", createQrCode(loginUrl));
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) {
if (loginMap.containsKey(loginId)) {
LoginResponse loginResponse = loginMap.get(loginId);

// 赋值登录用户
loginResponse.user = user;

// 唤醒登录等待线程
loginResponse.latch.countDown();
}

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 Map<String, Object> getResponse(@PathVariable String loginId, HttpSession session) {
Map<String, Object> result = new HashMap<>();
result.put("loginId", loginId);
try {
LoginResponse loginResponse = null;
if (!loginMap.containsKey(loginId)) {
loginResponse = new LoginResponse();
loginMap.put(loginId, loginResponse);
} else
loginResponse = loginMap.get(loginId);

// 第一次判断
// 判断是否登录,如果已登录则写入session
if (loginResponse.user != null) {
session.setAttribute(WebSecurityConfig.SESSION_KEY, loginResponse.user);
result.put("success", true);
return result;
}

if (loginResponse.latch == null) {
loginResponse.latch = new CountDownLatch(1);
}
try {
// 线程等待
loginResponse.latch.await(5, TimeUnit.MINUTES);
} catch (Exception e) {
e.printStackTrace();
}

// 再次判断
// 判断是否登录,如果已登录则写入session
if (loginResponse.user != null) {
session.setAttribute(WebSecurityConfig.SESSION_KEY, loginResponse.user);
result.put("success", true);
return result;
}

result.put("success", false);
return result;
} finally {
// 移除登录请求
if (loginMap.containsKey(loginId))
loginMap.remove(loginId);
}
}

/**
* 生成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());
}
}

}

 

其中,使用  Map<String, LoginResponse> loginMap类存储登录请求信息

createQrCode方法是用于生成二维码

getQrCode方法是给页面返回登录uuid和二维码,前端页面拿到登录uuid后请求长连接等待二维码的扫码登录结果。

setUser方法是提供给APP端调用的,在此过程中通过uuid找到对应的CountDownLatch,并唤醒长连接的线程。而这里是为了做演示才把这个方法放到这个类里,在实际项目中,此方法不一定在这个类里或未必在同一个后端中。另外我把用户信息的传递也写在这个方法中了,而实际项目是通过其他的方式来传递用户信息,这里仅仅是为了演示方便。

getResponse方法是处理ajax的长连接,并使用CountDownLatch等待APP端来唤醒这个线程,然后把用户信息写入session。

 

 入口类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);
}
}

 

 项目结构如下图所示:

 

 五、总结

 

打开浏览器输入http://localhost:8080。运行效果如下图所以:

 

 

使用CountDownLatch则避免了每隔500毫秒读一次数据库或redis的频繁查询性能问题。因为操作的是内存数据,所以性能非常高。

而CountDownLatch是java多线程中非常实用的类,二维码扫码登录就是一个具有代表意义的应用场景。当然,如果你不嫌代码量大也可以用wait+notify来实现。另在java.util.concurrent包下,也有很多的多线程类能到达同样的目的,我这里就不一一例举了。

 

根据园友的建议,我发现本篇文章里的线程阻塞是设计缺陷,所以不循环查询数据库或redis里,但一台服务器的线程数是有限的。在下篇我会改进这个设计

 

代码下载

 

如果你觉得我的博客对你有帮助,可以给我点儿打赏,左侧微信,右侧支付宝。

有可能就是你的一点打赏会让我的博客写的更好:)

 

返回玩转spring boot系列目录

 

内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: