我用响应式编程实现了一个验证码验证服务 (By Vert.X)

728 阅读8分钟

能随时把想到的东西用喜欢的工具实现,真令人开心 ————— 匿名.

因为一直对响应式编程很感兴趣,就使用它来实现一些服务/应用(大大小小),磨练手感。

响应式工具集我选择 Vert.X,来构建自己的 Reactive Web Application。SpringWebFlux虽然Web框架是响应式的,但是同步的配置方案、IOC、AOP我不喜欢,对于享受Reactive来说不够爽。Quarkus 封装的太好了,我比较喜欢原生(作为一名学习者/个人开发者而不是团队成员),把控自己应用的方方面面(我可没有自造HTTPServer的打算)。

先上图

动画.gif

如图展示了一次验证码的发送和验证(用windows通知模拟手机接收短信)。

验证码验证服务如何设计?

image.png

验证码验证,在我们

  • 登录
  • 身份验证
  • 帐号注册
  • 找回密码

的时候会用到,作为一种身份验证的方式。接受验证码的方式可以是手机号(短信)、邮箱(邮件)。

当用户触发一次验证事件时:

  1. 发送一个验证码申请请求给服务端
  2. 服务段收到请求,生成一个验证码与当前会话(用户和服务(比如登录服务、修改代码服务))绑定
  3. 发送验证码到某个手机号或邮箱
  4. 用户收到->填写验证码,并提交验证码开始验证请求
  5. 服务端验证验证码,返回验证状态(成功、失败、过期、无效成),结束

通常,假如验证码通过的话,服务端会继续进行下一步操作,比如直接登录,保存用户登录态,返回重定向到首页

*有空加个流程图

一些约束

  • 一个验证码session由用户和服务唯一确定;服务是账号登陆证、注册帐号等这些需要用到验证码的场景
  • 一个session同一时刻与一个验证码绑定
  • 一个验证码有过期时间(比如60s后失效)
  • 一个session也有过期时间(避免收到任何验证请求都走一边流程),通常比验证码过期时间长

对后端来说,需要什么组件?

  • 验证码 Session 管理器
    • 生成新验证码
    • 存储验证码
    • 一对一绑定验证码和session(用户与服务唯一确定)
    • 验证验证码(正确?过期?)
  • 先就这吧,够了

对客户端来说,需要什么接口?

  • POST /api/verification-code/ask-for | 获取验证码

    {
        "user": "18620210001",    // 手机号
        "service": "SIGIN_IN"    // 登录,这里表示登录场景服务
    }
    
  • POST /api/verification-code/validate | 验证码验证

    {
        "user": "18620210001",
        "service": "SIGIN_IN",
        "code": 925191   //验证码
    }
    

    Response Status Code:

    • 200 - 验证成功
    • 400 - 验证失败
    • 410 - 验证码过期
    • 404 - 无可用验证码

    Response Body 如何设计看自己了

如何开发?

  • Web框架选择 Vert.x Web
  • 验证码存储MongoDB、SQLDB、Redis都可以(能过期就像)(我选用Java缓存对象)
  • 验证码生成器,4或6位数字带不带字母都行(我用ThreadLocalRadom生成随机 100_000 - 999_999 int)
  • 别老玩Spring servicesrespositoryscontrollers 那套了
  • Vert.x 比较觉得你很👍,你会妥善处理好一切你可能会遇到的异常,还不会写出阻塞事件循环的代码
  • 写一个短信应用,来模拟接受验证码短信和显示

Here Is Key Code

Full Code: awesome-project/verification-code-app at master · onemsg/awesome-project (github.com)

VerificationCode

验证码的生命周期管理都包含在类 VerificationCode 中(naming better, annotation less):

package com.onemsg.sunday.service;

import java.time.Duration;
import java.util.Objects;
import java.util.concurrent.ThreadLocalRandom;
import java.util.function.BiFunction;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;

public class VerificationCode {
    
    public final int id;
    public final String user;  
    public final String service;
    public final int data;
    public final long timestramp = System.currentTimeMillis();
    public final long duration;

    /** 验证码默认过期时间,60_000毫秒  */ 
    public static final long DEFAULT_DURATION = 60_000L;
    public static final BiFunction<String,String,Integer> ID_GENERATOR = Objects::hash;

    private VerificationCode(String user, String service, int data, long duration){
        this.user = user;
        this.service = service;
        this.data = data;
        this.duration = duration;
        id = ID_GENERATOR.apply(user, service);
    }

    private static Cache<Integer, VerificationCode> store = Caffeine.newBuilder()
        .maximumSize(100_000)
        .expireAfterWrite(Duration.ofSeconds(600))
        .build();
    
    public static VerificationCode create(String user, String service, long duration){
        Objects.requireNonNull(user);
        Objects.requireNonNull(service);

        int data = generateCode();
        VerificationCode code = new VerificationCode(user, service, data, duration);
        store.put(code.id, code);
        return code;
    }

    public static VerificationCode create(String user, String service) {
        return create(user, service, DEFAULT_DURATION);
    }

    
    public static VerificationCode get(String user, String service){
        Objects.requireNonNull(user);
        Objects.requireNonNull(service);

        return store.getIfPresent(ID_GENERATOR.apply(user, service));
    }


    public static VerifiedState verify(String user, String service, int code){
        Objects.requireNonNull(user);
        Objects.requireNonNull(service);

        int id = ID_GENERATOR.apply(user, service);
        var vcode = store.getIfPresent(id);
        
        if( vcode == null) return VerifiedState.NOT_FOUND;

        if( System.currentTimeMillis() > vcode.timestramp + vcode.duration)
            return VerifiedState.EXPIRED;
        
        return vcode.data == code ? VerifiedState.SUCCESS : VerifiedState.FAIL;
    }


    static final int generateCode(){
        return ThreadLocalRandom.current().nextInt(100_000, 999_999);
    }


    public enum VerifiedState {
        /** 验证正确 */
        SUCCESS,
        /** 验证错误 */
        FAIL,
        /** 验证码过期 */
        EXPIRED,
        /** 验证码不存在 */
        NOT_FOUND
    }
}

路由处理器

  • /api/verification-code/ask-for 路由处理器:
    void handleCodeAskFor(RoutingContext rtx) {

        String user;
        String service;
        try {
            var body = rtx.getBodyAsJson();
            user = body.getString("user");
            service = body.getString("service");
            Objects.requireNonNull(user);
            Objects.requireNonNull(service);
        } catch (Exception e) {
            rtx.fail(400, new HttpBadParamException(e.getMessage()));
            return;
        }

        var code = VerificationCode.create(user, service, DEFAULT_CODE_DURATION);

        sendCode(code.data, user); // 调用短信发送服务(异步)

        rtx.end();
    }
  • /api/verification-code/validate 路由处理器:

    void handleCodeValidate(RoutingContext rtx) {
        String user;
        String service;
        int code = 0;
        try {
            var body = rtx.getBodyAsJson();
            user = body.getString("user");
            service = body.getString("service");
            code = body.getInteger("code");
            Objects.requireNonNull(user);
            Objects.requireNonNull(service);
        } catch (Exception e) {
            log.warn("json body 获取出错", e);
            rtx.fail(400, new HttpBadParamException(e.getMessage()));
            return;
        }

        VerifiedState state = VerificationCode.verify(user, service, code);

        if (VerifiedState.SUCCESS == state) {
            rtx.end();
        } else if (VerifiedState.FAIL == state) {
            var errorModel = ErrorModel.of(400, "验证失败", "当前验证码不正确: " + code);
            respondJson(rtx, 400, errorModel);
        } else if (VerifiedState.EXPIRED == state) {
            var errorModel = ErrorModel.of(410, "验证码已过期", null);
            respondJson(rtx, 410, errorModel);
        } else if (VerifiedState.NOT_FOUND == state) {
            var errorModel = ErrorModel.of(404, "无可用验证码", null);
            respondJson(rtx, 404, errorModel);
        }

    }
  • 异常处理器 这里的异常处理粗糙点

提醒:在异常处理器中,千万别再调用 rtx.fail 方法,否则会限于死循环,电脑卡死

    void handleException(RoutingContext rtx) {

        Throwable t = rtx.failure();

        log.warn("HTTP {} {} 处理发生异常", rtx.request().method(), rtx.request().uri(), t);

        if (t == null) {
            rtx.response().setStatusCode(rtx.statusCode()).end();
        }
        ErrorModel errorModel = null;
        var res = rtx.response();
        if (t instanceof HttpBadParamException) {
            var e = (HttpBadParamException) t;
            errorModel = ErrorModel.of(400, "HTTP 请求参数错误", e.getMessage());
            res.setStatusCode(400);
        } else {
            errorModel = ErrorModel.of(rtx.statusCode(), "HTTP 请求处理发生错误", rtx.failure().getMessage());
            res.setStatusCode(rtx.statusCode());
        }
        res.putHeader(HttpHeaders.CONTENT_TYPE, "application/json") 
            .end(Json.encodeToBuffer(errorModel));
    }

短信发送?

OK,我另外做了一个短信应用,模拟了手机(有自己的手机号,能接收、发送短信)和网络(手机之间传输短信),当手机号在收到短信时会打印信息到控制台顺带发个Windows通知(方便查看);这个应用(下面会说下设计)还提供了一个公开接口(类似各种云厂家的短信服务API),方便调用:

POST http://localhost:3001/api/sendMsg
Authorization: Basic faeFaeGEerfteEFmO458
Content-Type: application/json

{
    "Message": "【哔哩哔哩】大会员5周年“萌力冲刺”,4.6折买1年送3个月,限时2天> https://b23.tv/V5dMfh ,如已购买请忽略 退订回T",
    "PhoneNumber": "18620210001"
}

有了这个短信服务接口之后,封装一个短信发送方法就很容易了。当然作为响应式编程,不能阻塞哦😗

@Slf4j
public class MessageSender {
    
    static HttpClient client = HttpClient.newHttpClient();

    static String webhook = "http://localhost:3001/api/sendMsg";
    static URI uri = URI.create(webhook);

    private MessageSender() {}

    public static void sendAsync(String message, String phoneNumber){
        client.sendAsync( buildRequest(message, phoneNumber), BodyHandlers.ofString())
            .whenCompleteAsync( (res, t) -> {
                if(t != null){
                    log.warn("验证码短信 [to {}] 发送出现异常", phoneNumber, t);
                    return;
                }

                if(200 == res.statusCode()){
                    log.info("验证码短信 [to {}] 已发送", phoneNumber);
                }else {
                    log.warn("验证码短信 [to {}] 发送失败 | {} - {}", res.statusCode(), res.body());
                }
            });
    }

    private static HttpRequest buildRequest(String message, String phoneNumber){

        String body = toJson(message, phoneNumber);
        return HttpRequest.newBuilder()
            .uri(uri)
            .POST(BodyPublishers.ofString(body))
            .header("Authorization", "Basic faeFaeGEerfteEFmO458")
            .header("Content-Type", "application/json")
            .build();
    }

    private static String toJson(String message, String phoneNumber){
        return String.format("{\"Message\": \"%s\", \"PhoneNumber\": \"%s\"}", 
            message, phoneNumber);
    }

}

发送验证码短信:

    private static final String MESSAGE_FORMAT = "【SUNDAY】你的验证码是 %s,请勿告诉他人";

    private static void sendCode(int code, String user) {
        MessageSender.sendAsync(String.format(MESSAGE_FORMAT, code), user);
    }

短信应用

👉目录结构:

未命名文件.png

调用两次短信服务

POST http://localhost:3001/api/sendMsg
Authorization: Basic faeFaeGEerfteEFmO458
Content-Type: application/json

{
    "Message": "【哔哩哔哩】大会员5周年“萌力冲刺”,4.6折买1年送3个月,限时2天> https://b23.tv/V5dMfh ,如已购买请忽略 退订回T",
    "PhoneNumber": "17520212021"
}

###
POST http://localhost:3001/api/sendMsg
Authorization: Basic faeFaeGEerfteEFmO458
Content-Type: application/json

{
    "Message": "【宜家俱乐部】焕新家装季,收纳有妙招!至10/25来四元桥宜家\n⭐帕克思衣柜每满4000送300元电子券\n⭐斯玛斯塔儿童衣柜每满1500送100元电子券\n 线上线下同享>  c8b.co/Vpz8xpA 回0退订",
    "PhoneNumber": "17520212021"
}

看下日志👇

image.png

手机、5G网络和 Event Driven

  • MobilePhone

    /**
      * 模拟手机设备
      * 
      * @author onemsg
      * @since 2021-10
      */
     @Slf4j
     public class MobilePhone {
    
         private static final NetWork5G NETWORK = new NetWork5G();
         private static final ExecutorService executor = Executors.newFixedThreadPool(5);
    
         public final String phoneNumber;
    
         public final Deque<Message> inBoxes = new ConcurrentLinkedDeque<>();
    
         private MobilePhone(String phoneNumber) {
             Objects.requireNonNull(phoneNumber);
             this.phoneNumber = phoneNumber;
         }
    
         public void send(String phoneNumber, String message) {
             Packet packet = new Packet(message, this.phoneNumber, phoneNumber);
             NETWORK.transport(packet);
         }
    
         public Future<Void> sendAsync(String phoneNumber, String message){
            return  executor.submit(() -> send(phoneNumber, message), null);
         }
    
         void accept(String phoneNumber, String message) {
             Message newMessage = new Message(message, phoneNumber, LocalDateTime.now());
             inBoxes.add(newMessage);
             notice(newMessage);
    
             AcceptEvent event = new AcceptEvent(this.phoneNumber, newMessage);
             EventQueue.publish(event);
         }
    
         private static final DateTimeFormatter DTF = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL);
    
         private synchronized void notice(Message message) {
             log.info("Receive message from: {} {}", message.sender, message.datetime.format(DTF));
             System.out.println("= = = = = = = = = = = = = = = =");
             System.out.printf("PhoneNumber: %s %n", phoneNumber);
             System.out.printf("From: %s - %s %n", message.sender, message.datetime.format(DTF));
             System.out.printf("Message: %s %n", message.content);
             System.out.println("= = = = = = = = = = = = = = = =");
         }
    
         /**
          * 创建一部新手机,并入网
          * 
          * @param phoneNumber 手机号
          */
         public static MobilePhone create(String phoneNumber) {
             if (NETWORK.phones.containsKey(phoneNumber)) {
                 return NETWORK.phones.get(phoneNumber);
             }
    
             var newPhone = new MobilePhone(phoneNumber);
             NETWORK.register(newPhone);
             return newPhone;
         }
    
         public static void register(Consumer<AcceptEvent> consumer) {
             EventQueue.register(consumer);
         }
    
         @ToString
         @AllArgsConstructor
         public static class Message {
    
             public final String content;
    
             public final String sender;
    
             public final LocalDateTime datetime;
    
         }
     }
    
  • NetWork5G

    /**
     * 模拟 5G 网络 
     * 
     * @author onemsg
     * @since 2021-10
     */
    @Slf4j
    class NetWork5G {
    
        Map<String, MobilePhone> phones = new ConcurrentHashMap<>();
    
        public void register(MobilePhone phone) {
            phones.put(phone.phoneNumber, phone);
            log.info("phone {} 已入网", phone.phoneNumber);
        }
    
        void transport(Packet packet) {
            var toPhone = phones.get(packet.to);
            if(toPhone != null){
                toPhone.accept(packet.from, packet.content);
            }
        }
    
        @ToString
        @AllArgsConstructor
        static class Packet {
            final String content;
            final String from;
            final String to;
        }
    
    }
    
  • EventQueue

    @Slf4j
    public class EventQueue {
    
        private static BlockingQueue<AcceptEvent> queue = new ArrayBlockingQueue<>(1024);
    
        private static final Thread worker;
    
        private static Set<Consumer<AcceptEvent>> consumers = new ConcurrentHashSet<>();
    
        static {
            worker = new Thread(() -> {
                AcceptEvent event = null;
                while (true) {
                    if(Thread.currentThread().isInterrupted()){
                        return;
                    }
    
                    try {
                        event = queue.take();
                    } catch (InterruptedException e) {
                        log.warn("Event Queue is Interrupted", e);
                        return;
                    }
                    for (Consumer<AcceptEvent> consumer : consumers) {
                        consumer.accept(event);
                    }
                }
            }, "EventQueue-Worker-Thread");
    
            worker.setDaemon(true);
            worker.start();
        }
    
        private EventQueue() {}
    
        static void publish(AcceptEvent event){
            queue.add(event);
        }
    
        public static void register(Consumer<AcceptEvent> consumer){
            consumers.add(consumer);
        }
    
        public static void stop(){
            worker.interrupt();
        }
    
        @AllArgsConstructor
        @ToString
        public static class AcceptEvent{
    
            public final String phoneNumber;
            public final Message message;
        }
    
    }
    

🎈 使用的容器和处理模型尽量保证线程安全

如何给你的 Windows10/11 发送通知?

image.png

📣🔊📢 Dont need third libs, here is code 👇

import java.awt.AWTException;
import java.awt.SystemTray;
import java.awt.TrayIcon;
import java.awt.TrayIcon.MessageType;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.net.URL;
import java.util.concurrent.TimeUnit;

import javax.imageio.ImageIO;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class WindowsNoticer{
    
    // windows 托盘图标,比如QQ的小企鹅
    private static final String ICON_FILE = "icon.png"; 

    private TrayIcon trayIcon;
    private String tooltip;

    public WindowsNoticer(){
        this("短信模拟收件箱");
    }

    public WindowsNoticer(String tooltip){

        this.tooltip = tooltip;

        URL url = WindowsNoticer.class.getClassLoader().getResource(ICON_FILE);
        BufferedImage image = null;
        try {
            image = ImageIO.read(url);
        } catch (IOException e) {
            throw new RuntimeException("WindowsNoticer 创建失败, icon 文件没有找到", e);
        }
        trayIcon = new TrayIcon(image, tooltip);
        trayIcon.setImageAutoSize(true);
        try {
            SystemTray.getSystemTray().add(trayIcon);
        } catch (AWTException e) {
            throw new RuntimeException("WindowsNoticer 创建失败, desktop system tray is missing", e);
        }

        log.debug("WindowsNoticer - {} 创建成功", tooltip);
    }

    public void notice(String title, String message){
        trayIcon.displayMessage(title, message, MessageType.INFO);
    }

    public void stop() {
        SystemTray.getSystemTray().remove(trayIcon);
        log.debug("WindowsNoticer - {} 已暂停", tooltip);
    }

    public static void main(String[] args) throws InterruptedException{

        WindowsNoticer noticer = new WindowsNoticer();

        noticer.notice("【掘金】系统消息", "Hello, 别忘了为你喜欢的文章点赞👍");
        TimeUnit.SECONDS.sleep(5);
        noticer.stop();
    }

}

结语

image.png