spring boot高性能實現二維碼掃碼登陸(下)——訂閱與發佈機制版

 前言javascript


 

  基於以前兩篇(《spring boot高性能實現二維碼掃碼登陸(上)——單服務器版》和《spring boot高性能實現二維碼掃碼登陸(中)——Redis版》)的基礎,咱們使用消息隊列的訂閱與發佈來實現二維碼掃碼登陸的效果。css

 

1、實現原理html


 

1.參考微信的二維碼登陸機制前端

首先,請求後端拿到二維碼。而後經過http長鏈接請求後端,並獲取登陸認證信息。這時,當二維碼被掃,則記錄seesion並跳轉至內部頁面。java

若是沒有掃碼二維碼,則線程會等到30秒(也有的說是20秒),若是再此期間,二維碼被掃,則喚醒線程。若是二維碼沒有被掃,而且30秒等待結束,則前端頁面再次請求服務器。git

2.線程等待機制github

我使用CountDownLatch來控制線程的等待和喚醒。控制器返回Callable<>對象來達到「非阻塞」的目的。web

3.訂閱與廣播機制redis

參考:https://spring.io/guides/gs/messaging-redis/spring

使用redis的消息隊列機制,固然使用別的中間件來作消息隊列是能夠的。這裏是爲了演示方便才使用redis,時間項目中我不多用redis作消息隊列。

使用單例模式存儲一個Map<>對象,用於保存登陸狀態。當在30秒內請求不到被掃的結果,則阻塞線程。當二維碼被掃後,經過redis發送廣播,當其中後端服務器(能夠是多臺服務器)接收到廣播後,喚醒被請求的那臺服務器的線程。

 

 

2、代碼編寫


 

 

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

 

 

3、運行效果


 

以下圖所示,請求後臺,若是沒有掃碼結果,則等待30秒:

 

若是30後,二維碼依然沒有被掃,則返回http狀態200的相應。前端則需再次發起請求:

 

 若是長時間不掃(5分鐘),則刷新二維碼。

 

 

 

 整個流程的運行效果以下圖所示:

 

 

 

總結


 

  使用Redis做爲消息隊列的目的是,發送和接受消息訂閱。固然,若是是正式項目您最好使用性能高的消息隊列中間件,我這裏使用Redis是爲了演示方便而已。

那麼爲何要使用消息隊列的訂閱和廣播呢?那是由於,若是有多臺服務器,其中一臺「對等」的服務器內存中裏存儲了登陸的CountDownLatch來阻塞線程,而APP端掃碼又訪問了其餘「對等」的服務器,若是不使用「廣播機制」,那麼阻塞線程的服務器就不會被喚醒,除非APP的請求恰好訪問到被阻塞的那天服務器。

 

好了,關於掃碼登陸的博客就寫完了。若是我這幾篇博客中有不完善的地方或者是沒有考慮到的地方,歡迎你們留言,謝謝。

 

 

 

代碼下載

 

若是你以爲個人博客對你有幫助,能夠給我點兒打賞,左側微信,右側支付寶。

有可能就是你的一點打賞會讓個人博客寫的更好:)

 

返回玩轉spring boot系列目錄

相關文章
相關標籤/搜索