能随时把想到的东西用喜欢的工具实现,真令人开心 ————— 匿名.
因为一直对响应式编程很感兴趣,就使用它来实现一些服务/应用(大大小小),磨练手感。
响应式工具集我选择 Vert.X,来构建自己的 Reactive Web Application。SpringWebFlux虽然Web框架是响应式的,但是同步的配置方案、IOC、AOP我不喜欢,对于享受Reactive来说不够爽。Quarkus 封装的太好了,我比较喜欢原生(作为一名学习者/个人开发者而不是团队成员),把控自己应用的方方面面(我可没有自造HTTPServer的打算)。
先上图
如图展示了一次验证码的发送和验证(用windows通知模拟手机接收短信)。
验证码验证服务如何设计?
验证码验证,在我们
- 登录
- 身份验证
- 帐号注册
- 找回密码
的时候会用到,作为一种身份验证的方式。接受验证码的方式可以是手机号(短信)、邮箱(邮件)。
当用户触发一次验证事件时:
- 发送一个验证码申请请求给服务端
- 服务段收到请求,生成一个验证码与当前会话(用户和服务(比如登录服务、修改代码服务))绑定
- 发送验证码到某个手机号或邮箱
- 用户收到->填写验证码,并提交验证码开始验证请求
- 服务端验证验证码,返回验证状态(成功、失败、过期、无效成),结束
通常,假如验证码通过的话,服务端会继续进行下一步操作,比如直接登录,保存用户登录态,返回重定向到首页
*有空加个流程图
一些约束
- 一个验证码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
services、respositorys、controllers那套了 - 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);
}
短信应用
👉目录结构:
调用两次短信服务
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"
}
看下日志👇
手机、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 发送通知?
📣🔊📢 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();
}
}