spring boot高效能實現二維碼掃碼登入(下)——訂閱與釋出機制版

冬子哥發表於2018-03-26

 前言


 

  基於之前兩篇(《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系列目錄

 

 
 
 
 
 
 

相關文章