• 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系列目录

     
     
     
     
     
     
  • 相关阅读:
    elasticsearch-head插件基本使用
    Windows包管理工具-Chocolatey
    php-fpm常用操作
    nginx之日志处理
    进程管理工具之supervisor(完整版)
    Elasticsearch之常见问题
    支付宝支付功能接入(PC)
    UnityWebRequest 高级API常用的操作
    UnityWebRequest
    logback中使用日期做为文件目录
  • 原文地址:https://www.cnblogs.com/GoodHelper/p/8650243.html
Copyright © 2020-2023  润新知