通过transmittable-thread-local源码理解线程池线程本地变量传递的原理(下)

TTL实现的基本原理TTL设计上使用了大量的委托(Delegate),委托是C#里面的说法,对标Java的设计模式就是代理模式。举个简单的例子:@Slf4j public class StaticDelegate { public static void main(String[] args) throws Exception { new RunnableDelegate(() -> log.info("Hello World!")).run(); @Slf4j @RequiredArgsConstructor private static final class RunnableDelegate implements Runnable { private final Runnable runnable; @Override public void run() { try { log.info("Before run..."); runnable.run(); log.info("After run..."); } finally { log.info("Finally run..."); // 输出结果: 23:45:27.763 [main] INFO club.throwable.juc.StaticDelegate$RunnableDelegate - Before run... 23:45:27.766 [main] INFO club.throwable.juc.StaticDelegate - Hello World! 23:45:27.766 [main] INFO club.throwable.juc.StaticDelegate$RunnableDelegate - After run... 23:45:27.766 [main] INFO club.throwable.juc.StaticDelegate$RunnableDelegate - Finally run... 复制代码委托如果使用纯熟的话,可以做出很多十分有用的功能,例如可以基于Micrometer去统计任务的执行时间,上报到Prometheus,然后用Grafana做监控和展示:// 需要引入io.micrometer:micrometer-core:${version} @Slf4j public class MeterDelegate { public static void main(String[] args) throws Exception { Executor executor = Executors.newFixedThreadPool(1); Runnable task = () -> { try { // 模拟耗时 Thread.sleep(1000); } catch (Exception ignore) { Map<String, String> tags = new HashMap<>(8); tags.put("_class", "MeterDelegate"); executor.execute(new MicrometerDelegate(task, "test-task", tags)); TimeUnit.SECONDS.sleep(Long.MAX_VALUE); @Slf4j @RequiredArgsConstructor private static final class MicrometerDelegate implements Runnable { private final Runnable runnable; private final String taskType; private final Map<String, String> tags; @Override public void run() { long start = System.currentTimeMillis(); try { runnable.run(); } finally { long end = System.currentTimeMillis(); List<Tag> tagsList = Lists.newArrayList(); Optional.ofNullable(tags).ifPresent(x -> x.forEach((k, v) -> { tagsList.add(Tag.of(k, v)); Metrics.summary(taskType, tagsList).record(end - start); 复制代码委托理论上只要不线程栈溢出,可以无限层级地包装,有点像洋葱的结构,原始的目标方法会被包裹在最里面并且最后执行:public static void main(String[] args) throws Exception { Runnable target = () -> log.info("target"); Delegate level1 = new Delegate(target); Delegate level2 = new Delegate(level1); Delegate level3 = new Delegate(level2); // ...... @RequiredArgsConstructor static class Delegate implements Runnable{ private final Runnable runnable; @Override public void run() { runnable.run(); 复制代码当然,委托的层级越多,代码结构就会越复杂,不利于理解和维护。多层级委托这个洋葱结构,再配合Java反射API剥离对具体方法调用的依赖,就是Java中切面编程的普遍原理,spring-aop就是这样实现的。委托如果再结合Agent和字节码增强(使用ASM、Javassist等),可以实现类加载时期替换对应的Runnable、Callable或者一般接口的实现,这样就能无感知完成了增强功能。此外,TTL中还使用了模板方法模式,如:@Slf4j public class TemplateMethod { public static void main(String[] args) throws Exception { Runnable runnable = () -> log.info("Hello World!"); Template template = new Template(runnable) { @Override protected void beforeExecute() { log.info("BeforeExecute..."); @Override protected void afterExecute() { log.info("AfterExecute..."); template.run(); @RequiredArgsConstructor static abstract class Template implements Runnable { private final Runnable runnable; protected void beforeExecute() { @Override public void run() { beforeExecute(); runnable.run(); afterExecute(); protected void afterExecute() { // 输出结果: 00:25:32.862 [main] INFO club.throwable.juc.TemplateMethod - BeforeExecute... 00:25:32.865 [main] INFO club.throwable.juc.TemplateMethod - Hello World! 00:25:32.865 [main] INFO club.throwable.juc.TemplateMethod - AfterExecute... 复制代码分析了两种设计模式,下面简单理解一下TTL实现的伪代码:# TTL extends InheritableThreadLocal # Holder of TTL -> InheritableThreadLocal<WeakHashMap<TransmittableThreadLocal<Object>, ?>> [? => NULL] (1)创建一个全局的Holder,用于保存父线程(或者明确了父线程的子线程)的TTL对象,这里注意,是TTL对象,Holder是当作Set使用 (2)(父)线程A中使用了TTL,则所有设置的变量会被TTL捕获 (3)(子)线程B使用了TtlRunnable(Runnable的TTL实现,使用了前面提到的委托,像Callable的实现是TtlCallable),会重放所有存储在TTL中的,来自于线程A的存储变量 (4)线程B重放完毕后,清理线程B独立产生的ThreadLocal变量,归还变TTL的变量 复制代码主要就是这几步,里面的话术有点抽象,后面一节分析源码的时候会详细讲解。TTL的源码分析主要分析:框架的骨架。核心类TransmittableThreadLocal。发射器Transmitter。捕获、重放和复原。Agent模块。TTL框架骨架TTL是一个十分精悍的框架,它依赖少量的类实现了比较强大的功能,除了提供给用户使用的API,还提供了基于Agent和字节码增强实现了无感知增强泛线程池对应类的功能,这一点是比较惊艳的。这里先分析编程式的API,再简单分析Agent部分的实现。笔者阅读TTL框架的时间是2020年五一劳动节前后,当前的最新发行版本为2.11.4。TTL的项目结构很简单:- transmittable-thread-local - com.alibaba.ttl - spi SPI接口和一些实现 - threadpool 线程池增强,包括ThreadFactory和线程池的Wrapper等 - agent 线程池的Agent实现相关 最外层的包有一些Wrapper的实现和TTL 复制代码先看spi包:- spi TtlAttachments TtlAttachmentsDelegate TtlEnhanced TtlWrapper 复制代码TtlEnhanced是TTL的标识接口(空接口),标识具体的组件被TTL增强:public interface TtlEnhanced { 复制代码通过instanceof关键字就可以判断具体的实现是否TTL增强过的组件。TtlWrapper接口继承自接口TtlEnhanced,用于标记实现类可以解包装获得原始实例:public interface TtlWrapper<T> extends TtlEnhanced { // 返回解包装实例,实际是就是原始实例 @NonNull T unwrap(); 复制代码TtlAttachments接口也是继承自接口TtlEnhanced,用于为TTL添加K-V结构的附件,TtlAttachmentsDelegate是其实现类,K-V的存储实际上是委托给ConcurrentHashMap:public interface TtlAttachments extends TtlEnhanced { // 添加K-V附件 void setTtlAttachment(@NonNull String key, Object value); // 通过KEY获取值 <T> T getTtlAttachment(@NonNull String key); // 标识自动包装的KEY,Agent模式会使用自动包装,这个时候会传入一个附件的K-V,其中KEY就是KEY_IS_AUTO_WRAPPER String KEY_IS_AUTO_WRAPPER = "ttl.is.auto.wrapper"; // TtlAttachmentsDelegate public class TtlAttachmentsDelegate implements TtlAttachments { private final ConcurrentMap<String, Object> attachments = new ConcurrentHashMap<String, Object>(); @Override public void setTtlAttachment(@NonNull String key, Object value) { attachments.put(key, value); @Override @SuppressWarnings("unchecked") public <T> T getTtlAttachment(@NonNull String key) { return (T) attachments.get(key); 复制代码因为TTL的实现覆盖了泛线程池Executor、ExecutorService、ScheduledExecutorService、ForkJoinPool和TimerTask(在TTL中组件已经标记为过期,推荐使用ScheduledExecutorService),范围比较广,短篇幅无法分析所有的源码,而且它们的实现思路是基本一致的,笔者下文只会挑选Executor的实现路线进行分析。核心类TransmittableThreadLocalTransmittableThreadLocal是TTL的核心类,TTL框架就是用这个类来命名的。先看它的构造函数和关键属性:// 函数式接口,TTL拷贝器 @FunctionalInterface public interface TtlCopier<T> { // 拷贝父属性 T copy(T parentValue); public class TransmittableThreadLocal<T> extends InheritableThreadLocal<T> implements TtlCopier<T> { // 日志句柄,使用的不是SLF4J的接口,而是java.util.logging的实现 private static final Logger logger = Logger.getLogger(TransmittableThreadLocal.class.getName()); // 是否禁用忽略NULL值的语义 private final boolean disableIgnoreNullValueSemantics; // 默认是false,也就是不禁用忽略NULL值的语义,也就是忽略NULL值,也就是默认的话,NULL值传入不会覆盖原来已经存在的值 public TransmittableThreadLocal() { this(false); // 可以通过手动设置,去覆盖IgnoreNullValue的语义,如果设置为true,则是支持NULL值的设置,设置为true的时候,与ThreadLocal的语义一致 public TransmittableThreadLocal(boolean disableIgnoreNullValueSemantics) { this.disableIgnoreNullValueSemantics = disableIgnoreNullValueSemantics; // 先忽略其他代码 复制代码disableIgnoreNullValueSemantics属性相关可以查看Issue157,下文分析方法的时候也会说明具体的场景。TransmittableThreadLocal继承自InheritableThreadLocal,本质就是ThreadLocal,那它到底怎么样保证变量可以在线程池中的线程传递?接着分析其他所有方法:public class TransmittableThreadLocal<T> extends InheritableThreadLocal<T> implements TtlCopier<T> { // 拷贝器的拷贝方法实现 public T copy(T parentValue) { return parentValue; // 模板方法,留给子类实现,在TtlRunnable或者TtlCallable执行前回调 protected void beforeExecute() { // 模板方法,留给子类实现,在TtlRunnable或者TtlCallable执行后回调 protected void afterExecute() { // 获取值,直接从InheritableThreadLocal#get()获取 @Override public final T get() { T value = super.get(); // 如果值不为NULL 或者 禁用了忽略空值的语义(也就是和ThreadLocal语义一致),则重新添加TTL实例自身到存储器 if (disableIgnoreNullValueSemantics || null != value) addThisToHolder(); return value; @Override public final void set(T value) { // 如果不禁用忽略空值的语义,也就是需要忽略空值,并且设置的入参值为空,则做一次彻底的移除,包括从存储器移除TTL自身实例,TTL(ThrealLocalMap)中也移除对应的值 if (!disableIgnoreNullValueSemantics && null == value) { // may set null to remove value remove(); } else { // TTL(ThrealLocalMap)中设置对应的值 super.set(value); // 添加TTL实例自身到存储器 addThisToHolder(); // 从存储器移除TTL自身实例,从TTL(ThrealLocalMap)中移除对应的值 @Override public final void remove() { removeThisFromHolder(); super.remove(); // 从TTL(ThrealLocalMap)中移除对应的值 private void superRemove() { super.remove(); // 拷贝值,主要是拷贝get()的返回值 private T copyValue() { return copy(get()); // 存储器,本身就是一个InheritableThreadLocal(ThreadLocal) // 它的存放对象是WeakHashMap<TransmittableThreadLocal<Object>, ?>类型,而WeakHashMap的VALUE总是为NULL,这里当做Set容器使用,WeakHashMap支持NULL值 private static InheritableThreadLocal<WeakHashMap<TransmittableThreadLocal<Object>, ?>> holder = new InheritableThreadLocal<WeakHashMap<TransmittableThreadLocal<Object>, ?>>() { @Override protected WeakHashMap<TransmittableThreadLocal<Object>, ?> initialValue() { return new WeakHashMap<TransmittableThreadLocal<Object>, Object>(); @Override protected WeakHashMap<TransmittableThreadLocal<Object>, ?> childValue(WeakHashMap<TransmittableThreadLocal<Object>, ?> parentValue) { // 注意这里的WeakHashMap总是拷贝父线程的值 return new WeakHashMap<TransmittableThreadLocal<Object>, Object>(parentValue); // 添加TTL自身实例到存储器,不存在则添加策略 @SuppressWarnings("unchecked") private void addThisToHolder() { if (!holder.get().containsKey(this)) { holder.get().put((TransmittableThreadLocal<Object>) this, null); // WeakHashMap supports null value. // 从存储器移除TTL自身的实例 private void removeThisFromHolder() { holder.get().remove(this); // 执行目标方法,isBefore决定回调beforeExecute还是afterExecute,注意此回调方法会吞掉所有的异常只打印日志 private static void doExecuteCallback(boolean isBefore) { for (TransmittableThreadLocal<Object> threadLocal : holder.get().keySet()) { try { if (isBefore) threadLocal.beforeExecute(); else threadLocal.afterExecute(); } catch (Throwable t) { if (logger.isLoggable(Level.WARNING)) { logger.log(Level.WARNING, "TTL exception when " + (isBefore ? "beforeExecute" : "afterExecute") + ", cause: " + t.toString(), t); // DEBUG模式下打印TTL里面的所有值 static void dump(@Nullable String title) { if (title != null && title.length() > 0) { System.out.printf("Start TransmittableThreadLocal[%s] Dump...%n", title); } else { System.out.println("Start TransmittableThreadLocal Dump..."); for (TransmittableThreadLocal<Object> threadLocal : holder.get().keySet()) { System.out.println(threadLocal.get()); System.out.println("TransmittableThreadLocal Dump end!"); // DEBUG模式下打印TTL里面的所有值 static void dump() { dump(null); // 省略静态类Transmitter的实现代码 复制代码这里一定要记住holder是全局静态的,并且它自身也是一个InheritableThreadLocal(get()方法也是线程隔离的),它实际上就是父线程管理所有TransmittableThreadLocal的桥梁。这里可以考虑一个单线程的例子来说明TransmittableThreadLocal的存储架构:public class TtlSample3 { static TransmittableThreadLocal<String> TTL1 = new TransmittableThreadLocal<>(); static TransmittableThreadLocal<String> TTL2 = new TransmittableThreadLocal<>(); static TransmittableThreadLocal<String> TTL3 = new TransmittableThreadLocal<>(); public static void main(String[] args) throws Exception { TTL1.set("VALUE-1"); TTL2.set("VALUE-2"); TTL3.set("VALUE-3"); 复制代码这里简化了例子,只演示了单线程的场景,图中的一些对象的哈希码有可能每次启动JVM实例都不一样,这里只是做示例:注释里面也提到,holder里面的WeakHashMap是当成Set容器使用,映射的值都是NULL,每次遍历它的所有KEY就能获取holder里面的所有的TransmittableThreadLocal实例,它是一个全局的存储器,但是本身是一个InheritableThreadLocal,多线程共享后的映射关系会相对复杂:再聊一下disableIgnoreNullValueSemantics的作用,默认情况下disableIgnoreNullValueSemantics=false,TTL如果设置NULL值,会直接从holder移除对应的TTL实例,在TTL#get()方法被调用的时候,如果原来持有的属性不为NULL,该TTL实例会重新加到holder。如果设置disableIgnoreNullValueSemantics=true,则set(null)的语义和ThreadLocal一致。见下面的例子:public class TtlSample4 { static TransmittableThreadLocal<Integer> TL1 = new TransmittableThreadLocal<Integer>(false) { @Override protected Integer initialValue() { return 5; @Override protected Integer childValue(Integer parentValue) { return 10; static TransmittableThreadLocal<Integer> TL2 = new TransmittableThreadLocal<Integer>(true) { @Override protected Integer initialValue() { return 5; @Override protected Integer childValue(Integer parentValue) { return 10; public static void main(String[] args) throws Exception { TL1.set(null); TL2.set(null); Thread t1 = new Thread(TtlRunnable.get(() -> { System.out.println(String.format("Thread:%s,value:%s", Thread.currentThread().getName(), TL1.get())); }), "T1"); Thread t2 = new Thread(TtlRunnable.get(() -> { System.out.println(String.format("Thread:%s,value:%s", Thread.currentThread().getName(), TL2.get())); }), "T2"); t1.start(); t2.start(); TimeUnit.SECONDS.sleep(Long.MAX_VALUE); // 输出结果: Thread:T2,value:null Thread:T1,value:5 复制代码这是因为框架的设计者不想把NULL作为有状态的值,如果真的有需要保持和ThreadLocal一致的用法,可以在构造TransmittableThreadLocal实例的时候传入true。发射器Transmitter发射器Transmitter是TransmittableThreadLocal的一个公有静态类,它的核心功能是传输所有的TransmittableThreadLocal实例和提供静态方法注册当前线程的变量到其他线程。按照笔者阅读源码的习惯,先看构造函数和关键属性:// # TransmittableThreadLocal#Transmitter public static class Transmitter { // 保存手动注册的ThreadLocal->TtlCopier映射,这里是因为部分API提供了TtlCopier给用户实现 private static volatile WeakHashMap<ThreadLocal<Object>, TtlCopier<Object>> threadLocalHolder = new WeakHashMap<ThreadLocal<Object>, TtlCopier<Object>>(); // threadLocalHolder更变时候的监视器 private static final Object threadLocalHolderUpdateLock = new Object(); // 标记WeakHashMap中的ThreadLocal的对应值为NULL的属性,便于后面清理 private static final Object threadLocalClearMark = new Object(); // 默认的拷贝器,影子拷贝,直接返回父值 private static final TtlCopier<Object> shadowCopier = new TtlCopier<Object>() { @Override public Object copy(Object parentValue) { return parentValue; // 私有构造,说明只能通过静态方法提供外部调用 private Transmitter() { throw new InstantiationError("Must not instantiate this class"); // 私有静态类,快照,保存从holder中捕获的所有TransmittableThreadLocal和外部手动注册保存在threadLocalHolder的ThreadLocal的K-V映射快照 private static class Snapshot { final WeakHashMap<TransmittableThreadLocal<Object>, Object> ttl2Value; final WeakHashMap<ThreadLocal<Object>, Object> threadLocal2Value; private Snapshot(WeakHashMap<TransmittableThreadLocal<Object>, Object> ttl2Value, WeakHashMap<ThreadLocal<Object>, Object> threadLocal2Value) { this.ttl2Value = ttl2Value; this.threadLocal2Value = threadLocal2Value; 复制代码Transmitter在设计上是一个典型的工具类,外部只能调用其公有静态方法。接着看其他静态方法:// # TransmittableThreadLocal#Transmitter public static class Transmitter { //######################################### 捕获 ########################################################### // 捕获当前线程绑定的所有的TransmittableThreadLocal和已经注册的ThreadLocal的值 - 使用了用时拷贝快照的策略 // 笔者注:它一般在构造任务实例的时候被调用,因此当前线程相对于子线程或者线程池的任务就是父线程,其实本质是捕获父线程的所有线程本地变量的值 @NonNull public static Object capture() { return new Snapshot(captureTtlValues(), captureThreadLocalValues()); // 新建一个WeakHashMap,遍历TransmittableThreadLocal#holder中的所有TransmittableThreadLocal的Entry,获取K-V,存放到这个新的WeakHashMap返回 private static WeakHashMap<TransmittableThreadLocal<Object>, Object> captureTtlValues() { WeakHashMap<TransmittableThreadLocal<Object>, Object> ttl2Value = new WeakHashMap<TransmittableThreadLocal<Object>, Object>(); for (TransmittableThreadLocal<Object> threadLocal : holder.get().keySet()) { ttl2Value.put(threadLocal, threadLocal.copyValue()); return ttl2Value; // 新建一个WeakHashMap,遍历threadLocalHolder中的所有ThreadLocal的Entry,获取K-V,存放到这个新的WeakHashMap返回 private static WeakHashMap<ThreadLocal<Object>, Object> captureThreadLocalValues() { final WeakHashMap<ThreadLocal<Object>, Object> threadLocal2Value = new WeakHashMap<ThreadLocal<Object>, Object>(); for (Map.Entry<ThreadLocal<Object>, TtlCopier<Object>> entry : threadLocalHolder.entrySet()) { final ThreadLocal<Object> threadLocal = entry.getKey(); final TtlCopier<Object> copier = entry.getValue(); threadLocal2Value.put(threadLocal, copier.copy(threadLocal.get())); return threadLocal2Value; //######################################### 重放 ########################################################### // 重放capture()方法中捕获的TransmittableThreadLocal和手动注册的ThreadLocal中的值,本质是重新拷贝holder中的所有变量,生成新的快照 // 笔者注:重放操作一般会在子线程或者线程池中的线程的任务执行的时候调用,因此此时的holder#get()拿到的是子线程的原来就存在的本地线程变量,重放操作就是把这些子线程原有的本地线程变量备份 @NonNull public static Object replay(@NonNull Object captured) { final Snapshot capturedSnapshot = (Snapshot) captured; return new Snapshot(replayTtlValues(capturedSnapshot.ttl2Value), replayThreadLocalValues(capturedSnapshot.threadLocal2Value)); // 重放所有的TTL的值 @NonNull private static WeakHashMap<TransmittableThreadLocal<Object>, Object> replayTtlValues(@NonNull WeakHashMap<TransmittableThreadLocal<Object>, Object> captured) { // 新建一个新的备份WeakHashMap,其实也是一个快照 WeakHashMap<TransmittableThreadLocal<Object>, Object> backup = new WeakHashMap<TransmittableThreadLocal<Object>, Object>(); // 这里的循环针对的是子线程,用于获取的是子线程的所有线程本地变量 for (final Iterator<TransmittableThreadLocal<Object>> iterator = holder.get().keySet().iterator(); iterator.hasNext(); ) { TransmittableThreadLocal<Object> threadLocal = iterator.next(); // 拷贝holder当前线程(子线程)绑定的所有TransmittableThreadLocal的K-V结构到备份中 backup.put(threadLocal, threadLocal.get()); // 清理所有的非捕获快照中的TTL变量,以防有中间过程引入的额外的TTL变量(除了父线程的本地变量)影响了任务执行后的重放操作 // 简单来说就是:移除所有子线程的不包含在父线程捕获的线程本地变量集合的中所有子线程本地变量和对应的值 * 这个问题可以举个简单的例子: * static TransmittableThreadLocal<Integer> TTL = new TransmittableThreadLocal<>(); * 线程池中的子线程C中原来初始化的时候,在线程C中绑定了TTL的值为10087,C线程是核心线程不会主动销毁。 * 父线程P在没有设置TTL值的前提下,调用了线程C去执行任务,那么在C线程的Runnable包装类中通过TTL#get()就会获取到10087,显然是不符合预期的 * 所以,在C线程的Runnable包装类之前之前,要从C线程的线程本地变量,移除掉不包含在父线程P中的所有线程本地变量,确保Runnable包装类执行期间只能拿到父线程中捕获到的线程本地变量 * 下面这个判断和移除做的就是这个工作 if (!captured.containsKey(threadLocal)) { iterator.remove(); threadLocal.superRemove(); // 重新设置TTL的值到捕获的快照中 // 其实真实的意图是:把从父线程中捕获的所有线程本地变量重写设置到TTL中,本质上,子线程holder里面的TTL绑定的值会被刷新 setTtlValuesTo(captured); // 回调模板方法beforeExecute doExecuteCallback(true); return backup; // 提取WeakHashMap中的KeySet,遍历所有的TransmittableThreadLocal,重新设置VALUE private static void setTtlValuesTo(@NonNull WeakHashMap<TransmittableThreadLocal<Object>, Object> ttlValues) { for (Map.Entry<TransmittableThreadLocal<Object>, Object> entry : ttlValues.entrySet()) { TransmittableThreadLocal<Object> threadLocal = entry.getKey(); // 重新设置TTL值,本质上,当前线程(子线程)holder里面的TTL绑定的值会被刷新 threadLocal.set(entry.getValue()); // 重放所有的手动注册的ThreadLocal的值 private static WeakHashMap<ThreadLocal<Object>, Object> replayThreadLocalValues(@NonNull WeakHashMap<ThreadLocal<Object>, Object> captured) { // 新建备份 final WeakHashMap<ThreadLocal<Object>, Object> backup = new WeakHashMap<ThreadLocal<Object>, Object>(); // 注意这里是遍历捕获的快照中的ThreadLocal for (Map.Entry<ThreadLocal<Object>, Object> entry : captured.entrySet()) { final ThreadLocal<Object> threadLocal = entry.getKey(); // 添加到备份中 backup.put(threadLocal, threadLocal.get()); final Object value = entry.getValue(); // 如果值为清除标记则绑定在当前线程的变量进行remove,否则设置值覆盖 if (value == threadLocalClearMark) threadLocal.remove(); else threadLocal.set(value); return backup; // 从relay()或者clear()方法中恢复TransmittableThreadLocal和手工注册的ThreadLocal的值对应的备份 // 笔者注:恢复操作一般会在子线程或者线程池中的线程的任务执行的时候调用 public static void restore(@NonNull Object backup) { final Snapshot backupSnapshot = (Snapshot) backup; restoreTtlValues(backupSnapshot.ttl2Value); restoreThreadLocalValues(backupSnapshot.threadLocal2Value); private static void restoreTtlValues(@NonNull WeakHashMap<TransmittableThreadLocal<Object>, Object> backup) { // 回调模板方法afterExecute doExecuteCallback(false); // 这里的循环针对的是子线程,用于获取的是子线程的所有线程本地变量 for (final Iterator<TransmittableThreadLocal<Object>> iterator = holder.get().keySet().iterator(); iterator.hasNext(); ) { TransmittableThreadLocal<Object> threadLocal = iterator.next(); // 如果子线程原来就绑定的线程本地变量的值,如果不包含某个父线程传来的对象,那么就删除 // 这一步可以结合前面reply操作里面的方法段一起思考,如果不删除的话,就相当于子线程的原来存在的线程本地变量绑定值被父线程对应的值污染了 if (!backup.containsKey(threadLocal)) { iterator.remove(); threadLocal.superRemove(); // 重新设置TTL的值到捕获的快照中 // 其实真实的意图是:把子线程的线程本地变量恢复到reply()的备份(前面的循环已经做了父线程捕获变量的判断),本质上,等于把holder中绑定于子线程本地变量的部分恢复到reply操作之前的状态 setTtlValuesTo(backup); // 恢复所有的手动注册的ThreadLocal的值 private static void restoreThreadLocalValues(@NonNull WeakHashMap<ThreadLocal<Object>, Object> backup) { for (Map.Entry<ThreadLocal<Object>, Object> entry : backup.entrySet()) { final ThreadLocal<Object> threadLocal = entry.getKey(); threadLocal.set(entry.getValue()); 复制代码这里三个核心方法,看起来比较抽象,要结合多线程的场景和一些空间想象进行推敲才能比较容易地理解:capture():捕获操作,父线程原来就存在的线程本地变量映射和手动注册的线程本地变量映射捕获,得到捕获的快照值captured。reply():重放操作,子线程原来就存在的线程本地变量映射和手动注册的线程本地变量生成备份backup,刷新captured的所有值到子线程在全局存储器holder中绑定的值。restore():复原操作,子线程原来就存在的线程本地变量映射和手动注册的线程本地变量恢复成backup。setTtlValuesTo()这个方法比较隐蔽,要特别要结合多线程和空间思维去思考,例如当入参是captured,本质是从父线程捕获到的绑定在父线程的所有线程本地变量,调用的时机在reply()和restore(),这两个方法只会在子线程中调用,setTtlValuesTo()里面拿到的TransmittableThreadLocal实例调用set()方法相当于把绑定在父线程的所有线程本地变量的值全部刷新到子线程当前绑定的TTL中的线程本地变量的值,更深层次地想,是基于外部的传入值刷新了子线程绑定在全局存储器holder里面绑定到该子线程的线程本地变量的值。Transmitter还有不少静态工具方法,这里不做展开,可以参考项目里面的测试demo和README.md进行调试。捕获、重放和复原其实上面一节已经介绍了Transmitter提供的捕获、重放和复原的API,这一节主要结合分析TtlRunnable中的相关逻辑。TtlRunnable的源码如下:public final class TtlRunnable implements Runnable, TtlWrapper<Runnable>, TtlEnhanced, TtlAttachments { // 存放从父线程捕获得到的线程本地变量映射的备份 private final AtomicReference<Object> capturedRef; // 原始的Runable实例 private final Runnable runnable; // 执行之后是否释放TTL值引用 private final boolean releaseTtlValueReferenceAfterRun; private TtlRunnable(@NonNull Runnable runnable, boolean releaseTtlValueReferenceAfterRun) { // 这里关键点:TtlRunnable实例化的时候就已经进行了线程本地变量的捕获,所以一定是针对父线程的,因为此时任务还没提交到线程池 this.capturedRef = new AtomicReference<Object>(capture()); this.runnable = runnable; this.releaseTtlValueReferenceAfterRun = releaseTtlValueReferenceAfterRun; @Override public void run() { // 获取父线程捕获到的线程本地变量映射的备份,做一些前置判断 Object captured = capturedRef.get(); if (captured == null || releaseTtlValueReferenceAfterRun && !capturedRef.compareAndSet(captured, null)) { throw new IllegalStateException("TTL value reference is released after run!"); // 重放操作 Object backup = replay(captured); try { // 真正的Runnable调用 runnable.run(); } finally { // 复原操作 restore(backup); @Nullable public static TtlRunnable get(@Nullable Runnable runnable) { return get(runnable, false, false); @Nullable public static TtlRunnable get(@Nullable Runnable runnable, boolean releaseTtlValueReferenceAfterRun, boolean idempotent) { if (null == runnable) return null; if (runnable instanceof TtlEnhanced) { // avoid redundant decoration, and ensure idempotency if (idempotent) return (TtlRunnable) runnable; else throw new IllegalStateException("Already TtlRunnable!"); return new TtlRunnable(runnable, releaseTtlValueReferenceAfterRun); // 省略其他不太重要的方法 复制代码其实关注点只需要放在构造函数、run()方法,其他都是基于此做修饰或者扩展。构造函数的源码说明,capture()在TtlRunnable实例化的时候已经被调用,实例化它的一般就是父线程,所以整体的执行流程如下:Agent模块启用Agent功能,需要在Java的启动参数添加:-javaagent:path/to/transmittable-thread-local-x.yzx.jar。原理是通过Instrumentation回调激发ClassFileTransformer实现目标类的字节码增强,使用到javassist,被增强的类主要是泛线程池的类:Executor体系:主要包括ThreadPoolExecutor和ScheduledThreadPoolExecutor,对应的字节码增强类实现是TtlExecutorTransformlet。ForkJoinPool:对应的字节码增强类实现是TtlForkJoinTransformlet。TimerTask:对应的字节码增强类实现是TtlTimerTaskTransformlet。Agent的入口类是TtlAgent,这里查看对应的源码:public final class TtlAgent { public static void premain(String agentArgs, @NonNull Instrumentation inst) { kvs = splitCommaColonStringToKV(agentArgs); Logger.setLoggerImplType(getLogImplTypeFromAgentArgs(kvs)); final Logger logger = Logger.getLogger(TtlAgent.class); try { logger.info("[TtlAgent.premain] begin, agentArgs: " + agentArgs + ", Instrumentation: " + inst); final boolean disableInheritableForThreadPool = isDisableInheritableForThreadPool(); // 装载所有的JavassistTransformlet final List<JavassistTransformlet> transformletList = new ArrayList<JavassistTransformlet>(); transformletList.add(new TtlExecutorTransformlet(disableInheritableForThreadPool)); transformletList.add(new TtlForkJoinTransformlet(disableInheritableForThreadPool)); if (isEnableTimerTask()) transformletList.add(new TtlTimerTaskTransformlet()); final ClassFileTransformer transformer = new TtlTransformer(transformletList); inst.addTransformer(transformer, true); logger.info("[TtlAgent.premain] addTransformer " + transformer.getClass() + " success"); logger.info("[TtlAgent.premain] end"); ttlAgentLoaded = true; } catch (Exception e) { String msg = "Fail to load TtlAgent , cause: " + e.toString(); logger.log(Level.SEVERE, msg, e); throw new IllegalStateException(msg, e); 复制代码List<JavassistTransformlet>作为参数传入ClassFileTransformer的实现类TtlTransformer中,其中的转换方法为:public class TtlTransformer implements ClassFileTransformer { private final List<JavassistTransformlet> transformletList = new ArrayList<JavassistTransformlet>(); TtlTransformer(List<? extends JavassistTransformlet> transformletList) { for (JavassistTransformlet transformlet : transformletList) { this.transformletList.add(transformlet); logger.info("[TtlTransformer] add Transformlet " + transformlet.getClass() + " success"); @Override public final byte[] transform(@Nullable final ClassLoader loader, @Nullable final String classFile, final Class<?> classBeingRedefined, final ProtectionDomain protectionDomain, @NonNull final byte[] classFileBuffer) { try { // Lambda has no class file, no need to transform, just return. if (classFile == null) return NO_TRANSFORM; final String className = toClassName(classFile); ClassInfo classInfo = new ClassInfo(className, classFileBuffer, loader); // 这里做变量,如果字节码被修改,则跳出循环返回 for (JavassistTransformlet transformlet : transformletList) { transformlet.doTransform(classInfo); if (classInfo.isModified()) return classInfo.getCtClass().toBytecode(); } catch (Throwable t) { String msg = "Fail to transform class " + classFile + ", cause: " + t.toString(); logger.log(Level.SEVERE, msg, t); throw new IllegalStateException(msg, t); return NO_TRANSFORM; 复制代码这里挑选TtlExecutorTransformlet的部分方法来看:@Override public void doTransform(@NonNull final ClassInfo classInfo) throws IOException, NotFoundException, CannotCompileException { // 如果当前加载的类包含java.util.concurrent.ThreadPoolExecutor或者java.util.concurrent.ScheduledThreadPoolExecutor if (EXECUTOR_CLASS_NAMES.contains(classInfo.getClassName())) { final CtClass clazz = classInfo.getCtClass(); // 遍历所有的方法进行增强 for (CtMethod method : clazz.getDeclaredMethods()) { updateSubmitMethodsOfExecutorClass_decorateToTtlWrapperAndSetAutoWrapperAttachment(method); // 省略其他代码 // 省略其他代码 private void updateSubmitMethodsOfExecutorClass_decorateToTtlWrapperAndSetAutoWrapperAttachment(@NonNull final CtMethod method) throws NotFoundException, CannotCompileException { final int modifiers = method.getModifiers(); if (!Modifier.isPublic(modifiers) || Modifier.isStatic(modifiers)) return; // 这里主要在java.lang.Runnable构造时候调用com.alibaba.ttl.TtlRunnable#get()包装为com.alibaba.ttl.TtlRunnable // 在java.util.concurrent.Callable构造时候调用com.alibaba.ttl.TtlCallable#get()包装为com.alibaba.ttl.TtlCallable // 并且设置附件K-V为ttl.is.auto.wrapper=true CtClass[] parameterTypes = method.getParameterTypes(); StringBuilder insertCode = new StringBuilder(); for (int i = 0; i < parameterTypes.length; i++) { final String paramTypeName = parameterTypes[i].getName(); if (PARAM_TYPE_NAME_TO_DECORATE_METHOD_CLASS.containsKey(paramTypeName)) { String code = String.format( // decorate to TTL wrapper, // and then set AutoWrapper attachment/Tag "$%d = %s.get($%d, false, true);" + "\ncom.alibaba.ttl.threadpool.agent.internal.transformlet.impl.Utils.setAutoWrapperAttachment($%<d);", i + 1, PARAM_TYPE_NAME_TO_DECORATE_METHOD_CLASS.get(paramTypeName), i + 1); logger.info("insert code before method " + signatureOfMethod(method) + " of class " + method.getDeclaringClass().getName() + ": " + code); insertCode.append(code); if (insertCode.length() > 0) method.insertBefore(insertCode.toString()); 复制代码上面分析的方法的功能,就是让java.util.concurrent.ThreadPoolExecutor和java.util.concurrent.ScheduledThreadPoolExecutor的字节码被增强,提交的java.lang.Runnable类型的任务会被包装为TtlRunnable,提交的java.util.concurrent.Callable类型的任务会被包装为TtlCallable,实现了无入侵无感知地嵌入TTL的功能。小结TTL在使用线程池等会池化复用线程的执行组件情况下,提供ThreadLocal值的传递功能,解决异步执行时上下文传递的问题。 它是一个Java标准库,为框架/中间件设施开发提供的标配能力,项目代码精悍,只依赖了javassist做字节码增强,实现Agent模式下的近乎无入侵提供TTL功能的特性。TTL能在业务代码中实现透明/自动完成所有异步执行上下文的可定制、规范化的捕捉/传递,如果恰好碰到异步执行时上下文传递的问题,建议可以尝试此库。参考资料:JDK11相关源码TTL源码个人博客Throwable's Blog

基于Quartz编写一个可复用的分布式调度任务管理WebUI组件

前提创业小团队,无论选择任何方案,都优先考虑节省成本。关于分布式定时调度框架,成熟的候选方案有XXL-JOB、Easy Scheduler、Light Task Scheduler和Elastic Job等等,其实这些之前都在生产环境使用过。但是想要搭建高可用的分布式调度平台,这些框架(无论是否去中心化)都需要额外的服务器资源去部署中心调度管理服务实例,甚至有时候还会依赖一些中间件如Zookeeper。回想之前花过一段时间看Quartz的源码去分析它的线程模型,想到了它可以基于MySQL,通过一个不是很推荐的X锁方案(SELECT FOR UPDATE加锁)实现服务集群中单个触发器只有一个节点(加锁成功的那个节点)能够执行,这样子,就能够仅仅依赖于现有的MySQL实例资源实现分布式调度任务管理。一般来说,有关系型数据保存需求的业务应用都会有自己的MySQL实例,这样子就能几乎零成本引入一个分布式调度管理模块。某个加班的周六下午敲定了初步方案之后,花了几个小时把这个轮子造出来了,效果如下:方案设计先说说用到的所有依赖:Uikit:选用的前端的一个轻量级的UI框架,主要是考虑到轻量、文档和组件相对齐全。JQuery:选用js框架,原因只有一个:简单。Freemarker:模板引擎,主观上比Jsp和Thymeleaf好用。Quartz:工业级调度器。项目的依赖如下:<dependencies> <dependency> <groupId>org.quartz-scheduler</groupId> <artifactId>quartz</artifactId> <exclusions> <exclusion> <groupId>com.zaxxer</groupId> <artifactId>HikariCP-java7</artifactId> </exclusion> </exclusions> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> <scope>provided</scope> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context-support</artifactId> <scope>provided</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> <scope>provided</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-freemarker</artifactId> <scope>provided</scope> </dependency> <dependency> <groupId>com.zaxxer</groupId> <artifactId>HikariCP</artifactId> <scope>provided</scope> </dependency> </dependencies> 复制代码Uikit和JQuery可以直接使用现成的CDN即可:<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.2.2/dist/css/uikit.min.css"/> <script src="https://cdn.jsdelivr.net/npm/uikit@3.2.2/dist/js/uikit.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/uikit@3.2.2/dist/js/uikit-icons.min.js"></script> <script src="https://cdn.bootcss.com/jquery/3.4.1/jquery.min.js"></script> 复制代码表设计引入了Quartz的依赖后,在它的org.quartz.impl.jdbcjobstore包下可以看到一系列的DDL,一般使用MySQL的场景下关注tables_mysql.sql和tables_mysql_innodb.sql两个文件即可,笔者所在团队的开发规范MySQL的引擎必须选择innodb,所以选用了后者。应用中的定时任务信息应该单独拎出来管理,方便提供统一的查询和更变API。值得注意的是,Quartz内建的表使用了大量的外键,所以尽量通过Quartz提供的API去增删改它内建表的内容,切勿手动操作,否则可能会引发各种意想不到的故障。引入的两个新的表包括调度任务表schedule_task和调度任务参数表schedule_task_parameter:CREATE TABLE `schedule_task` `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '主键', `creator` VARCHAR(16) NOT NULL DEFAULT 'admin' COMMENT '创建人', `editor` VARCHAR(16) NOT NULL DEFAULT 'admin' COMMENT '修改人', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `edit_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', `version` BIGINT NOT NULL DEFAULT 1 COMMENT '版本号', `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除标识', `task_id` VARCHAR(64) NOT NULL COMMENT '任务标识', `task_class` VARCHAR(256) NOT NULL COMMENT '任务类', `task_type` VARCHAR(16) NOT NULL COMMENT '任务类型,CRON,SIMPLE', `task_group` VARCHAR(32) NOT NULL DEFAULT 'DEFAULT' COMMENT '任务分组', `task_expression` VARCHAR(256) NOT NULL COMMENT '任务表达式', `task_description` VARCHAR(256) COMMENT '任务描述', `task_status` TINYINT NOT NULL DEFAULT 0 COMMENT '任务状态', UNIQUE uniq_task_class_task_group (`task_class`, `task_group`), UNIQUE uniq_task_id (`task_id`) ) COMMENT '调度任务'; CREATE TABLE `schedule_task_parameter` `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '主键', `task_id` VARCHAR(64) NOT NULL COMMENT '任务标识', `parameter_value` VARCHAR(1024) NOT NULL COMMENT '参数值', UNIQUE uniq_task_id (`task_id`) ) COMMENT '调度任务参数'; 复制代码参数统一用JSON字符串存放,所以一个调度任务实体对应0或者1个调度任务参数实体。这里没有考虑多个应用使用同一个数据源的问题,其实这个问题应该考虑基于不同的org.quartz.jobStore.tablePrefix实现隔离,也就是不同的应用如果共库,或者每个应用的Quartz使用不同的表前缀区分,或者单独抽离所有调度任务到同一个应用中。Quartz的工作模式Quartz在设计调度模型的时候实际上是对触发器Trigger进行调度,一般在调度对应的任务Job的时候,需要绑定触发器和该被调度的任务实例,然后当触发器到了触发时间点的时候就会被激发,接着回调该触发器关联的Job实例的execute()方法。可以简单理解为触发器和Job实例是多对多的关系。简单来看就是这样的:为了实现这个多对多的关系,Quartz为Job(实际上是JobDetail)和Trigger分别定义了JobKey和TriggerKey用于作为两者的唯一标识。TriggerKey -> [name, group] JobKey -> [name, group] 复制代码为了降低维护成本,笔者把这个多对多的绑定关系强制约束为一对一,并且把TriggerKey和JobKey同化如下:JobKey,TriggerKey -> [jobClassName, ${spring.application.name} || applicationName] 复制代码实际上,调度相关的大部分工作都是委托给org.quartz.Scheduler完成,举下例子:public interface Scheduler { ......省略无关的代码...... // 添加调度任务 - 包括任务内容和触发器 void scheduleJob(JobDetail jobDetail, Set<? extends Trigger> triggersForJob, boolean replace) throws SchedulerException; // 移除触发器 boolean unscheduleJob(TriggerKey triggerKey) throws SchedulerException; // 移除任务内容 boolean deleteJob(JobKey jobKey) throws SchedulerException; ......省略无关的代码...... 复制代码笔者要做的,就是通过schedule_task表管理服务的定时任务,通过org.quartz.Scheduler提供的API把任务的具体操作移交给Quartz,并且添加一些扩展功能。这个模块已经被封装为一个轻量级的框架,命名为quartz-web-ui-kit,下称kit。kit核心逻辑分析kit的所有核心功能都封装在模块quartz-web-ui-kit-core中,主要功能包括:其中WebUI部分是通过Freemarker、JQuery和Uikit简单编写出来,主要包括三个页面:templates - common/script.ftl 公共脚本 - task-add.ftl 添加新任务页面 - task-edit.ftl 编辑任务页面 - task-list.ftl 任务列表 复制代码调度任务管理的核心方法是QuartzWebUiKitService#refreshScheduleTask():@Autowired private Scheduler scheduler; public void refreshScheduleTask(ScheduleTask task, Trigger oldTrigger, TriggerKey triggerKey, Trigger newTrigger) throws Exception { JobDataMap jobDataMap = prepareJobDataMap(task); JobDetail jobDetail = JobBuilder.newJob((Class<? extends Job>) Class.forName(task.getTaskClass())) .withIdentity(task.getTaskClass(), task.getTaskGroup()) .usingJobData(jobDataMap) .build(); // 总是覆盖 if (ScheduleTaskStatus.ONLINE == ScheduleTaskStatus.fromType(task.getTaskStatus())) { scheduler.scheduleJob(jobDetail, Collections.singleton(newTrigger), Boolean.TRUE); } else { if (null != oldTrigger) { scheduler.unscheduleJob(triggerKey); private JobDataMap prepareJobDataMap(ScheduleTask task) { JobDataMap jobDataMap = new JobDataMap(); jobDataMap.put("scheduleTask", JsonUtils.X.format(task)); ScheduleTaskParameter taskParameter = scheduleTaskParameterDao.selectByTaskId(task.getTaskId()); if (null != taskParameter) { Map<String, Object> parameterMap = JsonUtils.X.parse(taskParameter.getParameterValue(), new TypeReference<Map<String, Object>>() { jobDataMap.putAll(parameterMap); return jobDataMap; 复制代码其实是任意任务触发或者变动,都直接覆盖对应的JobDetail和Trigger,这样就能保证调度任务内容和触发器都是全新的,下一轮调度就会生效。任务类被抽象为AbstractScheduleTask,这个类承载了任务执行和大量的扩展功能:@DisallowConcurrentExecution public abstract class AbstractScheduleTask implements Job { protected Logger logger = LoggerFactory.getLogger(getClass()); @Autowired(required = false) private List<ScheduleTaskExecutionPostProcessor> processors; @Override public void execute(JobExecutionContext context) throws JobExecutionException { String scheduleTask = context.getMergedJobDataMap().getString("scheduleTask"); ScheduleTask task = JsonUtils.X.parse(scheduleTask, ScheduleTask.class); ScheduleTaskInfo info = ScheduleTaskInfo.builder() .taskId(task.getTaskId()) .taskClass(task.getTaskClass()) .taskDescription(task.getTaskDescription()) .taskExpression(task.getTaskExpression()) .taskGroup(task.getTaskGroup()) .taskType(task.getTaskType()) .build(); long start = System.currentTimeMillis(); info.setStart(start); // 在MDC中添加traceId便于追踪调用链 MappedDiagnosticContextAssistant.X.processInMappedDiagnosticContext(() -> { try { if (enableLogging()) { logger.info("任务[{}]-[{}]-[{}]开始执行......", task.getTaskId(), task.getTaskClass(), task.getTaskDescription()); // 执行前的处理器回调 processBeforeTaskExecution(info); // 子类实现的任务执行逻辑 executeInternal(context); // 执行成功的处理器回调 processAfterTaskExecution(info, ScheduleTaskExecutionStatus.SUCCESS); } catch (Exception e) { info.setThrowable(e); if (enableLogging()) { logger.info("任务[{}]-[{}]-[{}]执行异常", task.getTaskId(), task.getTaskClass(), task.getTaskDescription(), e); // 执行异常的处理器回调 processAfterTaskExecution(info, ScheduleTaskExecutionStatus.FAIL); } finally { long end = System.currentTimeMillis(); long cost = end - start; info.setEnd(end); info.setCost(cost); if (enableLogging() && null == info.getThrowable()) { logger.info("任务[{}]-[{}]-[{}]执行完毕,耗时:{} ms......", task.getTaskId(), task.getTaskClass(), task.getTaskDescription(), cost); // 执行结束的处理器回调 processAfterTaskCompletion(info); protected boolean enableLogging() { return true; * 内部执行方法 - 子类实现 * @param context context protected abstract void executeInternal(JobExecutionContext context); * 拷贝任务信息 private ScheduleTaskInfo copyScheduleTaskInfo(ScheduleTaskInfo info) { return ScheduleTaskInfo.builder() .cost(info.getCost()) .start(info.getStart()) .end(info.getEnd()) .throwable(info.getThrowable()) .taskId(info.getTaskId()) .taskClass(info.getTaskClass()) .taskDescription(info.getTaskDescription()) .taskExpression(info.getTaskExpression()) .taskGroup(info.getTaskGroup()) .taskType(info.getTaskType()) .build(); // 任务执行之前回调 void processBeforeTaskExecution(ScheduleTaskInfo info) { if (null != processors) { for (ScheduleTaskExecutionPostProcessor processor : processors) { processor.beforeTaskExecution(copyScheduleTaskInfo(info)); // 任务执行完毕时回调 void processAfterTaskExecution(ScheduleTaskInfo info, ScheduleTaskExecutionStatus status) { if (null != processors) { for (ScheduleTaskExecutionPostProcessor processor : processors) { processor.afterTaskExecution(copyScheduleTaskInfo(info), status); // 任务完结时回调 void processAfterTaskCompletion(ScheduleTaskInfo info) { if (null != processors) { for (ScheduleTaskExecutionPostProcessor processor : processors) { processor.afterTaskCompletion(copyScheduleTaskInfo(info)); 复制代码需要执行的目标调度任务类只需要继承AbstractScheduleTask即可获得这些功能。另外,调度任务后置处理器ScheduleTaskExecutionPostProcessor参考了Spring中的BeanPostProcessor和TransactionSynchronization的设计:public interface ScheduleTaskExecutionPostProcessor { default void beforeTaskExecution(ScheduleTaskInfo info) { default void afterTaskExecution(ScheduleTaskInfo info, ScheduleTaskExecutionStatus status) { default void afterTaskCompletion(ScheduleTaskInfo info) { 复制代码通过此后置处理器可以完成任务预警和任务执行日志持久化等各种功能。笔者通过ScheduleTaskExecutionPostProcessor已经实现了内置的预警功能,抽象出一个预警策略接口AlarmStrategy:public interface AlarmStrategy { void process(ScheduleTaskInfo scheduleTaskInfo); // 默认启用的实现是无预警策略 public class NoneAlarmStrategy implements AlarmStrategy { @Override public void process(ScheduleTaskInfo scheduleTaskInfo) { 复制代码通过覆盖AlarmStrategy的Bean配置即可获得自定义的预警策略,如:@Slf4j @Component public class LoggingAlarmStrategy implements AlarmStrategy { @Override public void process(ScheduleTaskInfo scheduleTaskInfo) { if (null != scheduleTaskInfo.getThrowable()) { log.error("任务执行异常,任务内容:{}", JsonUtils.X.format(scheduleTaskInfo), scheduleTaskInfo.getThrowable()); 复制代码笔者通过此接口的自定义现实,把所有的预警都打印到团队内部的钉钉群中,打印了任务的执行时间、状态以及耗时等等信息,一旦出现异常会及时@所有人,便于及时监控任务的健康和后续的调优。使用kit项目quartz-web-ui-kit的项目结构如下:quartz-web-ui-kit - quartz-web-ui-kit-core 核心包 - h2-example H2数据库的演示例子 - mysql-5.x-example MySQL5.x版本的演示例子 - mysql-8.x-example MySQL8.x版本的演示例子 复制代码如果单纯想体验一下kit的功能,那么直接下载此项目,启动h2-example模块中的club.throwable.h2.example.H2App,然后访问http://localhost:8081/quartz/kit/task/list即可。基于MySQL实例的应用,这里挑选目前用户比较多的MySQL5.x的例子简单说明一下。因为轮子刚造好,没有经过时间的考验,暂时没上交到Maven的仓库,这里需要进行手动编译:git clone https://github.com/zjcscut/quartz-web-ui-kit cd quartz-web-ui-kit mvn clean compile install 复制代码引入依赖(只需要引入quartz-web-ui-kit-core,而且quartz-web-ui-kit-core依赖于spring-boot-starter-web、spring-boot-starter-web、spring-boot-starter-jdbc、spring-boot-starter-freemarker和HikariCP):<dependency> <groupId>club.throwable</groupId> <artifactId>quartz-web-ui-kit-core</artifactId> <version>1.0-SNAPSHOT</version> </dependency> <!-- 这个是必须,MySQL的驱动包 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.48</version> </dependency> 复制代码添加一个配置实现QuartzWebUiKitConfiguration:@Configuration public class QuartzWebUiKitConfiguration implements EnvironmentAware { private Environment environment; @Override public void setEnvironment(Environment environment) { this.environment = environment; @Bean public QuartzWebUiKitPropertiesProvider quartzWebUiKitPropertiesProvider() { return () -> { QuartzWebUiKitProperties properties = new QuartzWebUiKitProperties(); properties.setDriverClassName(environment.getProperty("spring.datasource.driver-class-name")); properties.setUrl(environment.getProperty("spring.datasource.url")); properties.setUsername(environment.getProperty("spring.datasource.username")); properties.setPassword(environment.getProperty("spring.datasource.password")); return properties; 复制代码这里由于quartz-web-ui-kit-core设计时候考虑到部分组件的加载顺序,使用了ImportBeanDefinitionRegistrar钩子接口,所以无法通过@Value或者@Autowired实现属性注入,因为这两个注解的处理顺序比较靠后,如果用过MyBatis的MapperScannerConfigurer就会理解这里的问题。quartz-web-ui-kit-core依赖中已经整理好一份DDL脚本:scripts - quartz-h2.sql - quartz-web-ui-kit-h2-ddl.sql - quartz-mysql-innodb.sql - quartz-web-ui-kit-mysql-ddl.sql 复制代码需要提前在目标数据库执行quartz-mysql-innodb.sql和quartz-web-ui-kit-mysql-ddl.sql。一份相对标准的配置文件application.properties如下:spring.application.name=mysql-5.x-example server.port=8082 spring.datasource.driver-class-name=com.mysql.jdbc.Driver # 这个local是本地提前建好的数据库 spring.datasource.url=jdbc:mysql://localhost:3306/local?characterEncoding=utf8&useUnicode=true&useSSL=false spring.datasource.username=root spring.datasource.password=root # freemarker配置 spring.freemarker.template-loader-path=classpath:/templates/ spring.freemarker.cache=false spring.freemarker.charset=UTF-8 spring.freemarker.check-template-location=true spring.freemarker.content-type=text/html spring.freemarker.expose-request-attributes=true spring.freemarker.expose-session-attributes=true spring.freemarker.request-context-attribute=request spring.freemarker.suffix=.ftl 复制代码然后需要添加一个调度任务类,只需要继承club.throwable.quartz.kit.support.AbstractScheduleTask:@Slf4j public class CronTask extends AbstractScheduleTask { @Override protected void executeInternal(JobExecutionContext context) { logger.info("CronTask触发,TriggerKey:{}", context.getTrigger().getKey().toString()); 复制代码接着启动SpringBoot的启动类,然后访问http://localhost:8082/quartz/kit/task/list:通过左侧按钮添加一个定时任务:目前的任务表达式支持两种类型:CRON表达式:格式是cron=你的CRON表达式,如cron=*/20 * * * * ?。简单的周期性执行表达式:格式是intervalInMilliseconds=毫秒值,如intervalInMilliseconds=10000,表示10000毫秒执行一次。其他可选的参数有:repeatCount:表示简单的周期性执行任务的重复次数,默认为Integer.MAX_VALUE。startAt:任务首次执行的时间戳。关于任务表达式参数,没有考虑十分严格的校验,也没有做字符串的trim处理,需要输入紧凑的符合约定格式的特定表达式,如:cron=*/20 * * * * ? intervalInMilliseconds=10000 intervalInMilliseconds=10000,repeatCount=10 复制代码调度任务还支持输入用户的自定义参数,目前简单约定为JSON字符串,这个字符串最后会通过Jackson进行一次处理,再存放到任务的JobDataMap中,实际上会被Quartz持久化到数据库中:{"key":"value"} 复制代码这样就能从JobExecutionContext#getMergedJobDataMap()中获得,例如:@Slf4j public class SimpleTask extends AbstractScheduleTask { @Override protected void executeInternal(JobExecutionContext context) { JobDataMap jobDataMap = context.getMergedJobDataMap(); String value = jobDataMap.getString("key"); 复制代码其他关于kit,有两点设计是笔者基于团队中维护的项目面对的场景做了特化处理:AbstractScheduleTask使用了@DisallowConcurrentExecution注解,任务会禁用并发执行,也就是多节点的情况下,只会有一个服务节点在同一轮触发时间下进行任务调度。CRON类型的任务被禁用了Misfire策略,也就是CRON类型的任务如果错失了触发时机不会有任何操作(这一点可以了解一下Quartz的Misfire策略)。如果不能忍受这两点,切勿直接在生产中使用此工具包。小结本文简单介绍了笔者通过Quartz的加持造了一个轻量级分布式调度服务的轮子,起到了简单易用和节省成本的效果。不足的是,因为考虑到目前团队的项目中存在调度任务需求的服务都是内部的共享服务,笔者没有花很大的精力去完善鉴权、监控等模块,这里也是也是从目前遇到的业务场景考虑,如果引入过多的设计,就会演化成一个重量级的调度框架如Elastic-Job,那样会违背了节省部署成本的初衷。quartz-web-ui-kit项目Github仓库:github.com/zjcscut/qua…

通过源码理解Spring中@Scheduled的实现原理并且实现调度任务动态装载(下)

调度任务动态装载Scheduling模块本身已经支持基于NamespaceHandler支持通过XML文件配置调度任务,但是笔者一直认为XML给人的感觉太"重",使用起来显得太笨重,这里打算扩展出JSON文件配置和基于JDBC数据源配置(也就是持久化任务,这里选用MySQL)。根据前文的源码分析,需要用到SchedulingConfigurer接口的实现,用于在所有调度任务触发之前从外部添加自定义的调度任务。先定义调度任务的一些配置属性类:// 调度任务类型枚举 @Getter @RequiredArgsConstructor public enum ScheduleTaskType { CRON("CRON"), FIXED_DELAY("FIXED_DELAY"), FIXED_RATE("FIXED_RATE"), private final String type; // 调度任务配置,enable属性为全局开关 @Data public class ScheduleTaskProperties { private Long version; private Boolean enable; private List<ScheduleTasks> tasks; // 调度任务集合,笔者设计的时候采用一个宿主类中每个独立方法都是一个任务实例的模式 @Data public class ScheduleTasks { // 这里故意叫Klass代表Class,避免关键字冲突 private String taskHostKlass; private Boolean enable; private List<ScheduleTaskMethod> taskMethods; // 调度任务方法 - enable为任务开关,没有配置会被ScheduleTaskProperties或者ScheduleTasks中的enable覆盖 @Data public class ScheduleTaskMethod { private Boolean enable; private String taskDescription; private String taskMethod; // 时区,cron的计算需要用到 private String timeZone; private String cronExpression; private String intervalMilliseconds; private String initialDelayMilliseconds; 复制代码设计的时候,考虑到多个任务执行方法可以放在同一个宿主类,这样可以方便同一种类的任务进行统一管理,如:public class TaskHostClass { public void task1() { public void task2() { ...... public void taskN() { 复制代码细节方面,intervalMilliseconds和initialDelayMilliseconds的单位设计为毫秒,使用字符串形式,方便可以基于StringValueResolver解析配置文件中的属性配置。添加一个抽象的SchedulingConfigurer:@Slf4j public abstract class AbstractSchedulingConfigurer implements SchedulingConfigurer, InitializingBean, BeanFactoryAware, EmbeddedValueResolverAware { @Getter private StringValueResolver embeddedValueResolver; private ConfigurableBeanFactory configurableBeanFactory; private final List<InternalTaskProperties> internalTasks = Lists.newLinkedList(); private final Set<String> tasksLoaded = Sets.newHashSet(); @Override public void setBeanFactory(BeanFactory beanFactory) throws BeansException { configurableBeanFactory = (ConfigurableBeanFactory) beanFactory; @Override public void afterPropertiesSet() throws Exception { internalTasks.clear(); internalTasks.addAll(loadTaskProperties()); @Override public void setEmbeddedValueResolver(StringValueResolver resolver) { embeddedValueResolver = resolver; @Override public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { for (InternalTaskProperties task : internalTasks) { try { synchronized (tasksLoaded) { String key = task.taskHostKlass() + "#" + task.taskMethod(); // 避免重复加载 if (!tasksLoaded.contains(key)) { if (task instanceof CronTaskProperties) { loadCronTask((CronTaskProperties) task, taskRegistrar); if (task instanceof FixedDelayTaskProperties) { loadFixedDelayTask((FixedDelayTaskProperties) task, taskRegistrar); if (task instanceof FixedRateTaskProperties) { loadFixedRateTask((FixedRateTaskProperties) task, taskRegistrar); tasksLoaded.add(key); } else { log.info("调度任务已经装载,任务宿主类:{},任务执行方法:{}", task.taskHostKlass(), task.taskMethod()); } catch (Exception e) { throw new IllegalStateException(String.format("加载调度任务异常,任务宿主类:%s,任务执行方法:%s", task.taskHostKlass(), task.taskMethod()), e); private ScheduledMethodRunnable loadScheduledMethodRunnable(String taskHostKlass, String taskMethod) throws Exception { Class<?> klass = ClassUtils.forName(taskHostKlass, null); Object target = configurableBeanFactory.getBean(klass); Method method = ReflectionUtils.findMethod(klass, taskMethod); if (null == method) { throw new IllegalArgumentException(String.format("找不到目标方法,任务宿主类:%s,任务执行方法:%s", taskHostKlass, taskMethod)); Method invocableMethod = AopUtils.selectInvocableMethod(method, target.getClass()); return new ScheduledMethodRunnable(target, invocableMethod); private void loadCronTask(CronTaskProperties pops, ScheduledTaskRegistrar taskRegistrar) throws Exception { ScheduledMethodRunnable runnable = loadScheduledMethodRunnable(pops.taskHostKlass(), pops.taskMethod()); String cronExpression = embeddedValueResolver.resolveStringValue(pops.cronExpression()); if (null != cronExpression) { String timeZoneString = embeddedValueResolver.resolveStringValue(pops.timeZone()); TimeZone timeZone; if (null != timeZoneString) { timeZone = TimeZone.getTimeZone(timeZoneString); } else { timeZone = TimeZone.getDefault(); CronTask cronTask = new CronTask(runnable, new CronTrigger(cronExpression, timeZone)); taskRegistrar.addCronTask(cronTask); log.info("装载CronTask[{}#{}()]成功,cron表达式:{},任务描述:{}", cronExpression, pops.taskMethod(), pops.cronExpression(), pops.taskDescription()); private void loadFixedDelayTask(FixedDelayTaskProperties pops, ScheduledTaskRegistrar taskRegistrar) throws Exception { ScheduledMethodRunnable runnable = loadScheduledMethodRunnable(pops.taskHostKlass(), pops.taskMethod()); long fixedDelayMilliseconds = parseDelayAsLong(embeddedValueResolver.resolveStringValue(pops.intervalMilliseconds())); long initialDelayMilliseconds = parseDelayAsLong(embeddedValueResolver.resolveStringValue(pops.initialDelayMilliseconds())); FixedDelayTask fixedDelayTask = new FixedDelayTask(runnable, fixedDelayMilliseconds, initialDelayMilliseconds); taskRegistrar.addFixedDelayTask(fixedDelayTask); log.info("装载FixedDelayTask[{}#{}()]成功,固定延迟间隔:{} ms,初始延迟执行时间:{} ms,任务描述:{}", pops.taskHostKlass(), pops.taskMethod(), fixedDelayMilliseconds, initialDelayMilliseconds, pops.taskDescription()); private void loadFixedRateTask(FixedRateTaskProperties pops, ScheduledTaskRegistrar taskRegistrar) throws Exception { ScheduledMethodRunnable runnable = loadScheduledMethodRunnable(pops.taskHostKlass(), pops.taskMethod()); long fixedRateMilliseconds = parseDelayAsLong(embeddedValueResolver.resolveStringValue(pops.intervalMilliseconds())); long initialDelayMilliseconds = parseDelayAsLong(embeddedValueResolver.resolveStringValue(pops.initialDelayMilliseconds())); FixedRateTask fixedRateTask = new FixedRateTask(runnable, fixedRateMilliseconds, initialDelayMilliseconds); taskRegistrar.addFixedRateTask(fixedRateTask); log.info("装载FixedRateTask[{}#{}()]成功,固定执行频率:{} ms,初始延迟执行时间:{} ms,任务描述:{}", pops.taskHostKlass(), pops.taskMethod(), fixedRateMilliseconds, initialDelayMilliseconds, pops.taskDescription()); private long parseDelayAsLong(String value) { if (null == value) { return 0L; if (value.length() > 1 && (isP(value.charAt(0)) || isP(value.charAt(1)))) { return Duration.parse(value).toMillis(); return Long.parseLong(value); private boolean isP(char ch) { return (ch == 'P' || ch == 'p'); * 加载任务配置,预留给子类实现 protected abstract List<InternalTaskProperties> loadTaskProperties() throws Exception; interface InternalTaskProperties { String taskHostKlass(); String taskMethod(); String taskDescription(); @Builder protected static class CronTaskProperties implements InternalTaskProperties { private String taskHostKlass; private String taskMethod; private String cronExpression; private String taskDescription; private String timeZone; @Override public String taskDescription() { return taskDescription; public String cronExpression() { return cronExpression; public String timeZone() { return timeZone; @Override public String taskHostKlass() { return taskHostKlass; @Override public String taskMethod() { return taskMethod; @Builder protected static class FixedDelayTaskProperties implements InternalTaskProperties { private String taskHostKlass; private String taskMethod; private String intervalMilliseconds; private String initialDelayMilliseconds; private String taskDescription; @Override public String taskDescription() { return taskDescription; public String initialDelayMilliseconds() { return initialDelayMilliseconds; public String intervalMilliseconds() { return intervalMilliseconds; @Override public String taskHostKlass() { return taskHostKlass; @Override public String taskMethod() { return taskMethod; @Builder protected static class FixedRateTaskProperties implements InternalTaskProperties { private String taskHostKlass; private String taskMethod; private String intervalMilliseconds; private String initialDelayMilliseconds; private String taskDescription; @Override public String taskDescription() { return taskDescription; public String initialDelayMilliseconds() { return initialDelayMilliseconds; public String intervalMilliseconds() { return intervalMilliseconds; @Override public String taskHostKlass() { return taskHostKlass; @Override public String taskMethod() { return taskMethod; 复制代码loadTaskProperties()方法用于加载任务配置,留给子类实现。JSON配置JSON配置文件的格式如下(类路径下的scheduling/tasks.json文件):{ "version": 1, "tasks": [ "taskKlass": "club.throwable.schedule.Tasks", "taskMethods": [ "taskType": "FIXED_DELAY", "taskDescription": "processTask1任务", "taskMethod": "processTask1", "intervalMilliseconds": "5000" 复制代码每个层级都有一个enable属性,默认为true,只有强制指定为false的时候才不会装载对应的任务调度方法。这里就是简单继承AbstractSchedulingConfigurer,实现从类路径加载配置的逻辑,定义JsonSchedulingConfigurer:public class JsonSchedulingConfigurer extends AbstractSchedulingConfigurer { // 这里把默认的任务配置JSON文件放在CLASSPATH下的scheduling/tasks.json,可以通过配置项scheduling.json.config.location进行覆盖 @Value("${scheduling.json.config.location:scheduling/tasks.json}") private String location; @Autowired private ObjectMapper objectMapper; @Override protected List<InternalTaskProperties> loadTaskProperties() throws Exception { ClassPathResource resource = new ClassPathResource(location); String content = StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8); ScheduleTaskProperties properties = objectMapper.readValue(content, ScheduleTaskProperties.class); if (Boolean.FALSE.equals(properties.getEnable()) || null == properties.getTasks()) { return Lists.newArrayList(); List<InternalTaskProperties> target = Lists.newArrayList(); for (ScheduleTasks tasks : properties.getTasks()) { if (null != tasks) { List<ScheduleTaskMethod> taskMethods = tasks.getTaskMethods(); if (null != taskMethods) { for (ScheduleTaskMethod taskMethod : taskMethods) { if (!Boolean.FALSE.equals(taskMethod.getEnable())) { if (ScheduleTaskType.CRON == taskMethod.getTaskType()) { target.add(CronTaskProperties.builder() .taskMethod(taskMethod.getTaskMethod()) .cronExpression(taskMethod.getCronExpression()) .timeZone(taskMethod.getTimeZone()) .taskDescription(taskMethod.getTaskDescription()) .taskHostKlass(tasks.getTaskKlass()) .build()); if (ScheduleTaskType.FIXED_DELAY == taskMethod.getTaskType()) { target.add(FixedDelayTaskProperties.builder() .taskMethod(taskMethod.getTaskMethod()) .intervalMilliseconds(taskMethod.getIntervalMilliseconds()) .initialDelayMilliseconds(taskMethod.getInitialDelayMilliseconds()) .taskDescription(taskMethod.getTaskDescription()) .taskHostKlass(tasks.getTaskKlass()) .build()); if (ScheduleTaskType.FIXED_RATE == taskMethod.getTaskType()) { target.add(FixedRateTaskProperties.builder() .taskMethod(taskMethod.getTaskMethod()) .intervalMilliseconds(taskMethod.getIntervalMilliseconds()) .initialDelayMilliseconds(taskMethod.getInitialDelayMilliseconds()) .taskDescription(taskMethod.getTaskDescription()) .taskHostKlass(tasks.getTaskKlass()) .build()); return target; 复制代码添加一个配置类和任务类:@Configuration public class SchedulingAutoConfiguration { @Bean public JsonSchedulingConfigurer jsonSchedulingConfigurer(){ return new JsonSchedulingConfigurer(); // club.throwable.schedule.Tasks @Slf4j @Component public class Tasks { public void processTask1() { log.info("processTask1触发.........."); 复制代码启动SpringBoot应用,某次执行的部分日志如下:2020-03-22 16:24:17.248 INFO 22836 --- [ main] c.t.s.AbstractSchedulingConfigurer : 装载FixedDelayTask[club.throwable.schedule.Tasks#processTask1()]成功,固定延迟间隔:5000 ms,初始延迟执行时间:0 ms,任务描述:processTask1任务 2020-03-22 16:24:22.275 INFO 22836 --- [pool-1-thread-1] club.throwable.schedule.Tasks : processTask1触发.......... 2020-03-22 16:24:27.277 INFO 22836 --- [pool-1-thread-1] club.throwable.schedule.Tasks : processTask1触发.......... 2020-03-22 16:24:32.279 INFO 22836 --- [pool-1-thread-1] club.throwable.schedule.Tasks : processTask1触发.......... ...... 复制代码这里有些细节没有完善,例如配置文件参数的一些非空判断、配置值是否合法等等校验逻辑没有做,如果要设计成一个工业级的类库,这些方面必须要考虑。JDBC数据源配置JDBC数据源这里用MySQL举例说明,先建一个调度任务配置表schedule_task:CREATE TABLE `schedule_task` id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT '主键', edit_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', editor VARCHAR(32) NOT NULL DEFAULT 'admin' COMMENT '修改者', creator VARCHAR(32) NOT NULL DEFAULT 'admin' COMMENT '创建者', deleted BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '软删除标识', task_host_class VARCHAR(256) NOT NULL COMMENT '任务宿主类全类名', task_method VARCHAR(128) NOT NULL COMMENT '任务执行方法名', task_type VARCHAR(16) NOT NULL COMMENT '任务类型', task_description VARCHAR(64) NOT NULL COMMENT '任务描述', cron_expression VARCHAR(128) COMMENT 'cron表达式', time_zone VARCHAR(32) COMMENT '时区', interval_milliseconds BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '执行间隔时间', initial_delay_milliseconds BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '初始延迟执行时间', UNIQUE uniq_class_method (task_host_class, task_method) ) COMMENT '调度任务配置表'; 复制代码其实具体的做法和JSON配置差不多,先引入spring-boot-starter-jdbc,接着编写MysqlSchedulingConfigurer:// DAO @RequiredArgsConstructor public class MysqlScheduleTaskDao { private final JdbcTemplate jdbcTemplate; private static final ResultSetExtractor<List<ScheduleTask>> MULTI = r -> { List<ScheduleTask> tasks = Lists.newArrayList(); while (r.next()) { ScheduleTask task = new ScheduleTask(); tasks.add(task); task.setId(r.getLong("id")); task.setCronExpression(r.getString("cron_expression")); task.setInitialDelayMilliseconds(r.getLong("initial_delay_milliseconds")); task.setIntervalMilliseconds(r.getLong("interval_milliseconds")); task.setTimeZone(r.getString("time_zone")); task.setTaskDescription(r.getString("task_description")); task.setTaskHostClass(r.getString("task_host_class")); task.setTaskMethod(r.getString("task_method")); task.setTaskType(r.getString("task_type")); return tasks; public List<ScheduleTask> selectAllTasks() { return jdbcTemplate.query("SELECT * FROM schedule_task WHERE deleted = 0", MULTI); // MysqlSchedulingConfigurer @RequiredArgsConstructor public class MysqlSchedulingConfigurer extends AbstractSchedulingConfigurer { private final MysqlScheduleTaskDao mysqlScheduleTaskDao; @Override protected List<InternalTaskProperties> loadTaskProperties() throws Exception { List<InternalTaskProperties> target = Lists.newArrayList(); List<ScheduleTask> tasks = mysqlScheduleTaskDao.selectAllTasks(); if (!tasks.isEmpty()) { for (ScheduleTask task : tasks) { ScheduleTaskType scheduleTaskType = ScheduleTaskType.fromType(task.getTaskType()); if (ScheduleTaskType.CRON == scheduleTaskType) { target.add(CronTaskProperties.builder() .taskMethod(task.getTaskMethod()) .cronExpression(task.getCronExpression()) .timeZone(task.getTimeZone()) .taskDescription(task.getTaskDescription()) .taskHostKlass(task.getTaskHostClass()) .build()); if (ScheduleTaskType.FIXED_DELAY == scheduleTaskType) { target.add(FixedDelayTaskProperties.builder() .taskMethod(task.getTaskMethod()) .intervalMilliseconds(String.valueOf(task.getIntervalMilliseconds())) .initialDelayMilliseconds(String.valueOf(task.getInitialDelayMilliseconds())) .taskDescription(task.getTaskDescription()) .taskHostKlass(task.getTaskHostClass()) .build()); if (ScheduleTaskType.FIXED_RATE == scheduleTaskType) { target.add(FixedRateTaskProperties.builder() .taskMethod(task.getTaskMethod()) .intervalMilliseconds(String.valueOf(task.getIntervalMilliseconds())) .initialDelayMilliseconds(String.valueOf(task.getInitialDelayMilliseconds())) .taskDescription(task.getTaskDescription()) .taskHostKlass(task.getTaskHostClass()) .build()); return target; 复制代码记得引入spring-boot-starter-jdbc和mysql-connector-java并且激活MysqlSchedulingConfigurer配置。插入一条记录:INSERT INTO `schedule_task`(`id`, `edit_time`, `create_time`, `editor`, `creator`, `deleted`, `task_host_class`, `task_method`, `task_type`, `task_description`, `cron_expression`, `time_zone`, `interval_milliseconds`, `initial_delay_milliseconds`) VALUES (1, '2020-03-30 23:46:10', '2020-03-30 23:46:10', 'admin', 'admin', 0, 'club.throwable.schedule.Tasks', 'processTask1', 'FIXED_DELAY', '测试任务', NULL, NULL, 10000, 5000); 复制代码然后启动服务,某次执行的输出:2020-03-30 23:47:27.376 INFO 53120 --- [pool-1-thread-1] club.throwable.schedule.Tasks : processTask1触发.......... 2020-03-30 23:47:37.378 INFO 53120 --- [pool-1-thread-1] club.throwable.schedule.Tasks : processTask1触发.......... 复制代码混合配置有些时候我们希望可以JSON配置和JDBC数据源配置进行混合配置,或者动态二选一以便灵活应对多环境的场景(例如要在开发环境使用JSON配置而测试和生产环境使用JDBC数据源配置,甚至可以将JDBC数据源配置覆盖JSON配置,这样能保证总是倾向于使用JDBC数据源配置),这样需要对前面两小节的实现加多一层抽象。这里的设计可以参考SpringMVC中的控制器参数解析器的设计,具体是HandlerMethodArgumentResolverComposite,其实道理是相同的。其他注意事项在生产实践中,暂时不考虑生成任务执行日志和细粒度的监控,着重做了两件事:并发控制,(多服务节点下)禁止任务并发执行。跟踪任务的日志轨迹。解决并发执行问题一般情况下,我们需要禁止任务并发执行,考虑引入Redisson提供的分布式锁:// 引入依赖 <dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>最新版本</version> </dependency> // 配置类 @Configuration @AutoConfigureAfter(RedisAutoConfiguration.class) public class RedissonAutoConfiguration { @Autowired private RedisProperties redisProperties; @Bean(destroyMethod = "shutdown") public RedissonClient redissonClient() { Config config = new Config(); SingleServerConfig singleServerConfig = config.useSingleServer(); singleServerConfig.setAddress(String.format("redis://%s:%d", redisProperties.getHost(), redisProperties.getPort())); if (redisProperties.getDatabase() > 0) { singleServerConfig.setDatabase(redisProperties.getDatabase()); if (null != redisProperties.getPassword()) { singleServerConfig.setPassword(redisProperties.getPassword()); return Redisson.create(config); // 分布式锁工厂 @Component public class DistributedLockFactory { private static final String DISTRIBUTED_LOCK_PATH_PREFIX = "dl:"; @Autowired private RedissonClient redissonClient; public DistributedLock provideDistributedLock(String lockKey) { String lockPath = DISTRIBUTED_LOCK_PATH_PREFIX + lockKey; return new RedissonDistributedLock(redissonClient, lockPath); 复制代码这里考虑到项目依赖了spring-boot-starter-redis,直接复用了它的配置属性类(RedissonDistributedLock是RLock的轻量级封装,见附录)。使用方式如下:@Autowired private DistributedLockFactory distributedLockFactory; public void task1() { DistributedLock lock = distributedLockFactory.provideDistributedLock(lockKey); // 等待时间为20秒,持有锁的最大时间为60秒 boolean tryLock = lock.tryLock(20L, 60, TimeUnit.SECONDS); if (tryLock) { try { // 业务逻辑 }finally { lock.unlock(); 复制代码引入MDC跟踪任务的TraceMDC其实是Mapped Diagnostic Context的缩写,也就是映射诊断上下文,一般用于日志框架里面同一个线程执行过程的跟踪(例如一个线程跑过了多个方法,各个方法里面都打印了日志,那么通过MDC可以对整个调用链通过一个唯一标识关联起来),例如这里选用slf4j提供的org.slf4j.MDC:@Component public class MappedDiagnosticContextAssistant { * 在MDC中执行 * @param runnable runnable public void processInMappedDiagnosticContext(Runnable runnable) { String uuid = UUID.randomUUID().toString(); MDC.put("TRACE_ID", uuid); try { runnable.run(); } finally { MDC.remove("TRACE_ID"); 复制代码任务执行的时候需要包裹成一个Runnale实例:public void task1() { mappedDiagnosticContextAssistant.processInMappedDiagnosticContext(() -> { StopWatch watch = new StopWatch(); watch.start(); log.info("开始执行......"); // 业务逻辑 watch.stop(); log.info("执行完毕,耗时:{} ms......", watch.getTotalTimeMillis()); 复制代码结合前面一节提到的并发控制,那么最终执行的任务方法如下:public void task1() { mappedDiagnosticContextAssistant.processInMappedDiagnosticContext(() -> { StopWatch watch = new StopWatch(); watch.start(); log.info("开始执行......"); scheduleTaskAssistant.executeInDistributedLock("任务分布式锁KEY", () -> { // 真实的业务逻辑 watch.stop(); log.info("执行完毕,耗时:{} ms......", watch.getTotalTimeMillis()); 复制代码这里的方法看起来比较别扭,其实可以直接在任务装载的时候基于分布式锁和MDC进行封装,方式类似于ScheduledMethodRunnable,这里不做展开,因为要详细展开篇幅可能比较大(ScheduleTaskAssistant见附录)。小结其实spring-context整个调度模块完全依赖于TaskScheduler实现,更底层的是JUC调度线程池ScheduledThreadPoolExecutor。如果想要从底层原理理解整个调度模块的运行原理,那么就一定要分析ScheduledThreadPoolExecutor的实现。整篇文章大致介绍了spring-context调度模块的加载调度任务的流程,并且基于扩展接口SchedulingConfigurer扩展出多种自定义配置调度任务的方式,但是考虑到需要在生产环境中运行,那么免不了需要考虑监控、并发控制、日志跟踪等等的功能,但是这样就会使得整个调度模块变重,慢慢地就会发现,这个轮子越造越大,越有主流调度框架Quartz或者Easy Scheduler的影子。笔者认为,软件工程,有些时候要权衡取舍,该抛弃的就应该果断抛弃,否则总是负重而行,还能走多远?参考资料:SpringBoot源码附录ScheduleTaskAssistant:@RequiredArgsConstructor @Component public class ScheduleTaskAssistant { public static final long DEFAULT_WAIT_TIME = 5L; * 30秒 public static final long DEFAULT_LEAVE_TIME = 30L; private final DistributedLockFactory distributedLockFactory; * 在分布式锁中执行 * @param waitTime 锁等着时间 * @param leaveTime 锁持有时间 * @param timeUnit 时间单位 * @param lockKey 锁的key * @param task 任务对象 public void executeInDistributedLock(long waitTime, long leaveTime, TimeUnit timeUnit, String lockKey, Runnable task) { DistributedLock lock = distributedLockFactory.dl(lockKey); boolean tryLock = lock.tryLock(waitTime, leaveTime, timeUnit); if (tryLock) { try { long waitTimeMillis = timeUnit.toMillis(waitTime); long start = System.currentTimeMillis(); task.run(); long end = System.currentTimeMillis(); long cost = end - start; // 预防锁过早释放 if (cost < waitTimeMillis) { Sleeper.X.sleep(waitTimeMillis - cost); } finally { lock.unlock(); * 在分布式锁中执行 - 使用默认时间 * @param lockKey 锁的key * @param task 任务对象 public void executeInDistributedLock(String lockKey, Runnable task) { executeInDistributedLock(DEFAULT_WAIT_TIME, DEFAULT_LEAVE_TIME, TimeUnit.SECONDS, lockKey, task); 复制代码RedissonDistributedLock:@Slf4j public class RedissonDistributedLock implements DistributedLock { private final RedissonClient redissonClient; private final String lockPath; private final RLock internalLock; RedissonDistributedLock(RedissonClient redissonClient, String lockPath) { this.redissonClient = redissonClient; this.lockPath = lockPath; this.internalLock = initInternalLock(); private RLock initInternalLock() { return redissonClient.getLock(lockPath); @Override public boolean isLock() { return internalLock.isLocked(); @Override public boolean isHeldByCurrentThread() { return internalLock.isHeldByCurrentThread(); @Override public void lock(long leaseTime, TimeUnit unit) { internalLock.lock(leaseTime, unit); @Override public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) { try { return internalLock.tryLock(waitTime, leaseTime, unit); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException(String.format("Acquire lock fail by thread interrupted,path:%s", lockPath), e); @Override public void unlock() { try { internalLock.unlock(); } catch (IllegalMonitorStateException ex) { log.warn("Unlock path:{} error for thread status change in concurrency", lockPath, ex);

通过源码理解Spring中@Scheduled的实现原理并且实现调度任务动态装载(上)

前提最近的新项目和数据同步相关,有定时调度的需求。之前一直有使用过Quartz、XXL-Job、Easy Scheduler等调度框架,后来越发觉得这些框架太重量级了,于是想到了Spring内置的Scheduling模块。而原生的Scheduling模块只是内存态的调度模块,不支持任务的持久化或者配置(配置任务通过@Scheduled注解进行硬编码,不能抽离到类之外),因此考虑理解Scheduling模块的底层原理,并且基于此造一个简单的轮子,使之支持调度任务配置:通过配置文件或者JDBC数据源。Scheduling模块Scheduling模块是spring-context依赖下的一个包org.springframework.scheduling:这个模块的类并不多,有四个子包:顶层包的定义了一些通用接口和异常。org.springframework.scheduling.annotation:定义了调度、异步任务相关的注解和解析类,常用的注解如@Async、@EnableAsync、@EnableScheduling和@Scheduled。org.springframework.scheduling.concurrent:定义了调度任务执行器和相对应的FactoryBean。org.springframework.scheduling.config:定义了配置解析、任务具体实现类、调度任务XML配置文件解析相关的解析类。org.springframework.scheduling.support:定义了反射支持类、Cron表达式解析器等工具类。如果想单独使用Scheduling,只需要引入spring-context这个依赖。但是现在流行使用SpringBoot,引入spring-boot-starter-web已经集成了spring-context,可以直接使用Scheduling模块,笔者编写本文的时候(2020-03-14)SpringBoot的最新版本为2.2.5.RELEASE,可以选用此版本进行源码分析或者生产应用:<properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <spring.boot.version>2.2.5.RELEASE</spring.boot.version> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>${spring.boot.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> </dependencies> 复制代码开启Scheduling模块支持只需要在某一个配置类中添加@EnableScheduling注解即可,一般为了明确模块的引入,建议在启动类中使用此注解,如:@EnableScheduling @SpringBootApplication public class App { public static void main(String[] args) { SpringApplication.run(App.class, args); 复制代码Scheduling模块的工作流程这个图描述了Scheduling模块的工作流程,这里分析一下非XML配置下的流程(右边的分支):通过注解@EnableScheduling中的@Import引入了SchedulingConfiguration,而SchedulingConfiguration中配置了一个类型为ScheduledAnnotationBeanPostProcessor名称为org.springframework.context.annotation.internalScheduledAnnotationProcessor的Bean,这里有个常见的技巧,Spring内部加载的Bean一般会定义名称为internalXXX,Bean的role会定义为ROLE_INFRASTRUCTURE = 2。Bean后置处理器ScheduledAnnotationBeanPostProcessor会解析和处理每一个符合特定类型的Bean中的@Scheduled注解(注意@Scheduled只能使用在方法或者注解上),并且把解析完成的方法封装为不同类型的Task实例,缓存在ScheduledTaskRegistrar中的。ScheduledAnnotationBeanPostProcessor中的钩子接口方法afterSingletonsInstantiated()在所有单例初始化完成之后回调触发,在此方法中设置了ScheduledTaskRegistrar中的任务调度器(TaskScheduler或者ScheduledExecutorService类型)实例,并且调用ScheduledTaskRegistrar#afterPropertiesSet()方法添加所有缓存的Task实例到任务调度器中执行。任务调度器Scheduling模块支持TaskScheduler或者ScheduledExecutorService类型的任务调度器,而ScheduledExecutorService其实是JDK并发包java.util.concurrent的接口,一般实现类就是调度线程池ScheduledThreadPoolExecutor。实际上,ScheduledExecutorService类型的实例最终会通过适配器模式转变为ConcurrentTaskScheduler,所以这里只需要分析TaskScheduler类型的执行器。ThreadPoolTaskScheduler:基于线程池实现的任务执行器,这个是最常用的实现,底层依赖于ScheduledThreadPoolExecutor实现。ConcurrentTaskScheduler:TaskScheduler接口和ScheduledExecutorService接口的适配器,如果自定义一个ScheduledThreadPoolExecutor类型的Bean,那么任务执行器就会适配为ConcurrentTaskScheduler。DefaultManagedTaskScheduler:JDK7引入的JSR-236的支持,可以通过JNDI配置此调度执行器,一般很少用到,底层也是依赖于ScheduledThreadPoolExecutor实现。也就是说,内置的三个调度器类型底层都依赖于JUC调度线程池ScheduledThreadPoolExecutor。这里分析一下顶层接口org.springframework.scheduling.TaskScheduler提供的功能(笔者已经把功能一致的default方法暂时移除):// 省略一些功能一致的default方法 public interface TaskScheduler { // 调度一个任务,通过触发器实例指定触发时间周期 ScheduledFuture<?> schedule(Runnable task, Trigger trigger); // 指定起始时间调度一个任务 - 单次执行 ScheduledFuture<?> schedule(Runnable task, Date startTime); // 指定固定频率调度一个任务,period的单位是毫秒 ScheduledFuture<?> scheduleAtFixedRate(Runnable task, long period); // 指定起始时间和固定频率调度一个任务,period的单位是毫秒 ScheduledFuture<?> scheduleAtFixedRate(Runnable task, Date startTime, long period); // 指定固定延迟间隔调度一个任务,delay的单位是毫秒 ScheduledFuture<?> scheduleWithFixedDelay(Runnable task, long delay); // 指定起始时间和固定延迟间隔调度一个任务,delay的单位是毫秒 ScheduledFuture<?> scheduleWithFixedDelay(Runnable task, Date startTime, long delay); 复制代码Task的分类Scheduling模块中支持不同类型的任务,主要包括下面的3种(解析的优先顺序也是如下):Cron表达式任务,支持通过Cron表达式配置执行的周期,对应的任务类型为org.springframework.scheduling.config.CronTask。固定延迟间隔任务,也就是上一轮执行完毕后间隔固定周期再执行本轮,依次类推,对应的的任务类型为org.springframework.scheduling.config.FixedDelayTask。固定频率任务,基于固定的间隔时间执行,不会理会上一轮是否执行完毕本轮会照样执行,对应的的任务类型为org.springframework.scheduling.config.FixedRateTask。关于这几类Task,举几个简单的例子。CronTask是通过cron表达式指定执行周期的,并且不支持延迟执行,可以使用特殊字符-禁用任务执行:// 注解声明式使用 - 每五秒执行一次,不支持initialDelay @Scheduled(cron = "*/5 * * * * ?") public void processTask(){ // 注解声明式使用 - 禁止任务执行 @Scheduled(cron = "-") public void processTask(){ // 编程式使用 public class Tasks { static DateTimeFormatter F = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); public static void main(String[] args) throws Exception { ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler(); taskScheduler.setPoolSize(10); taskScheduler.initialize(); CronTask cronTask = new CronTask(() -> { System.out.println(String.format("[%s] - CronTask触发...", F.format(LocalDateTime.now()))); }, "*/5 * * * * ?"); taskScheduler.schedule(cronTask.getRunnable(),cronTask.getTrigger()); Thread.sleep(Integer.MAX_VALUE); // 某次执行输出结果 [2020-03-16 01:07:00] - CronTask触发... [2020-03-16 01:07:05] - CronTask触发... ...... 复制代码FixedDelayTask需要配置延迟间隔值(fixedDelay或者fixedDelayString)和可选的起始延迟执行时间(initialDelay或者initialDelayString),这里注意一点是fixedDelayString和initialDelayString都支持从EmbeddedValueResolver(简单理解为配置文件的属性处理器)读取和Duration(例如P2D就是parses as 2 days,表示86400秒)支持格式的解析:// 注解声明式使用 - 延迟一秒开始执行,延迟间隔为5秒 @Scheduled(fixedDelay = 5000, initialDelay = 1000) public void process(){ // 注解声明式使用 - spring-boot配置文件中process.task.fixedDelay=5000 process.task.initialDelay=1000 @Scheduled(fixedDelayString = "${process.task.fixedDelay}", initialDelayString = "${process.task.initialDelay}") public void process(){ // 编程式使用 public class Tasks { static DateTimeFormatter F = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); public static void main(String[] args) throws Exception { ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler(); taskScheduler.setPoolSize(10); taskScheduler.initialize(); FixedDelayTask fixedDelayTask = new FixedDelayTask(() -> { System.out.println(String.format("[%s] - FixedDelayTask触发...", F.format(LocalDateTime.now()))); }, 5000, 1000); Date startTime = new Date(System.currentTimeMillis() + fixedDelayTask.getInitialDelay()); taskScheduler.scheduleWithFixedDelay(fixedDelayTask.getRunnable(), startTime, fixedDelayTask.getInterval()); Thread.sleep(Integer.MAX_VALUE); // 某次执行输出结果 [2020-03-16 01:06:12] - FixedDelayTask触发... [2020-03-16 01:06:17] - FixedDelayTask触发... ...... 复制代码FixedRateTask需要配置固定间隔值(fixedRate或者fixedRateString)和可选的起始延迟执行时间(initialDelay或者initialDelayString),这里注意一点是fixedRateString和initialDelayString都支持从EmbeddedValueResolver(简单理解为配置文件的属性处理器)读取和Duration(例如P2D就是parses as 2 days,表示86400秒)支持格式的解析:// 注解声明式使用 - 延迟一秒开始执行,每隔5秒执行一次 @Scheduled(fixedRate = 5000, initialDelay = 1000) public void processTask(){ // 注解声明式使用 - spring-boot配置文件中process.task.fixedRate=5000 process.task.initialDelay=1000 @Scheduled(fixedRateString = "${process.task.fixedRate}", initialDelayString = "${process.task.initialDelay}") public void process(){ // 编程式使用 public class Tasks { static DateTimeFormatter F = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); public static void main(String[] args) throws Exception { ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler(); taskScheduler.setPoolSize(10); taskScheduler.initialize(); FixedRateTask fixedRateTask = new FixedRateTask(() -> { System.out.println(String.format("[%s] - FixedRateTask触发...", F.format(LocalDateTime.now()))); }, 5000, 1000); Date startTime = new Date(System.currentTimeMillis() + fixedRateTask.getInitialDelay()); taskScheduler.scheduleAtFixedRate(fixedRateTask.getRunnable(), startTime, fixedRateTask.getInterval()); Thread.sleep(Integer.MAX_VALUE); // 某次执行输出结果 [2020-03-16 23:58:25] - FixedRateTask触发... [2020-03-16 23:58:30] - FixedRateTask触发... ...... 复制代码简单分析核心流程的源代码在SpringBoot注解体系下,Scheduling模块的所有逻辑基本在ScheduledAnnotationBeanPostProcessor和ScheduledTaskRegistrar中。一般来说,一个类实现的接口代表了它能提供的功能,先看ScheduledAnnotationBeanPostProcessor实现的接口:ScheduledTaskHolder接口:返回Set<ScheduledTask>,表示持有的所有任务实例。MergedBeanDefinitionPostProcessor接口:Bean定义合并时回调,预留空实现,暂时不做任何处理。BeanPostProcessor接口:也就是MergedBeanDefinitionPostProcessor的父接口,Bean实例初始化前后分别回调,其中,后回调的postProcessAfterInitialization()方法就是用于解析@Scheduled和装载ScheduledTask,需要重点关注此方法的逻辑。DestructionAwareBeanPostProcessor接口:具体的Bean实例销毁的时候回调,用于Bean实例销毁的时候移除和取消对应的任务实例。Ordered接口:用于Bean加载时候的排序,主要是改变ScheduledAnnotationBeanPostProcessor在BeanPostProcessor执行链中的顺序。EmbeddedValueResolverAware接口:回调StringValueResolver实例,用于解析带占位符的环境变量属性值。BeanNameAware接口:回调BeanName。BeanFactoryAware接口:回调BeanFactory实例,具体是DefaultListableBeanFactory,也就是熟知的IOC容器。ApplicationContextAware接口:回调ApplicationContext实例,也就是熟知的Spring上下文,它是IOC容器的门面,同时是事件广播器、资源加载器的实现等等。SmartInitializingSingleton接口:所有单例实例化完毕之后回调,作用是在持有的applicationContext为NULL的时候开始调度所有加载完成的任务,这个钩子接口十分有用,笔者常用它做一些资源初始化工作。ApplicationListener接口:监听Spring应用的事件,具体是ApplicationListener<ContextRefreshedEvent>,监听上下文刷新的事件,如果事件中携带的ApplicationContext实例和ApplicationContextAware回调的ApplicationContext实例一致,那么在此监听回调方法中开始调度所有加载完成的任务,也就是在ScheduledAnnotationBeanPostProcessor这个类中,SmartInitializingSingleton接口的实现和ApplicationListener接口的实现逻辑是互斥的。DisposableBean接口:当前Bean实例销毁时候回调,也就是ScheduledAnnotationBeanPostProcessor自身被销毁的时候回调,用于取消和清理所有的ScheduledTask。上面分析的钩子接口在SpringBoot体系中可以按需使用,了解回调不同钩子接口的回调时机,可以在特定时机完成达到理想的效果。@Scheduled注解的解析集中在postProcessAfterInitialization()方法:public Object postProcessAfterInitialization(Object bean, String beanName) { // 忽略AopInfrastructureBean、TaskScheduler和ScheduledExecutorService三种类型的Bean if (bean instanceof AopInfrastructureBean || bean instanceof TaskScheduler || bean instanceof ScheduledExecutorService) { // Ignore AOP infrastructure such as scoped proxies. return bean; // 获取Bean的用户态类型,例如Bean有可能被CGLIB增强,这个时候要取其父类 Class<?> targetClass = AopProxyUtils.ultimateTargetClass(bean); // nonAnnotatedClasses存放着不存在@Scheduled注解的类型,缓存起来避免重复判断它是否携带@Scheduled注解的方法 if (!this.nonAnnotatedClasses.contains(targetClass) && AnnotationUtils.isCandidateClass(targetClass, Arrays.asList(Scheduled.class, Schedules.class))) { // 因为JDK8之后支持重复注解,因此获取具体类型中Method -> @Scheduled的集合,也就是有可能一个方法使用多个@Scheduled注解,最终会封装为多个Task Map<Method, Set<Scheduled>> annotatedMethods = MethodIntrospector.selectMethods(targetClass, (MethodIntrospector.MetadataLookup<Set<Scheduled>>) method -> { Set<Scheduled> scheduledMethods = AnnotatedElementUtils.getMergedRepeatableAnnotations( method, Scheduled.class, Schedules.class); return (!scheduledMethods.isEmpty() ? scheduledMethods : null); // 解析到类型中不存在@Scheduled注解的方法添加到nonAnnotatedClasses缓存 if (annotatedMethods.isEmpty()) { this.nonAnnotatedClasses.add(targetClass); if (logger.isTraceEnabled()) { logger.trace("No @Scheduled annotations found on bean class: " + targetClass); else { // Method -> @Scheduled的集合遍历processScheduled()方法进行登记 annotatedMethods.forEach((method, scheduledMethods) -> scheduledMethods.forEach(scheduled -> processScheduled(scheduled, method, bean))); if (logger.isTraceEnabled()) { logger.trace(annotatedMethods.size() + " @Scheduled methods processed on bean '" + beanName + "': " + annotatedMethods); return bean; 复制代码processScheduled(Scheduled scheduled, Method method, Object bean)就是具体的注解解析和Task封装的方法:// Runnable适配器 - 用于反射调用具体的方法,触发任务方法执行 public class ScheduledMethodRunnable implements Runnable { private final Object target; private final Method method; public ScheduledMethodRunnable(Object target, Method method) { this.target = target; this.method = method; ....// 省略无关代码 // 这个就是最终的任务方法执行的核心方法,抑制修饰符,然后反射调用 @Override public void run() { try { ReflectionUtils.makeAccessible(this.method); this.method.invoke(this.target); catch (InvocationTargetException ex) { ReflectionUtils.rethrowRuntimeException(ex.getTargetException()); catch (IllegalAccessException ex) { throw new UndeclaredThrowableException(ex); // 通过方法所在Bean实例和方法封装Runnable适配器ScheduledMethodRunnable实例 protected Runnable createRunnable(Object target, Method method) { Assert.isTrue(method.getParameterCount() == 0, "Only no-arg methods may be annotated with @Scheduled"); Method invocableMethod = AopUtils.selectInvocableMethod(method, target.getClass()); return new ScheduledMethodRunnable(target, invocableMethod); // 这个方法十分长,不过逻辑并不复杂,它只做了四件事 // 0. 解析@Scheduled中的initialDelay、initialDelayString属性,适用于FixedDelayTask或者FixedRateTask的延迟执行 // 1. 优先解析@Scheduled中的cron属性,封装为CronTask,通过ScheduledTaskRegistrar进行缓存 // 2. 解析@Scheduled中的fixedDelay、fixedDelayString属性,封装为FixedDelayTask,通过ScheduledTaskRegistrar进行缓存 // 3. 解析@Scheduled中的fixedRate、fixedRateString属性,封装为FixedRateTask,通过ScheduledTaskRegistrar进行缓存 protected void processScheduled(Scheduled scheduled, Method method, Object bean) { try { // 通过方法宿主Bean和目标方法封装Runnable适配器ScheduledMethodRunnable实例 Runnable runnable = createRunnable(bean, method); boolean processedSchedule = false; String errorMessage = "Exactly one of the 'cron', 'fixedDelay(String)', or 'fixedRate(String)' attributes is required"; // 缓存已经装载的任务 Set<ScheduledTask> tasks = new LinkedHashSet<>(4); // Determine initial delay // 解析初始化延迟执行时间,initialDelayString支持占位符配置,如果initialDelayString配置了,会覆盖initialDelay的值 long initialDelay = scheduled.initialDelay(); String initialDelayString = scheduled.initialDelayString(); if (StringUtils.hasText(initialDelayString)) { Assert.isTrue(initialDelay < 0, "Specify 'initialDelay' or 'initialDelayString', not both"); if (this.embeddedValueResolver != null) { initialDelayString = this.embeddedValueResolver.resolveStringValue(initialDelayString); if (StringUtils.hasLength(initialDelayString)) { try { initialDelay = parseDelayAsLong(initialDelayString); catch (RuntimeException ex) { throw new IllegalArgumentException( "Invalid initialDelayString value \"" + initialDelayString + "\" - cannot parse into long"); // Check cron expression // 解析时区zone的值,支持支持占位符配置,判断cron是否存在,存在则装载为CronTask String cron = scheduled.cron(); if (StringUtils.hasText(cron)) { String zone = scheduled.zone(); if (this.embeddedValueResolver != null) { cron = this.embeddedValueResolver.resolveStringValue(cron); zone = this.embeddedValueResolver.resolveStringValue(zone); if (StringUtils.hasLength(cron)) { Assert.isTrue(initialDelay == -1, "'initialDelay' not supported for cron triggers"); processedSchedule = true; if (!Scheduled.CRON_DISABLED.equals(cron)) { TimeZone timeZone; if (StringUtils.hasText(zone)) { timeZone = StringUtils.parseTimeZoneString(zone); else { timeZone = TimeZone.getDefault(); // 此方法虽然表面上是调度CronTask,实际上由于ScheduledTaskRegistrar不持有TaskScheduler,只是把任务添加到它的缓存中 // 返回的任务实例添加到宿主Bean的缓存中,然后最后会放入宿主Bean -> List<ScheduledTask>映射中 tasks.add(this.registrar.scheduleCronTask(new CronTask(runnable, new CronTrigger(cron, timeZone)))); // At this point we don't need to differentiate between initial delay set or not anymore // 修正小于0的初始化延迟执行时间值为0 if (initialDelay < 0) { initialDelay = 0; // 解析fixedDelay和fixedDelayString,如果同时配置,fixedDelayString最终解析出来的整数值会覆盖fixedDelay,封装为FixedDelayTask long fixedDelay = scheduled.fixedDelay(); if (fixedDelay >= 0) { Assert.isTrue(!processedSchedule, errorMessage); processedSchedule = true; tasks.add(this.registrar.scheduleFixedDelayTask(new FixedDelayTask(runnable, fixedDelay, initialDelay))); String fixedDelayString = scheduled.fixedDelayString(); if (StringUtils.hasText(fixedDelayString)) { if (this.embeddedValueResolver != null) { fixedDelayString = this.embeddedValueResolver.resolveStringValue(fixedDelayString); if (StringUtils.hasLength(fixedDelayString)) { Assert.isTrue(!processedSchedule, errorMessage); processedSchedule = true; try { fixedDelay = parseDelayAsLong(fixedDelayString); catch (RuntimeException ex) { throw new IllegalArgumentException( "Invalid fixedDelayString value \"" + fixedDelayString + "\" - cannot parse into long"); // 此方法虽然表面上是调度FixedDelayTask,实际上由于ScheduledTaskRegistrar不持有TaskScheduler,只是把任务添加到它的缓存中 // 返回的任务实例添加到宿主Bean的缓存中,然后最后会放入宿主Bean -> List<ScheduledTask>映射中 tasks.add(this.registrar.scheduleFixedDelayTask(new FixedDelayTask(runnable, fixedDelay, initialDelay))); // 解析fixedRate和fixedRateString,如果同时配置,fixedRateString最终解析出来的整数值会覆盖fixedRate,封装为FixedRateTask long fixedRate = scheduled.fixedRate(); if (fixedRate >= 0) { Assert.isTrue(!processedSchedule, errorMessage); processedSchedule = true; tasks.add(this.registrar.scheduleFixedRateTask(new FixedRateTask(runnable, fixedRate, initialDelay))); String fixedRateString = scheduled.fixedRateString(); if (StringUtils.hasText(fixedRateString)) { if (this.embeddedValueResolver != null) { fixedRateString = this.embeddedValueResolver.resolveStringValue(fixedRateString); if (StringUtils.hasLength(fixedRateString)) { Assert.isTrue(!processedSchedule, errorMessage); processedSchedule = true; try { fixedRate = parseDelayAsLong(fixedRateString); catch (RuntimeException ex) { throw new IllegalArgumentException( "Invalid fixedRateString value \"" + fixedRateString + "\" - cannot parse into long"); // 此方法虽然表面上是调度FixedRateTask,实际上由于ScheduledTaskRegistrar不持有TaskScheduler,只是把任务添加到它的缓存中 // 返回的任务实例添加到宿主Bean的缓存中,然后最后会放入宿主Bean -> List<ScheduledTask>映射中 tasks.add(this.registrar.scheduleFixedRateTask(new FixedRateTask(runnable, fixedRate, initialDelay))); // Check whether we had any attribute set Assert.isTrue(processedSchedule, errorMessage); // Finally register the scheduled tasks synchronized (this.scheduledTasks) { // 注册所有任务实例,这个映射Key为宿主Bean实例,Value为List<ScheduledTask>,后面用于调度所有注册完成的任务 Set<ScheduledTask> regTasks = this.scheduledTasks.computeIfAbsent(bean, key -> new LinkedHashSet<>(4)); regTasks.addAll(tasks); catch (IllegalArgumentException ex) { throw new IllegalStateException( "Encountered invalid @Scheduled method '" + method.getName() + "': " + ex.getMessage()); 复制代码总的来说,这个方法做了四件事:解析@Scheduled中的initialDelay、initialDelayString属性,适用于FixedDelayTask或者FixedRateTask的延迟执行。优先解析@Scheduled中的cron属性,封装为CronTask,通过ScheduledTaskRegistrar进行缓存。解析@Scheduled中的fixedDelay、fixedDelayString属性,封装为FixedDelayTask,通过ScheduledTaskRegistrar进行缓存。解析@Scheduled中的fixedRate、fixedRateString属性,封装为FixedRateTask,通过ScheduledTaskRegistrar进行缓存。@Scheduled修饰的某个方法如果同时配置了cron、fixedDelay|fixedDelayString和fixedRate|fixedRateString属性,意味着此方法同时封装为三种任务CronTask、FixedDelayTask和FixedRateTask。解析xxString值的使用,用到了EmbeddedValueResolver解析字符串的值,支持占位符,这样可以直接获取环境配置中的占位符属性(基于SPEL的特性,甚至可以支持嵌套占位符)。解析成功的所有任务实例存放在ScheduledAnnotationBeanPostProcessor的一个映射scheduledTasks中:// 宿主Bean实例 -> 解析完成的任务实例Set private final Map<Object, Set<ScheduledTask>> scheduledTasks = new IdentityHashMap<>(16); 复制代码解析和缓存工作完成之后,接着分析最终激活所有调度任务的逻辑,见互斥方法afterSingletonsInstantiated()和onApplicationEvent(),两者中一定只有一个方法能够调用finishRegistration():// 所有单例实例化完毕之后回调 public void afterSingletonsInstantiated() { // Remove resolved singleton classes from cache this.nonAnnotatedClasses.clear(); if (this.applicationContext == null) { // Not running in an ApplicationContext -> register tasks early... finishRegistration(); // 上下文刷新完成之后回调 @Override public void onApplicationEvent(ContextRefreshedEvent event) { if (event.getApplicationContext() == this.applicationContext) { // Running in an ApplicationContext -> register tasks this late... // giving other ContextRefreshedEvent listeners a chance to perform // their work at the same time (e.g. Spring Batch's job registration). finishRegistration(); private void finishRegistration() { // 如果持有的scheduler对象不为null则设置ScheduledTaskRegistrar中的任务调度器 if (this.scheduler != null) { this.registrar.setScheduler(this.scheduler); // 这个判断一般会成立,得到的BeanFactory就是DefaultListableBeanFactory if (this.beanFactory instanceof ListableBeanFactory) { // 获取所有的调度配置器SchedulingConfigurer实例,并且都回调configureTasks()方法,这个很重要,它是用户动态装载调取任务的扩展钩子接口 Map<String, SchedulingConfigurer> beans = ((ListableBeanFactory) this.beanFactory).getBeansOfType(SchedulingConfigurer.class); List<SchedulingConfigurer> configurers = new ArrayList<>(beans.values()); // SchedulingConfigurer实例列表排序 AnnotationAwareOrderComparator.sort(configurers); for (SchedulingConfigurer configurer : configurers) { configurer.configureTasks(this.registrar); // 下面这一大段逻辑都是为了从BeanFactory取出任务调度器实例,主要判断TaskScheduler或者ScheduledExecutorService类型的Bean,包括尝试通过类型或者名字获取 // 获取成功后设置到ScheduledTaskRegistrar中 if (this.registrar.hasTasks() && this.registrar.getScheduler() == null) { Assert.state(this.beanFactory != null, "BeanFactory must be set to find scheduler by type"); try { // Search for TaskScheduler bean... this.registrar.setTaskScheduler(resolveSchedulerBean(this.beanFactory, TaskScheduler.class, false)); catch (NoUniqueBeanDefinitionException ex) { logger.trace("Could not find unique TaskScheduler bean", ex); try { this.registrar.setTaskScheduler(resolveSchedulerBean(this.beanFactory, TaskScheduler.class, true)); catch (NoSuchBeanDefinitionException ex2) { if (logger.isInfoEnabled()) { logger.info("More than one TaskScheduler bean exists within the context, and " + "none is named 'taskScheduler'. Mark one of them as primary or name it 'taskScheduler' " + "(possibly as an alias); or implement the SchedulingConfigurer interface and call " + "ScheduledTaskRegistrar#setScheduler explicitly within the configureTasks() callback: " + ex.getBeanNamesFound()); catch (NoSuchBeanDefinitionException ex) { logger.trace("Could not find default TaskScheduler bean", ex); // Search for ScheduledExecutorService bean next... try { this.registrar.setScheduler(resolveSchedulerBean(this.beanFactory, ScheduledExecutorService.class, false)); catch (NoUniqueBeanDefinitionException ex2) { logger.trace("Could not find unique ScheduledExecutorService bean", ex2); try { this.registrar.setScheduler(resolveSchedulerBean(this.beanFactory, ScheduledExecutorService.class, true)); catch (NoSuchBeanDefinitionException ex3) { if (logger.isInfoEnabled()) { logger.info("More than one ScheduledExecutorService bean exists within the context, and " + "none is named 'taskScheduler'. Mark one of them as primary or name it 'taskScheduler' " + "(possibly as an alias); or implement the SchedulingConfigurer interface and call " + "ScheduledTaskRegistrar#setScheduler explicitly within the configureTasks() callback: " + ex2.getBeanNamesFound()); catch (NoSuchBeanDefinitionException ex2) { logger.trace("Could not find default ScheduledExecutorService bean", ex2); // Giving up -> falling back to default scheduler within the registrar... logger.info("No TaskScheduler/ScheduledExecutorService bean found for scheduled processing"); // 调用ScheduledTaskRegistrar的afterPropertiesSet()方法,装载所有的调度任务 this.registrar.afterPropertiesSet(); public class ScheduledTaskRegistrar implements ScheduledTaskHolder, InitializingBean, DisposableBean { // 省略其他代码......... @Override public void afterPropertiesSet() { scheduleTasks(); // 装载所有调度任务 @SuppressWarnings("deprecation") protected void scheduleTasks() { // 这里注意一点,如果找不到任务调度器实例,那么会用单个线程调度所有任务 if (this.taskScheduler == null) { this.localExecutor = Executors.newSingleThreadScheduledExecutor(); this.taskScheduler = new ConcurrentTaskScheduler(this.localExecutor); // 调度所有装载完毕的自定义触发器的任务实例 if (this.triggerTasks != null) { for (TriggerTask task : this.triggerTasks) { addScheduledTask(scheduleTriggerTask(task)); // 调度所有装载完毕的CronTask if (this.cronTasks != null) { for (CronTask task : this.cronTasks) { addScheduledTask(scheduleCronTask(task)); // 调度所有装载完毕的FixedRateTask if (this.fixedRateTasks != null) { for (IntervalTask task : this.fixedRateTasks) { addScheduledTask(scheduleFixedRateTask(task)); // 调度所有装载完毕的FixedDelayTask if (this.fixedDelayTasks != null) { for (IntervalTask task : this.fixedDelayTasks) { addScheduledTask(scheduleFixedDelayTask(task)); // 省略其他代码......... 复制代码注意两个个问题:如果没有配置TaskScheduler或者ScheduledExecutorService类型的Bean,那么调度模块只会创建一个线程去调度所有装载完毕的任务,如果任务比较多,执行密度比较大,很有可能会造成大量任务饥饿,表现为存在部分任务不会触发调度的场景(这个是调度模块生产中经常遇到的故障,需要重点排查是否没有设置TaskScheduler或者ScheduledExecutorService)。SchedulingConfigurer是调度模块提供给使用的进行扩展的钩子接口,用于在激活所有调度任务之前回调ScheduledTaskRegistrar实例,只要拿到ScheduledTaskRegistrar实例,我们就可以使用它注册和装载新的Task。

基于Canal和Kafka实现MySQL的Binlog近实时同步

前提近段时间,业务系统架构基本完备,数据层面的建设比较薄弱,因为笔者目前工作重心在于搭建一个小型的数据平台。优先级比较高的一个任务就是需要近实时同步业务系统的数据(包括保存、更新或者软删除)到一个另一个数据源,持久化之前需要清洗数据并且构建一个相对合理的便于后续业务数据统计、标签系统构建等扩展功能的数据模型。基于当前团队的资源和能力,优先调研了Alibaba开源中间件Canal的使用。这篇文章简单介绍一下如何快速地搭建一套Canal相关的组件.关于Canal简单介绍一下中间件Canal的背景和原理。简介下面的简介和下一节的原理均来自于Canal项目的README:Canal[kə'næl],译意为水道/管道/沟渠,主要用途是基于MySQL数据库增量日志解析,提供增量数据订阅和消费。Canal按照音标的正确读音和"磕尿"相近,而不是很多人认为的Can Nal,「笔者曾因此事被开发小姐姐嘲笑」。早期阿里巴巴因为杭州和美国双机房部署,存在跨机房同步的业务需求,实现方式主要是基于业务trigger获取增量变更。从 2010 年开始,业务逐步尝试数据库日志解析获取增量变更进行同步,由此衍生出了大量的数据库增量订阅和消费业务。基于日志增量订阅和消费的业务包括:数据库镜像。数据库实时备份。索引构建和实时维护(拆分异构索引、倒排索引等)。业务Cache刷新。带业务逻辑的增量数据处理。Canal的工作原理MySQL主备复制原理:MySQL的Master实例将数据变更写入二进制日志(binary log,其中记录叫做二进制日志事件binary log events,可以通过show binlog events进行查看)MySQL的Slave实例将master的binary log events拷贝到它的中继日志(relay log)MySQL的Slave实例重放relay log中的事件,将数据变更反映它到自身的数据Canal的工作原理如下:Canal模拟MySQL Slave的交互协议,伪装自己为MySQL Slave,向MySQL Master发送dump协议MySQL Master收到dump请求,开始推送binary log给Slave(即Canal)Canal解析binary log对象(原始为byte流),并且可以通过连接器发送到对应的消息队列等中间件中关于Canal的版本和部件截止笔者开始编写本文的时候(2020-03-05),Canal的最新发布版本是v1.1.5-alpha-1(2019-10-09发布的),最新的正式版是v1.1.4(2019-09-02发布的)。其中,v1.1.4主要添加了鉴权、监控的功能,并且做了一些列的性能优化,此版本集成的连接器是Tcp、Kafka和RockerMQ。而v1.1.5-alpha-1版本已经新增了RabbitMQ连接器,但是此版本的RabbitMQ连接器暂时不能定义连接RabbitMQ的端口号,不过此问题已经在master分支中修复(具体可以参看源码中的CanalRabbitMQProducer类的提交记录)。换言之,v1.1.4版本中目前能使用的内置连接器只有Tcp、Kafka和RockerMQ三种,如果想尝鲜使用RabbitMQ连接器,可以选用下面的两种方式之一:选用v1.1.5-alpha-1版本,但是无法修改RabbitMQ的port属性,默认为5672。基于master分支自行构建Canal。目前,Canal项目的活跃度比较高,但是考虑到功能的稳定性问题,笔者建议选用稳定版本在生产环境中实施,当前可以选用v1.1.4版本,「本文的例子用选用的就是v1.1.4版本,配合Kafka连接器使用」。Canal主要包括三个核心部件:canal-admin:后台管理模块,提供面向WebUI的Canal管理能力。canal-adapter:适配器,增加客户端数据落地的适配及启动功能,包括REST、日志适配器、关系型数据库的数据同步(表对表同步)、HBase数据同步、ES数据同步等等。canal-deployer:发布器,核心功能所在,包括binlog解析、转换和发送报文到连接器中等等功能都由此模块提供。一般情况下,canal-deployer部件是必须的,其他两个部件按需选用即可。部署所需的中间件搭建一套可以用的组件需要部署MySQL、Zookeeper、Kafka和Canal四个中间件的实例,下面简单分析一下部署过程。选用的虚拟机系统是CentOS7。安装MySQL为了简单起见,选用yum源安装(官方链接是https://dev.mysql.com/downloads/repo/yum):::: info mysql80-community-release-el7-3虽然包名带了mysql80关键字,其实已经集成了MySQL主流版本5.6、5.7和8.x等等的最新安装包仓库 :::选用的是最新版的MySQL8.x社区版,下载CentOS7适用的rpm包:cd /data/mysql wget https://dev.mysql.com/get/mysql80-community-release-el7-3.noarch.rpm // 下载完毕之后 sudo rpm -Uvh mysql80-community-release-el7-3.noarch.rpm 复制代码此时列举一下yum仓库里面的MySQL相关的包:[root@localhost mysql]# yum repolist all | grep mysql mysql-cluster-7.5-community/x86_64 MySQL Cluster 7.5 Community disabled mysql-cluster-7.5-community-source MySQL Cluster 7.5 Community - disabled mysql-cluster-7.6-community/x86_64 MySQL Cluster 7.6 Community disabled mysql-cluster-7.6-community-source MySQL Cluster 7.6 Community - disabled mysql-cluster-8.0-community/x86_64 MySQL Cluster 8.0 Community disabled mysql-cluster-8.0-community-source MySQL Cluster 8.0 Community - disabled mysql-connectors-community/x86_64 MySQL Connectors Community enabled: 141 mysql-connectors-community-source MySQL Connectors Community - disabled mysql-tools-community/x86_64 MySQL Tools Community enabled: 105 mysql-tools-community-source MySQL Tools Community - Sourc disabled mysql-tools-preview/x86_64 MySQL Tools Preview disabled mysql-tools-preview-source MySQL Tools Preview - Source disabled mysql55-community/x86_64 MySQL 5.5 Community Server disabled mysql55-community-source MySQL 5.5 Community Server - disabled mysql56-community/x86_64 MySQL 5.6 Community Server disabled mysql56-community-source MySQL 5.6 Community Server - disabled mysql57-community/x86_64 MySQL 5.7 Community Server disabled mysql57-community-source MySQL 5.7 Community Server - disabled mysql80-community/x86_64 MySQL 8.0 Community Server enabled: 161 mysql80-community-source MySQL 8.0 Community Server - disabled 复制代码编辑/etc/yum.repos.d/mysql-community.repo文件([mysql80-community]块中enabled设置为1,其实默认就是这样子,不用改,如果要选用5.x版本则需要修改对应的块):[mysql80-community] name=MySQL 8.0 Community Server baseurl=http://repo.mysql.com/yum/mysql-8.0-community/el/7/$basearch/ enabled=1 gpgcheck=1 gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-mysql 复制代码然后安装MySQL服务:sudo yum install mysql-community-server 复制代码这个过程比较漫长,因为需要下载和安装5个rpm安装包(或者是所有安装包组合的压缩包mysql-8.0.18-1.el7.x86_64.rpm-bundle.tar)。如果网络比较差,也可以直接从官网手动下载后安装:// 下载下面5个rpm包 common --> libs --> libs-compat --> client --> server mysql-community-common mysql-community-libs mysql-community-libs-compat mysql-community-client mysql-community-server // 强制安装 rpm -ivh mysql-community-common-8.0.18-1.el7.x86_64.rpm --force --nodeps rpm -ivh mysql-community-libs-8.0.18-1.el7.x86_64.rpm --force --nodeps rpm -ivh mysql-community-libs-compat-8.0.18-1.el7.x86_64.rpm --force --nodeps rpm -ivh mysql-community-client-8.0.18-1.el7.x86_64.rpm --force --nodeps rpm -ivh mysql-community-server-8.0.18-1.el7.x86_64.rpm --force --nodeps 复制代码安装完毕之后,启动MySQL服务,然后搜索MySQL服务的root账号的临时密码用于首次登陆(mysql -u root -p):// 启动服务,关闭服务就是service mysqld stop service mysqld start // 查看临时密码 cat /var/log/mysqld.log [root@localhost log]# cat /var/log/mysqld.log 2020-03-02T06:03:53.996423Z 0 [System] [MY-013169] [Server] /usr/sbin/mysqld (mysqld 8.0.18) initializing of server in progress as process 22780 2020-03-02T06:03:57.321447Z 5 [Note] [MY-010454] [Server] A temporary password is generated for root@localhost: >kjYaXENK6li 2020-03-02T06:04:00.123845Z 0 [System] [MY-010116] [Server] /usr/sbin/mysqld (mysqld 8.0.18) starting as process 22834 // 登录临时root用户,使用临时密码 [root@localhost log]# mysql -u root -p 复制代码接下来做下面的操作:修改root用户的密码:ALTER USER 'root'@'localhost' IDENTIFIED BY 'QWqw12!@';(注意密码规则必须包含大小写字母、数字和特殊字符)更新root的host,切换数据库use mysql;,指定host为%以便可以让其他服务器远程访问UPDATE USER SET HOST = '%' WHERE USER = 'root';赋予'root'@'%'用户,所有权限,执行GRANT ALL PRIVILEGES ON *.* TO 'root'@'%';改变root'@'%用户的密码校验规则以便可以使用Navicat等工具访问:ALTER USER 'root'@'%' IDENTIFIED WITH mysql_native_password BY 'QWqw12!@';操作完成之后,就可以使用root用户远程访问此虚拟机上的MySQL服务。最后确认是否开启了binlog(注意一点是MySQL8.x默认开启binlog)SHOW VARIABLES LIKE '%bin%';:最后在MySQL的Shell执行下面的命令,新建一个用户名canal密码为QWqw12!@的新用户,赋予REPLICATION SLAVE和 REPLICATION CLIENT权限:CREATE USER canal IDENTIFIED BY 'QWqw12!@'; GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%'; FLUSH PRIVILEGES; ALTER USER 'canal'@'%' IDENTIFIED WITH mysql_native_password BY 'QWqw12!@'; 复制代码切换回去root用户,创建一个数据库test:CREATE DATABASE `test` CHARSET `utf8mb4` COLLATE `utf8mb4_unicode_ci`; 复制代码安装ZookeeperCanal和Kafka集群都依赖于Zookeeper做服务协调,为了方便管理,一般会独立部署Zookeeper服务或者Zookeeper集群。笔者这里选用2020-03-04发布的3.6.0版本:midkr /data/zk # 创建数据目录 midkr /data/zk/data cd /data/zk wget http://mirror.bit.edu.cn/apache/zookeeper/zookeeper-3.6.0/apache-zookeeper-3.6.0-bin.tar.gz tar -zxvf apache-zookeeper-3.6.0-bin.tar.gz cd apache-zookeeper-3.6.0-bin/conf cp zoo_sample.cfg zoo.cfg && vim zoo.cfg 复制代码把zoo.cfg文件中的dataDir设置为/data/zk/data,然后启动Zookeeper:[root@localhost conf]# sh /data/zk/apache-zookeeper-3.6.0-bin/bin/zkServer.sh start /usr/bin/java ZooKeeper JMX enabled by default Using config: /data/zk/apache-zookeeper-3.6.0-bin/bin/../conf/zoo.cfg Starting zookeeper ... STARTED 复制代码这里注意一点,要启动此版本的Zookeeper服务必须本地安装好JDK8+,这一点需要自行处理。启动的默认端口是2181,启动成功后的日志如下:安装KafkaKafka是一个高性能分布式消息队列中间件,它的部署依赖于Zookeeper。笔者在此选用2.4.0并且Scala版本为2.13的安装包:mkdir /data/kafka mkdir /data/kafka/data wget http://mirrors.tuna.tsinghua.edu.cn/apache/kafka/2.4.0/kafka_2.13-2.4.0.tgz tar -zxvf kafka_2.13-2.4.0.tgz 复制代码由于解压后/data/kafka/kafka_2.13-2.4.0/config/server.properties配置中对应的zookeeper.connect=localhost:2181已经符合需要,不必修改,需要修改日志文件的目录log.dirs为/data/kafka/data。然后启动Kafka服务:sh /data/kafka/kafka_2.13-2.4.0/bin/kafka-server-start.sh /data/kafka/kafka_2.13-2.4.0/config/server.properties 复制代码这样启动一旦退出控制台就会结束Kafka进程,可以添加-daemon参数用于控制Kafka进程后台不挂断运行。sh /data/kafka/kafka_2.13-2.4.0/bin/kafka-server-start.sh -daemon /data/kafka/kafka_2.13-2.4.0/config/server.properties 复制代码安装和使用Canal终于到了主角登场,这里选用Canal的v1.1.4稳定发布版,只需要下载deployer模块:mkdir /data/canal cd /data/canal # 这里注意一点,Github在国内被墙,下载速度极慢,可以先用其他下载工具下载完再上传到服务器中 wget https://github.com/alibaba/canal/releases/download/canal-1.1.4/canal.deployer-1.1.4.tar.gz tar -zxvf canal.deployer-1.1.4.tar.gz 复制代码解压后的目录如下:- bin # 运维脚本 - conf # 配置文件 canal_local.properties # canal本地配置,一般不需要动 canal.properties # canal服务配置 logback.xml # logback日志配置 metrics # 度量统计配置 spring # spring-实例配置,主要和binlog位置计算、一些策略配置相关,可以在canal.properties选用其中的任意一个配置文件 example # 实例配置文件夹,一般认为单个数据库对应一个独立的实例配置文件夹 instance.properties # 实例配置,一般指单个数据库的配置 - lib # 服务依赖包 - logs # 日志文件输出目录 复制代码在开发和测试环境建议把logback.xml的日志级别修改为DEBUG方便定位问题。这里需要关注canal.properties和instance.properties两个配置文件。canal.properties文件中,需要修改:去掉canal.instance.parser.parallelThreadSize = 16这个配置项的「注释」,也就是启用此配置项,和实例解析器的线程数相关,不配置会表现为阻塞或者不进行解析。canal.serverMode配置项指定为kafka,可选值有tcp、kafka和rocketmq(master分支或者最新的的v1.1.5-alpha-1版本,可以选用rabbitmq),默认是kafka。canal.mq.servers配置需要指定为Kafka服务或者集群Broker的地址,这里配置为127.0.0.1:9092。❝canal.mq.servers在不同的canal.serverMode有不同的意义。 kafka模式下,指Kafka服务或者集群Broker的地址,也就是bootstrap.servers rocketmq模式下,指NameServer列表 rabbitmq模式下,指RabbitMQ服务的Host和Port❞其他配置项可以参考下面两个官方Wiki的链接:Canal-Kafka-RocketMQ-QuickStartAdminGuideinstance.properties一般指一个数据库实例的配置,Canal架构支持一个Canal服务实例,处理多个数据库实例的binlog异步解析。instance.properties需要修改的配置项主要包括:canal.instance.mysql.slaveId需要配置一个和Master节点的服务ID完全不同的值,这里笔者配置为654321。配置数据源实例,包括地址、用户、密码和目标数据库:canal.instance.master.address,这里指定为127.0.0.1:3306。canal.instance.dbUsername,这里指定为canal。canal.instance.dbPassword,这里指定为QWqw12!@。新增canal.instance.defaultDatabaseName,这里指定为test(需要在MySQL中建立一个test数据库,见前面的流程)。Kafka相关配置,这里暂时使用静态topic和单个partition:canal.mq.topic,这里指定为test,「也就是解析完的binlog结构化数据会发送到Kafka的命名为test的topic中」。canal.mq.partition,这里指定为0。配置工作做好之后,可以启动Canal服务:sh /data/canal/bin/startup.sh # 查看服务日志 tail -100f /data/canal/logs/canal/canal # 查看实例日志 -- 一般情况下,关注实例日志即可 tail -100f /data/canal/logs/example/example.log 复制代码启动正常后,见实例日志如下:在test数据库创建一个订单表,并且执行几个简单的DML:use `test`; CREATE TABLE `order` id BIGINT UNIQUE PRIMARY KEY AUTO_INCREMENT COMMENT '主键', order_id VARCHAR(64) NOT NULL COMMENT '订单ID', amount DECIMAL(10, 2) NOT NULL DEFAULT 0 COMMENT '订单金额', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', UNIQUE uniq_order_id (`order_id`) ) COMMENT '订单表'; INSERT INTO `order`(order_id, amount) VALUES ('10086', 999); UPDATE `order` SET amount = 10087 WHERE order_id = '10086'; DELETE FROM `order` WHERE order_id = '10086'; 复制代码这个时候,可以利用Kafka的kafka-console-consumer或者Kafka Tools查看test这个topic的数据:sh /data/kafka/kafka_2.13-2.4.0/bin/kafka-console-consumer.sh --bootstrap-server 127.0.0.1:9092 --from-beginning --topic test 复制代码具体的数据如下:// test数据库建库脚本 {"data":null,"database":"`test`","es":1583143732000,"id":1,"isDdl":false,"mysqlType":null,"old":null,"pkNames":null,"sql":"CREATE DATABASE `test` CHARSET `utf8mb4` COLLATE `utf8mb4_unicode_ci`","sqlType":null,"table":"","ts":1583143930177,"type":"QUERY"} // order表建表DDL {"data":null,"database":"test","es":1583143957000,"id":2,"isDdl":true,"mysqlType":null,"old":null,"pkNames":null,"sql":"CREATE TABLE `order`\n(\n id BIGINT UNIQUE PRIMARY KEY AUTO_INCREMENT COMMENT '主键',\n order_id VARCHAR(64) NOT NULL COMMENT '订单ID',\n amount DECIMAL(10, 2) NOT NULL DEFAULT 0 COMMENT '订单金额',\n create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',\n UNIQUE uniq_order_id (`order_id`)\n) COMMENT '订单表'","sqlType":null,"table":"order","ts":1583143958045,"type":"CREATE"} // INSERT Binlog事件 {"data":[{"id":"1","order_id":"10086","amount":"999.0","create_time":"2020-03-02 05:12:49"}],"database":"test","es":1583143969000,"id":3,"isDdl":false,"mysqlType":{"id":"BIGINT","order_id":"VARCHAR(64)","amount":"DECIMAL(10,2)","create_time":"DATETIME"},"old":null,"pkNames":["id"],"sql":"","sqlType":{"id":-5,"order_id":12,"amount":3,"create_time":93},"table":"order","ts":1583143969460,"type":"INSERT"} // UPDATE Binlog事件 {"data":[{"id":"1","order_id":"10086","amount":"10087.0","create_time":"2020-03-02 05:12:49"}],"database":"test","es":1583143974000,"id":4,"isDdl":false,"mysqlType":{"id":"BIGINT","order_id":"VARCHAR(64)","amount":"DECIMAL(10,2)","create_time":"DATETIME"},"old":[{"amount":"999.0"}],"pkNames":["id"],"sql":"","sqlType":{"id":-5,"order_id":12,"amount":3,"create_time":93},"table":"order","ts":1583143974870,"type":"UPDATE"} // DELETE Binlog事件 {"data":[{"id":"1","order_id":"10086","amount":"10087.0","create_time":"2020-03-02 05:12:49"}],"database":"test","es":1583143980000,"id":5,"isDdl":false,"mysqlType":{"id":"BIGINT","order_id":"VARCHAR(64)","amount":"DECIMAL(10,2)","create_time":"DATETIME"},"old":null,"pkNames":["id"],"sql":"","sqlType":{"id":-5,"order_id":12,"amount":3,"create_time":93},"table":"order","ts":1583143981091,"type":"DELETE"} 复制代码可见Kafka的名为test的topic已经写入了对应的结构化binlog事件数据,可以编写消费者监听Kafka对应的topic然后对获取到的数据进行后续处理。这里发送过来的数据结构可以参考一下Canal源码(当前编辑的时间为2020-07-03的master分支)中的com.alibaba.otter.canal.protocol.FlatMessage:其中注意一下:FlatMessage.data是当前的DML新写入的数据,而FlatMessage.old是当前新写入数据前的历史数据,对于UPDATE类型的DML来说,FlatMessage.data和FlatMessage.old都会存在数据。FlatMessage.sqlType的Map.Entry#value()一般情况下和java.sql.JDBCType这个枚举的映射一致,解析的时候可以匹配每一个Column属性的JDBCType,再按照需要转化成合适的Java类型即可。为了提高传输效率,Canal发送到消息中间件的时候会进行消息合并,一个FlatMessage有可能包含同一类事件的多条不同的更变记录,注意到FlatMessage.data是List<Map<String, String>>类型,例如对于同一个表的INSERT事件,有可能合并到同一个FlatMessage实例,而FlatMessage.data中包含两个元素。Canal发送到FlatMessage的时候,使用FastJson进行序列化,最近一段时间看到很多关于FastJson的漏洞相关的信息,需要做好心理准备进行版本升级。小结这篇文章大部分篇幅用于介绍其他中间件是怎么部署的,这个问题侧面说明了Canal本身部署并不复杂,它的配置文件属性项比较多,但是实际上需要自定义和改动的配置项是比较少的,也就是说明了它的运维成本和学习成本并不高。笔者目前担任架构、部分运维职责和数据中心的搭建工作,前一段时间主导把整套线上服务由UCloud迁移到阿里云,并且应用了云RDS MySQL,同时自建了一套Canal的HA集群,用于订阅核心服务的数据,经过轻量级ETL和清洗,落入一个持续建模的数据仓库中,基于近实时的Binlog事件进行一些实时缓存的计算和更新,生成一些视图表对接Metabase提供多种维度的图标用于运营指标的实时监控。这个过程中,踩了相对多的坑。解析Canal生成的Binlog事件在前期花费了大量的时间,考虑到开发效率低下,笔者花了点时间写了一个Binlog事件解析的胶水层,实现了无感知的对象映射功能,解放了生产力。下一篇文章会分析一下这个Binlog事件解析工具的实现,后面还会分享一下遇到的问题以及解决方案,包括:Canal停启的异常(如果用了云RDS MySQL,这个坑比较大)以及Binlog事件顺序的问题等等。参考资料:A Quick Guide to Using the MySQL Yum RepositoryCanal

Levenshtein Distance(编辑距离)算法与使用场景

前提已经很久没深入研究过算法相关的东西,毕竟日常少用,就算死记硬背也是没有实施场景导致容易淡忘。最近在做一个「脱敏数据和明文数据匹配」的需求的时候,用到了一个算法叫Levenshtein Distance Algorithm,本文对此算法原理做简单的分析,并且用此算法解决几个常见的场景。什么是Levenshtein DistanceLevenshtein Distance,一般称为编辑距离(Edit Distance,Levenshtein Distance只是编辑距离的其中一种)或者莱文斯坦距离,算法概念是俄罗斯科学家弗拉基米尔·莱文斯坦(Levenshtein · Vladimir I)在1965年提出。此算法的概念很简单:Levenshtein Distance指「两个字串之间,由一个转换成另一个所需的最少编辑操作次数」,允许的编辑操作包括:将其中一个字符替换成另一个字符(Substitutions)。插入一个字符(Insertions)。删除一个字符(Deletions)。❝下文开始简称Levenshtein Distance为LD❞Levenshtein Distance公式定义这个数学公式最终得出的数值就是LD的值。举个例子:将kitten这个单词转成sitting的LD值为3:kitten → sitten (k→s)sitten → sittin (e→i)sittin → sitting (insert a 'g')Levenshtein Distance动态规划方法可以使用动态规划的方法去测量LD的值,步骤大致如下:初始化一个LD矩阵(M,N),M和N分别是两个输入字符串的长度。矩阵可以从左上角到右下角进行填充,每个水平或垂直跳转分别对应于一个插入或一个删除。通过定义每个操作的成本为1,如果两个字符串不匹配,则对角跳转的代价为1,否则为0,简单来说就是:如果[i][j]位置的两个字符串相等,则从[i][j]位置左加1,上加1,左上加0,然后从这三个数中取出最小的值填充到[i][j]。如果[i][j]位置的两个字符串不相等,则从[i][j]位置左、左上、上三个位置的值中取最小值,这个最小值加1(或者说这三个值都加1然后取最小值),然后填充到[i][j]。按照上面规则LD矩阵(M,N)填充完毕后,最终「矩阵右下角的数字」就是两个字符串的LD值。这里不打算证明上面动态规划的结论(也就是默认这个动态规划的结果是正确的),直接举两个例子说明这个问题:例子一(两个等长字符串):son和sun。例子二(两个非等长字符串):doge和dog。「例子一:」初始化LD矩阵(3,3):son0123s1u2n3计算[0][0]的位置的值,因为's' = 's',所以[0][0]的值 = min(1+1, 1+1, 0+0) = 0。son0123s10u2n3按照这个规则计算其他位置的值,填充完毕后的LD矩阵`如下:son0123s1012u2112n3221那么son和sun的LD值为1。「例子二:」初始化LD矩阵(4,3):dog0123d1o2g3e4接着填充矩阵:dog0123d1012o2101g3210e4321那么doge和dog的LD值为1。Levenshtein Distance算法实现依据前面提到的动态规划方法,可以相对简单地实现LD的算法,这里选用Java语言进行实现:public enum LevenshteinDistance { // 单例 * 计算Levenshtein Distance public int ld(String source, String target) { Optional.ofNullable(source).orElseThrow(() -> new IllegalArgumentException("source")); Optional.ofNullable(target).orElseThrow(() -> new IllegalArgumentException("target")); int sl = source.length(); int tl = target.length(); // 定义矩阵,行列都要加1 int[][] matrix = new int[sl + 1][tl + 1]; // 首行首列赋值 for (int k = 0; k <= sl; k++) { matrix[k][0] = k; for (int k = 0; k <= tl; k++) { matrix[0][k] = k; // 定义临时的编辑消耗 int cost; for (int i = 1; i <= sl; i++) { for (int j = 1; j <= tl; j++) { if (source.charAt(i - 1) == target.charAt(j - 1)) { cost = 0; } else { cost = 1; matrix[i][j] = min( // 左上 matrix[i - 1][j - 1] + cost, // 右上 matrix[i][j - 1] + 1, // 左边 matrix[i - 1][j] + 1 return matrix[sl][tl]; private int min(int x, int y, int z) { return Math.min(x, Math.min(y, z)); * 计算匹配度match rate public BigDecimal mr(String source, String target) { int ld = ld(source, target); // 1 - ld / max(len1,len2) return BigDecimal.ONE.subtract(BigDecimal.valueOf(ld) .divide(BigDecimal.valueOf(Math.max(source.length(), target.length())), 2, BigDecimal.ROUND_HALF_UP)); 复制代码算法的复杂度为O(N * M),其中N和M分别是两个输入字符串的长度。这里的算法实现完全参照前面的动态规划方法推论过程,实际上不一定需要定义二维数组(矩阵),使用两个一维的数组即可,可以参看一下java-string-similarity中Levenshtein算法的实现。以前面的例子运行一下:public static void main(String[] args) throws Exception { String s = "doge"; String t = "dog"; System.out.println("Levenshtein Distance:" +LevenshteinDistance.X.ld(s, t)); System.out.println("Match Rate:" +LevenshteinDistance.X.mr(s, t)); // 输出 Levenshtein Distance:1 Match Rate:0.75 复制代码Levenshtein Distance算法一些使用场景LD算法主要的应用场景有:DNA分析。拼写检查。语音识别。抄袭侦测。等等......其实主要就是"字符串"匹配场景,这里基于实际遇到的场景举例。脱敏数据和明文数据匹配最近有场景做脱敏数据和明文数据匹配,有时候第三方导出的文件是脱敏文件,格式如下:姓名手机号身份证张*狗123****8910123456****8765****己方有明文数据如下:姓名手机号身份证张大狗12345678910123456789987654321要把两份数据进行匹配,得出上面两条数据对应的是同一个人的数据,原理就是:当且仅当两条数据中手机号的LD值为4,身份证的LD值为8,姓名的LD值为1,则两条数据完全匹配。使用前面写过的算法:public static void main(String[] args) throws Exception { String sourceName = "张*狗"; String sourcePhone = "123****8910"; String sourceIdentityNo = "123456****8765****"; String targetName = "张大狗"; String targetPhone = "12345678910"; String targetIdentityNo = "123456789987654321"; boolean match = LevenshteinDistance.X.ld(sourceName, targetName) == 1 && LevenshteinDistance.X.ld(sourcePhone, targetPhone) == 4 && LevenshteinDistance.X.ld(sourceIdentityNo, targetIdentityNo) == 8; System.out.println("是否匹配:" + match); targetName = "张大doge"; match = LevenshteinDistance.X.ld(sourceName, targetName) == 1 && LevenshteinDistance.X.ld(sourcePhone, targetPhone) == 4 && LevenshteinDistance.X.ld(sourceIdentityNo, targetIdentityNo) == 8; System.out.println("是否匹配:" + match); // 输出结果 是否匹配:true 是否匹配:false 复制代码拼写检查这个场景看起来比较贴近生活,也就是词典应用的拼写提示,例如输入了throwab,就能提示出throwable,笔者认为一个简单实现就是遍历t开头的单词库,寻找匹配度比较高(LD值比较小)的单词进行提示(实际上为了满足效率有可能并不是这样实现的)。举个例子:public static void main(String[] args) throws Exception { String target = "throwab"; // 模拟一个单词库 List<String> words = Lists.newArrayList(); words.add("throwable"); words.add("their"); words.add("the"); Map<String, BigDecimal> result = Maps.newHashMap(); words.forEach(x -> result.put(x, LevenshteinDistance.X.mr(x, target))); System.out.println("输入值为:" + target); result.forEach((k, v) -> System.out.println(String.format("候选值:%s,匹配度:%s", k, v))); // 输出结果 输入值为:throwab 候选值:the,匹配度:0.29 候选值:throwable,匹配度:0.78 候选值:their,匹配度:0.29 复制代码这样子就可以基于输入的throwab选取匹配度最高的throwable。抄袭侦测抄袭侦测的本质也是字符串的匹配,可以简单认为匹配度高于某一个阈值就是属于抄袭。例如《我是一只小小鸟》里面的一句歌词是:❝我是一只小小小小鸟,想要飞呀飞却飞也飞不高❞假设笔者创作了一句歌词:❝我是一条小小小小狗,想要睡呀睡却睡也睡不够❞我们可以尝试找出两句词的匹配度:System.out.println(LevenshteinDistance.X.mr("我是一只小小小小鸟,想要飞呀飞却飞也飞不高", "我是一条小小小小狗,想要睡呀睡却睡也睡不够")); // 输出如下 复制代码可以认为笔者创作的歌词是完全抄袭的。当然,对于大文本的抄袭侦测(如论文查重等等)需要考虑执行效率的问题,解决的思路应该是类似的,但是需要考虑如何分词、大小写等等各种的问题。小结本文仅仅对Levenshtein Distance做了一点皮毛上的分析并且列举了一些简单的场景,其实此算法在日常生活中是十分常见的,笔者猜测词典应用的单词拼写检查、论文查重(抄袭判别)都可能和此算法相关。算法虽然学习曲线比较陡峭,但是它确实是一把解决问题的利刃。Levenshtein Distance存在明显的优势和劣势:明显的劣势:算法的时间复杂度是O(M * N),存在两重循环,「效率比较低」。明显的优势:根据此算法得到的匹配度或者说「结果和现实生活的真实场景最接近」。对于其劣势,可以考虑选择一些改良的编辑距离算法,这里就不做展开了。参考资料:维基百科 - Levenshtein distancejava-string-similarityThe Levenshtein Algorithm

JSR310新日期API(完结篇)-生产实战

前提前面通过五篇文章基本介绍完JSR-310常用的日期时间API以及一些工具类,这篇博文主要说说笔者在生产实战中使用JSR-310日期时间API的一些经验。系列文章:JSR310新日期API(一)-时区与时间偏移量JSR310新日期API(二)-日期时间APIJSR310新日期API(三)-日期时间格式化与解析JSR310新日期API(四)-日期时间常用计算工具JSR310新日期API(五)-在主流框架中使用新日期时间类::: info 不经意间,JDK8发布已经超过6年了,如果还在用旧的日期时间API,可以抽点时间熟悉一下JSR-310的日期时间API。 :::仿真场景下面会结合一下仿真场景介绍具体的API选取,由于OffsetDateTime基本能满足大部分场景,因此挑选OffsetDateTime进行举例。场景一:字符串输入转换为日期时间对象一般在Web应用的表单提交或者Reuqest Body提交的内容中,需要把字符串形式的日期时间转换为对应的日期时间对象。Web应用多数情况下会使用SpringMVC,而SpringMVC的消息转换器在处理application/json类型的请求内容的时候会使用ObjectMapper(Jackson)进行反序列化。这里引入org.springframework.boot:spring-boot-starter-web:2.2.5.RELEASE做一个演示。引入spring-boot-starter-web的最新版本之后,内置的Jackson已经引入了JSR-310相关的两个依赖。SpringBoot中引入在装载ObjectMapper通过Jackson2ObjectMapperBuilder中的建造器方法加载了JavaTimeModule和Jdk8Module,实现了对JSR-310特性的支持。值得注意的是JavaTimeModule中和日期时间相关的格式化器DateTimeFormatter都使用了内置的实现,如日期时间使用的是DateTimeFormatter.ISO_OFFSET_DATE_TIME,无法解析yyyy-MM-dd HH:mm:ss模式的字符串。例如:public class Request { private OffsetDateTime createTime; public OffsetDateTime getCreateTime() { return createTime; public void setCreateTime(OffsetDateTime createTime) { this.createTime = createTime; @PostMapping(path = "/test") public void test(@RequestBody Request request) throws Exception { LOGGER.info("请求内容:{}", objectMapper.writeValueAsString(request)); 复制代码请求如下:curl --location --request POST 'localhost:9091/test' \ --header 'Content-Type: application/json' \ --data-raw '{ "createTime": "2020-03-01T21:51:03+08:00" }' // 请求内容:{"createTime":"2020-03-01T13:51:03Z"} 复制代码如果执意要选用yyyy-MM-dd HH:mm:ss模式的字符串,那么属性的类型只能选用LocalDateTime并且要重写对应的序列化器和反序列化器,覆盖JavaTimeModule中原有的实现,参考前面的一篇文章。场景二:查询两个日期时间范围内的数据笔者负责的系统中,经常有定时调度的场景,举个例子:每天凌晨1点要跑一个定时任务,查询T-1日或者上一周的业务数据,更新到对应的业务统计表中,以便第二天早上运营的同事查看报表数据。查询T-1日的数据,实际上就是查询T-1日00:00:00到23:59:59的数据。这里举一个案例,计算T-1日所有订单的总金额:@Slf4j public class Process { static ZoneId Z = ZoneId.of("Asia/Shanghai"); static DateTimeFormatter F = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); JdbcTemplate jdbcTemplate; @Data private static class Order { private Long id; private String orderId; private BigDecimal amount; private OffsetDateTime createTime; public void processTask() { // 这里的时区要按实际情况选择 OffsetDateTime now = OffsetDateTime.now(Z); OffsetDateTime start = now.plusDays(-1L).withHour(0).withMinute(0).withSecond(0).withNano(0); OffsetDateTime end = start.withHour(23).withMinute(59).withSecond(59).withNano(0); BigDecimal totalAmount = BigDecimal.ZERO; int limit = 500; long maxId = 0L; while (true) { List<Order> orders = selectPendingProcessingOrders(start, end, limit, maxId); if (!orders.isEmpty()) { totalAmount = totalAmount.add(orders.stream().map(Order::getAmount).reduce(BigDecimal::add) .orElse(BigDecimal.ZERO)); maxId = orders.stream().map(Order::getId).max(Long::compareTo).orElse(Long.MAX_VALUE); } else { break; log.info("统计[{}-{}]的订单总金额为:{}", start.format(F), end.format(F), totalAmount); static ResultSetExtractor<List<Order>> MANY = r -> { List<Order> orders = new ArrayList<>(); while (r.next()) { Order order = new Order(); orders.add(order); order.setId(r.getLong("id")); order.setOrderId(r.getString("order_id")); order.setAmount(r.getBigDecimal("amount")); order.setCreateTime(OffsetDateTime.ofInstant(r.getTimestamp("create_time").toInstant(), Z)); return orders; private List<Order> selectPendingProcessingOrders(OffsetDateTime start, OffsetDateTime end, int limit, long id) { return jdbcTemplate.query("SELECT * FROM t_order WHERE create_time >= ? AND create_time <= ? AND id > ? LIMIT ?", p -> { p.setTimestamp(1, Timestamp.from(start.toInstant())); p.setTimestamp(2, Timestamp.from(end.toInstant())); p.setLong(3, id); p.setInt(4, limit); }, MANY); 复制代码上面的只是伪代码(而且例子比较不合理,其实一个SUM就可以搞定),不能直接执行,使用的是基于日期时间和ID翻页的设计,在保证效率的同时可以降低IO,常用于查询比较多的定时任务或者数据迁移。场景三:计算两个日期时间之间的差值计算两个日期时间之间的差值也是很常见的场景,笔者遇到过的场景就是:运营需要导出一批用户数据,主要包括用户ID、脱敏信息、用户注册日期时间以及注册日期时间距当前日期的天数。用户ID用户姓名注册日期时间注册距今天数1张小狗2019-01-03 12:11:23x2张大狗2019-10-02 23:22:13y设计的伪代码如下:@Data private static class CustomerDto { private Long id; private String name; private OffsetDateTime registerTime; private Long durationInDay; @Data private static class Customer { private Long id; private String name; private OffsetDateTime registerTime; static ZoneId Z = ZoneId.of("Asia/Shanghai"); static OffsetDateTime NOW = OffsetDateTime.now(Z); public List<CustomerDto> processUnit() { return Optional.ofNullable(select()).filter(Objects::nonNull) .map(list -> { List<CustomerDto> result = new ArrayList<>(); list.forEach(x -> { CustomerDto dto = new CustomerDto(); dto.setId(x.getId()); dto.setName(x.getName()); dto.setRegisterTime(x.getRegisterTime()); Duration duration = Duration.between(x.getRegisterTime(), NOW); dto.setDurationInDay(duration.toDays()); result.add(dto); return result; }).orElse(null); private List<Customer> select() { // 模拟查询 return null; 复制代码通过Duration可以轻松计算两个日期时间之间的差值,并且可以轻松转换为不同的时间计量单位。场景四:计算特殊节假日的日期利用日期时间校准器TemporalAdjuster可以十分方便地计算XX月YY日是ZZ节这种日期形式的节日。例如:五月第二个星期日是母亲节,六月的第三个星期日是父亲节。public class X { public static void main(String[] args) throws Exception { OffsetDateTime time = OffsetDateTime.now(); System.out.println(String.format("%d年母亲节是:%s", time.getYear(), time.withMonth(5).with(TemporalAdjusters.dayOfWeekInMonth(2, DayOfWeek.SUNDAY)).toLocalDate().toString())); System.out.println(String.format("%d年父亲节是:%s", time.getYear(), time.withMonth(6).with(TemporalAdjusters.dayOfWeekInMonth(3, DayOfWeek.SUNDAY)).toLocalDate().toString())); time = time.plusYears(1); System.out.println(String.format("%d年母亲节是:%s", time.getYear(), time.withMonth(5).with(TemporalAdjusters.dayOfWeekInMonth(2, DayOfWeek.SUNDAY)).toLocalDate().toString())); System.out.println(String.format("%d年父亲节是:%s", time.getYear(), time.withMonth(6).with(TemporalAdjusters.dayOfWeekInMonth(3, DayOfWeek.SUNDAY)).toLocalDate().toString())); // 输出结果 2020年母亲节是:2020-05-10 2020年父亲节是:2020-06-21 2021年母亲节是:2021-05-09 2021年父亲节是:2021-06-20 复制代码有些定时调度或者提醒消息发送需要在这类特定的日期时间触发,那么通过TemporalAdjuster就可以相对简单地计算出具体的日期。小结关于JSR-310的日期时间API就介绍这么多,笔者最近从事数据方面的工作,不过肯定会持续和JSR-310打交道。附录这里贴一个工具类OffsetDateTimeUtils:@Getter @RequiredArgsConstructor public enum TimeZoneConstant { CHINA(ZoneId.of("Asia/Shanghai"), "上海-中国时区"); private final ZoneId zoneId; private final String description; public enum DateTimeUtils { // 单例 public static final DateTimeFormatter L_D_T_F = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); public static final DateTimeFormatter S_D_F = DateTimeFormatter.ofPattern("yyyy-MM-dd"); public static final DateTimeFormatter S_D_M_F = DateTimeFormatter.ofPattern("yyyy-MM"); public static final DateTimeFormatter S_T_F = DateTimeFormatter.ofPattern("HH:mm:ss"); public OffsetDateTime getCurrentOffsetDateTime() { return OffsetDateTime.now(TimeZoneConstant.CHINA.getZoneId()); public OffsetDateTime getDeltaDayOffsetDateTimeStart(long delta) { return getCurrentOffsetDateTime().plusDays(delta).withHour(0).withMinute(0).withSecond(0).withNano(0); public OffsetDateTime getDeltaDayOffsetDateTimeEnd(long delta) { return getCurrentOffsetDateTime().plusDays(delta).withHour(23).withMinute(59).withSecond(59).withNano(0); public OffsetDateTime getYesterdayOffsetDateTimeStart() { return getDeltaDayOffsetDateTimeStart(-1L); public OffsetDateTime getYesterdayOffsetDateTimeEnd() { return getDeltaDayOffsetDateTimeEnd(-1L); public long durationInDays(OffsetDateTime start, OffsetDateTime end) { return Duration.between(start, end).toDays(); public OffsetDateTime getThisMonthOffsetDateTimeStart() { OffsetDateTime offsetDateTime = getCurrentOffsetDateTime(); return offsetDateTime.with(TemporalAdjusters.firstDayOfMonth()).withHour(0).withMinute(0).withSecond(0).withNano(0); public OffsetDateTime getThisMonthOffsetDateTimeEnd() { OffsetDateTime offsetDateTime = getCurrentOffsetDateTime(); return offsetDateTime.with(TemporalAdjusters.lastDayOfMonth()).withHour(23).withMinute(59).withSecond(59).withNano(0);

编写一个可复用的SpringBoot应用运维脚本

前提作为Java开发者,很多场景下会使用SpringBoot开发Web应用,目前微服务主流SpringCloud全家桶也是基于SpringBoot搭建的。SpringBoot应用部署到服务器上,需要编写运维管理脚本。本文尝试基于经验,总结之前生产使用的Shell脚本,编写一个可以复用的SpringBoot应用运维脚本,从而极大减轻SpringBoot应用启动、状态、重启等管理的工作量。本文的Shell脚本在CentOS7中正常运行,其他操作系统不一定适合。如果对一些基础或者原理不感兴趣可以拖到最后,直接拷贝脚本使用。依赖到的Shell相关的知识编写SpringBoot应用运维脚本除了基本的Shell语法要相对熟练之外,还需要解决两个比较重要的问题(笔者个人认为):正确获取目标应用程序的进程ID,也就是获取Process ID(下面称PID)的问题。kill命令的正确使用姿势。命令nohup的正确使用方式。获取PID一般而言,如果通过应用名称能够成功获取PID,则可以确定应用进程正在运行,否则应用进程不处于运行状态。应用进程的运行状态是基于PID判断的,因此在应用进程管理脚本中会多次调用获取PID的命令。通常情况下会使用grep命令去查找PID,例如下面的命令是查询Redis服务的PID:ps -ef |grep redis |grep -v grep |awk '{print $2}' 复制代码其实这是一个复合命令,每个|后面都是一个完整独立的命令,其中:ps -ef是ps命令加上-ef参数,ps命令主要用于查看进程的相关状态,-e代表显示所有进程,而-f代表完整输出显示进程之间的父子关系,例如下面是笔者的虚拟机中的CentOS 7执行ps -ef后的结果:grep XXX其实就是grep对应的目标参数,用于搜索目标参数的结果,复合命令中会从前一个命令的结果中进行搜索。grep -v grep就是grep命令执行时候忽略grep自身的进程。awk '{print $2}'就是对处理的结果取出第二列。ps -ef |grep redis |grep -v grep |awk '{print $2}'复合命令执行过程就是:<1>通过ps -ef获取系统进程状态。<2>通过grep redis从<1>中的结果搜索redis关键字,得出redis进程信息。<3>通过grep -v grep从<2>中的结果过滤掉grep自身的进程。<4>通过awk '{print $2}'从<3>中的结果获取第二列。在Shell脚本中,可以使用这种方式获取PID:PID=`ps -ef |grep redis-server |grep -v grep |awk '{print $2}'` echo $PID 复制代码但是这样会存在一个问题,就是每次想获取PID都必须使用这串非常长的命令,显得有些笨拙。可以使用eval简化这个过程:PID_CMD="ps -ef |grep docker |grep -v grep |awk '{print \$2}'" PID=$(eval $PID_CMD) echo $PID 复制代码获取PID的问题解决,然后可以基于PID是否存在,决定一下步怎么操作。理解kill命令kill命令的一般形式是kill -N PID,本质功能是向对应PID的进程发送一个信号,然后对应的进程需要对这个信号作出响应,信号的编号就是N,这个N的可选值如下(系统是CentOS 7):其中开发者常见的就是9) SIGKILL和15) SIGTERM,它们的一般描述如下:信号编号信号名称描述功能影响15SIGTERMTermination (ANSI)系统向对应的进程发送一个SIGTERM信号进程立即停止,或者释放资源后停止,或者由于等待IO继续处于运行状态,也就是一般会有一个阻塞过程,或者换一个角度来说就是进程可以阻塞、处理或者忽略SIGTERM信号9SIGKILLKill(can't be caught or ignored) (POSIX)系统向对应的进程发送一个SIGKILL信号SIGKILL信号不能被忽略,一般表现为进程立即停止(当然也有额外的情况)不带-N参数的kill命令默认就是kill -15。一般而言,kill -9 PID是进程的必杀手段,但是它很有可能影响进程结束前释放资源的过程或者中止I/O操作造成数据异常丢失等问题。nohup命令如果希望在退出账号或者关闭终端后应用进程不退出,可以使用nohup命令运行对应的进程。nohup就是no hang up的缩写,翻译过来就是"不挂起"的意思,nohup的作用就是不挂起地运行命令。nohup命令的格式是:nohup Command [Arg...] [&],功能是:基于命令Command和可选的附加参数Arg运行命令,忽略所有kill命令中的挂断信号SIGHUP,&符号表示命令需要在后台运行。这里注意一点,操作系统中有三种常用的标准流: 0:标准输入流STDIN 1:标准输出流STDOUT 2:标准错误流STDERR直接运行nohup Command &的话,所有的标准输出流和错误输出流都会输出到当前目录nohup.out文件,时间长了有可能导致占用大量磁盘空间,所以一般需要把标准输出流STDOUT和标准错误流STDERR重定向到其他文件,例如nohup Command 1>server.log 2>server.log &。但是由于标准错误流STDERR没有缓冲区,所以这样做会导致server.log会被打开两次,导致标准输出和错误输出的内容会相互竞争和覆盖,因此一般会把标准错误流STDERR重定向到已经打开的标准输出流STDOUT中,也就是经常见到的2>&1,而标准输出流STDOUT可以省略>前面的1,所以:nohup Command 1>server.log 2>server.log &修改为nohup Command >server.log 2>&1 &然而,更多时候部署Java应用的时候,应用会专门把日志打印到磁盘特定的目录中便于ELK收集,如笔者前公司的运维规定日志必须打印在/data/log-center/${serverName}目录下,那么这个时候必须把nohup的标准输出流STDOUT和标准错误流STDERR完全忽略。一个比较可行的做法就是把这两个标准流全部重定向到"黑洞/dev/null"中。例如:nohup Command >/dev/null 2>&1 &编写SpringBoot应用运维脚本SpringBoot应用本质就是一个Java应用,但是会有可能添加特定的SpringBoot允许的参数,下面会一步一步分析怎么编写一个可复用的运维脚本。全局变量考虑到尽可能复用变量和提高脚本的简洁性,这里先提取可复用的全局变量。先是定义JDK的位置JDK_HOME:JDK_HOME="/usr/lib/jvm/java-1.8.0-openjdk-1.8.0.242.b08-0.el7_7.x86_64/bin/java" 复制代码接着定义应用的位置APP_LOCATION:APP_LOCATION="/data/shell/app.jar" 复制代码接着定义应用名称APP_NAME(主要用于搜索和展示):APP_NAME="app" 复制代码然后定义获取PID的命令临时变量PID_CMD,用于后面获取PID的临时变量:PID_CMD="ps -ef |grep $APP_LOCATION |grep -v grep |awk '{print \$2}'" // PID = $(eval $PID_CMD) 复制代码定义虚拟机属性VM_OPTS:VM_OPTS="-Xms2048m -Xmx2048m" 复制代码定义SpringBoot属性SPB_OPTS(一般用于配置启动端口、应用Profile或者注册中心地址等等):SPB_OPTS="--spring.profiles.active=dev" 复制代码主要是这些参数,具体可以按照实际的场景修改或者添加。编写核心方法例如脚本的文件是server.sh,那么最后需要使用sh server.sh Command执行,其中Command列表如下:start:启动服务。info:打印信息,主要是共享变量的内容。status:打印服务状态,用于判断服务是否正在运行。stop:停止服务进程。restart:重启服务。help:帮助指南。这里通过case关键字和命令执行时输入的第一个参数确定具体的调用方法。start() { echo "start: start server" stop() { echo "stop: shutdown server" restart() { echo "restart: restart server" status() { echo "status: display status of server" info() { echo "help: help info" help() { echo "start: start server" echo "stop: shutdown server" echo "restart: restart server" echo "status: display status of server" echo "info: display info of server" echo "help: help info" case $1 in start) start stop) restart) restart status) status info) help) exit $? 复制代码测试一下:[root@localhost shell]# sh server.sh start: start server stop: shutdown server restart: restart server status: display status of server info: display info of server help: help info ...... [root@localhost shell]# sh c.sh start start: start server 复制代码接着需要编写对应的方法实现。info方法info()主要用于打印当前服务的环境变量和服务的信息等等。info() { echo "=============================info==============================" echo "APP_LOCATION: $APP_LOCATION" echo "APP_NAME: $APP_NAME" echo "JDK_HOME: $JDK_HOME" echo "VM_OPTS: $VM_OPTS" echo "SPB_OPTS: $SPB_OPTS" echo "=============================info==============================" 复制代码status方法status()方法主要用于展示服务的运行状态。status() { echo "=============================status==============================" PID=$(eval $PID_CMD) if [[ -n $PID ]]; then echo "$APP_NAME is running,PID is $PID" echo "$APP_NAME is not running!!!" echo "=============================status==============================" 复制代码start方法start()方法主要用于启动服务,需要用到JDK和nohup等相关命令。start() { echo "=============================start==============================" PID=$(eval $PID_CMD) if [[ -n $PID ]]; then echo "$APP_NAME is already running,PID is $PID" nohup $JDK_HOME $VM_OPTS -jar $APP_LOCATION $SPB_OPTS >/dev/null 2>\$1 & echo "nohup $JDK_HOME $VM_OPTS -jar $APP_LOCATION $SPB_OPTS >/dev/null 2>\$1 &" PID=$(eval $PID_CMD) if [[ -n $PID ]]; then echo "Start $APP_NAME successfully,PID is $PID" echo "Failed to start $APP_NAME !!!" echo "=============================start==============================" 复制代码先判断应用是否已经运行,如果已经能获取到应用进程PID,那么直接返回。使用nohup命令结合java -jar命令启动应用程序jar包,基于PID判断是否启动成功。stop方法stop()方法用于终止应用程序进程,这里为了相对安全和优雅地kill掉进程,先采用kill -15方式,确定kill -15无法杀掉进程,再使用kill -9。stop() { echo "=============================stop==============================" PID=$(eval $PID_CMD) if [[ -n $PID ]]; then kill -15 $PID sleep 5 PID=$(eval $PID_CMD) if [[ -n $PID ]]; then echo "Stop $APP_NAME failed by kill -15 $PID,begin to kill -9 $PID" kill -9 $PID sleep 2 echo "Stop $APP_NAME successfully by kill -9 $PID" echo "Stop $APP_NAME successfully by kill -15 $PID" echo "$APP_NAME is not running!!!" echo "=============================stop==============================" 复制代码restart方法其实就是先stop(),再start()。restart() { echo "=============================restart==============================" start echo "=============================restart==============================" 复制代码测试笔者已经基于SpringBoot依赖只引入spring-boot-starter-web最简依赖,打了一个Jar包app.jar放在虚拟机的/data/shell目录下,同时上传脚本server.sh到/data/shell目录下:/data/shell - app.jar - server.sh 复制代码某一次测试结果如下:[root@localhost shell]# sh server.sh info =============================info============================== APP_LOCATION: /data/shell/app.jar APP_NAME: app JDK_HOME: /usr/lib/jvm/java-1.8.0-openjdk-1.8.0.242.b08-0.el7_7.x86_64/bin/java VM_OPTS: -Xms2048m -Xmx2048m SPB_OPTS: --spring.profiles.active=dev =============================info============================== ...... [root@localhost shell]# sh server.sh start =============================start============================== app is already running,PID is 26950 =============================start============================== ...... [root@localhost shell]# sh server.sh stop =============================stop============================== Stop app successfully by kill -15 =============================stop============================== ...... [root@localhost shell]# sh server.sh restart =============================restart============================== =============================stop============================== app is not running!!! =============================stop============================== =============================start============================== Start app successfully,PID is 27559 =============================start============================== =============================restart============================== ...... [root@localhost shell]# curl http://localhost:9091/ping -s [root@localhost shell]# pong 复制代码测试脚本确认执行的结果是正确的。其中的=================是笔者故意加入,如果觉得碍眼可以去掉。小结SpringBoot是目前或者将来一段很长时间Web服务中的主流框架,笔者花了一点时间学习Shell相关的语法,结合nohup、ps等Linux命令编写了一个可复用的应用运维脚本,目前已经应用在测试和生产环境中,在一定程度上节省了运维成本。参考资料:Nohup Invocation附录下面是server.sh脚本的所有内容:#!/bin/bash JDK_HOME="/usr/lib/jvm/java-1.8.0-openjdk-1.8.0.242.b08-0.el7_7.x86_64/bin/java" VM_OPTS="-Xms2048m -Xmx2048m" SPB_OPTS="--spring.profiles.active=dev" APP_LOCATION="/data/shell/app.jar" APP_NAME="app" PID_CMD="ps -ef |grep $APP_NAME |grep -v grep |awk '{print \$2}'" start() { echo "=============================start==============================" PID=$(eval $PID_CMD) if [[ -n $PID ]]; then echo "$APP_NAME is already running,PID is $PID" nohup $JDK_HOME $VM_OPTS -jar $APP_LOCATION $SPB_OPTS >/dev/null 2>\$1 & echo "nohup $JDK_HOME $VM_OPTS -jar $APP_LOCATION $SPB_OPTS >/dev/null 2>\$1 &" PID=$(eval $PID_CMD) if [[ -n $PID ]]; then echo "Start $APP_NAME successfully,PID is $PID" echo "Failed to start $APP_NAME !!!" echo "=============================start==============================" stop() { echo "=============================stop==============================" PID=$(eval $PID_CMD) if [[ -n $PID ]]; then kill -15 $PID sleep 5 PID=$(eval $PID_CMD) if [[ -n $PID ]]; then echo "Stop $APP_NAME failed by kill -15 $PID,begin to kill -9 $PID" kill -9 $PID sleep 2 echo "Stop $APP_NAME successfully by kill -9 $PID" echo "Stop $APP_NAME successfully by kill -15 $PID" echo "$APP_NAME is not running!!!" echo "=============================stop==============================" restart() { echo "=============================restart==============================" start echo "=============================restart==============================" status() { echo "=============================status==============================" PID=$(eval $PID_CMD) if [[ -n $PID ]]; then echo "$APP_NAME is running,PID is $PID" echo "$APP_NAME is not running!!!" echo "=============================status==============================" info() { echo "=============================info==============================" echo "APP_LOCATION: $APP_LOCATION" echo "APP_NAME: $APP_NAME" echo "JDK_HOME: $JDK_HOME" echo "VM_OPTS: $VM_OPTS" echo "SPB_OPTS: $SPB_OPTS" echo "=============================info==============================" help() { echo "start: start server" echo "stop: shutdown server" echo "restart: restart server" echo "status: display status of server" echo "info: display info of server" echo "help: help info" case $1 in start) start stop) restart) restart status) status info) help) exit $? 复制代码个人博客Throwable's Blog

ThreadLocal源码分析-黄金分割数的使用(下)

ThreadLocal源码分析ThreadLocal的创建从ThreadLocal的构造函数来看,ThreadLocal实例的构造并不会做任何操作,只是为了得到一个ThreadLocal的泛型实例,后续可以把它作为ThreadLocalMap$Entry的键:// 注意threadLocalHashCode在每个新`ThreadLocal`实例的构造同时已经确定了 private final int threadLocalHashCode = nextHashCode(); private static AtomicInteger nextHashCode = new AtomicInteger(); private static final int HASH_INCREMENT = 0x61c88647; private static int nextHashCode() { return nextHashCode.getAndAdd(HASH_INCREMENT); // 通过Supplier去覆盖initialValue方法 public static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier) { return new SuppliedThreadLocal<>(supplier); // 默认公有构造函数 public ThreadLocal() { 复制代码注意threadLocalHashCode在每个新ThreadLocal实例的构造同时已经确定了,这个值也是Entry哈希表的哈希槽绑定的哈希值。TreadLocal的set方法ThreadLocal中set()方法的源码如下:public void set(T value) { //设置值前总是获取当前线程实例 Thread t = Thread.currentThread(); //从当前线程实例中获取threadLocals属性 ThreadLocalMap map = getMap(t); if (map != null) //threadLocals属性不为null则覆盖key为当前的ThreadLocal实例,值为value map.set(this, value); //threadLocals属性为null,则创建ThreadLocalMap,第一个项的Key为当前的ThreadLocal实例,值为value createMap(t, value); // 这里看到获取ThreadLocalMap实例时候总是从线程实例的成员变量获取 ThreadLocalMap getMap(Thread t) { return t.threadLocals; // 创建ThreadLocalMap实例的时候,会把新实例赋值到线程实例的threadLocals成员 void createMap(Thread t, T firstValue) { t.threadLocals = new ThreadLocalMap(this, firstValue); 复制代码上面的过程源码很简单,设置值的时候总是先获取当前线程实例并且操作它的变量threadLocals。步骤是:获取当前运行线程的实例。通过线程实例获取线程实例成员threadLocals(ThreadLocalMap),如果为null,则创建一个新的ThreadLocalMap实例赋值到threadLocals。通过threadLocals设置值value,如果原来的哈希槽已经存在值,则进行覆盖。TreadLocal的get方法ThreadLocal中get()方法的源码如下:public T get() { //获取当前线程的实例 Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) { //根据当前的ThreadLocal实例获取ThreadLocalMap中的Entry,使用的是ThreadLocalMap的getEntry方法 ThreadLocalMap.Entry e = map.getEntry(this); if (e != null) { @SuppressWarnings("unchecked") T result = (T) e.value; return result; //线程实例中的threadLocals为null,则调用initialValue方法,并且创建ThreadLocalMap赋值到threadLocals return setInitialValue(); private T setInitialValue() { // 调用initialValue方法获取值 T value = initialValue(); Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); // ThreadLocalMap如果未初始化则进行一次创建,已初始化则直接设置值 if (map != null) map.set(this, value); createMap(t, value); return value; protected T initialValue() { return null; 复制代码initialValue()方法默认返回null,如果ThreadLocal实例没有使用过set()方法直接使用get()方法,那么ThreadLocalMap中的此ThreadLocal为Key的项会把值设置为initialValue()方法的返回值。如果想改变这个逻辑可以对initialValue()方法进行覆盖。TreadLocal的remove方法ThreadLocal中remove()方法的源码如下:public void remove() { //获取Thread实例中的ThreadLocalMap ThreadLocalMap m = getMap(Thread.currentThread()); if (m != null) //根据当前ThreadLocal作为Key对ThreadLocalMap的元素进行移除 m.remove(this); 复制代码ThreadLocal.ThreadLocalMap的初始化我们可以关注一下java.lang.Thread类里面的变量:public class Thread implements Runnable { //传递ThreadLocal中的ThreadLocalMap变量 ThreadLocal.ThreadLocalMap threadLocals = null; //传递InheritableThreadLocal中的ThreadLocalMap变量 ThreadLocal.ThreadLocalMap inheritableThreadLocals = null; 复制代码也就是,ThreadLocal需要存放和获取的数据实际上绑定在Thread实例的成员变量threadLocals中,并且是ThreadLocal#set()方法调用的时候才进行懒加载的,可以结合上一节的内容理解一下,这里不展开。什么情况下ThreadLocal的使用会导致内存泄漏其实ThreadLocal本身不存放任何的数据,而ThreadLocal中的数据实际上是存放在线程实例中,从实际来看是线程内存泄漏,底层来看是Thread对象中的成员变量threadLocals持有大量的K-V结构,并且线程一直处于活跃状态导致变量threadLocals无法释放被回收。threadLocals持有大量的K-V结构这一点的前提是要存在大量的ThreadLocal实例的定义,一般来说,一个应用不可能定义大量的ThreadLocal,所以一般的泄漏源是线程一直处于活跃状态导致变量threadLocals无法释放被回收。但是我们知道,·ThreadLocalMap·中的Entry结构的Key用到了弱引用(·WeakReference<ThreadLocal<?>>·),当没有强引用来引用ThreadLocal实例的时候,JVM的GC会回收ThreadLocalMap中的这些Key,此时,ThreadLocalMap中会出现一些Key为null,但是Value不为null的Entry项,这些Entry项如果不主动清理,就会一直驻留在ThreadLocalMap中。也就是为什么ThreadLocal中get()、set()、remove()这些方法中都存在清理ThreadLocalMap实例key为null的代码块。总结下来,内存泄漏可能出现的地方是:1、大量地(静态)初始化ThreadLocal实例,初始化之后不再调用get()、set()、remove()方法。2、初始化了大量的ThreadLocal,这些ThreadLocal中存放了容量大的Value,并且使用了这些ThreadLocal实例的线程一直处于活跃的状态。ThreadLocal中一个设计亮点是ThreadLocalMap中的Entry结构的Key用到了弱引用。试想如果使用强引用,等于ThreadLocalMap中的所有数据都是与Thread的生命周期绑定,这样很容易出现因为大量线程持续活跃导致的内存泄漏。使用了弱引用的话,JVM触发GC回收弱引用后,ThreadLocal在下一次调用get()、set()、remove()方法就可以删除那些ThreadLocalMap中Key为null的值,起到了惰性删除释放内存的作用。其实ThreadLocal在设置内部类ThreadLocal.ThreadLocalMap中构建的Entry哈希表已经考虑到内存泄漏的问题,所以ThreadLocal.ThreadLocalMap$Entry类设计为弱引用,类签名为static class Entry extends WeakReference<ThreadLocal<?>>。之前一篇文章介绍过,如果弱引用关联的对象如果置为null,那么该弱引用会在下一次GC时候回收弱引用关联的对象。举个例子:public class ThreadLocalMain { private static ThreadLocal<Integer> TL_1 = new ThreadLocal<>(); public static void main(String[] args) throws Exception { TL_1.set(1); TL_1 = null; System.gc(); Thread.sleep(300); 复制代码这种情况下,TL_1这个ThreadLocal在主动GC之后,线程绑定的ThreadLocal.ThreadLocalMap实例中的Entry哈希表中原来的TL_1所在的哈希槽Entry的引用持有值referent(继承自WeakReference)会变成null,但是Entry中的value是强引用,还存放着TL_1这个ThreadLocal未回收之前的值。这些被"孤立"的哈希槽Entry就是前面说到的要惰性删除的哈希槽。ThreadLocal的最佳实践其实ThreadLocal的最佳实践很简单:每次使用完ThreadLocal实例,都调用它的remove()方法,清除Entry中的数据。调用remove()方法最佳时机是线程运行结束之前的finally代码块中调用,这样能完全避免操作不当导致的内存泄漏,这种主动清理的方式比惰性删除有效。父子线程数据传递InheritableThreadLocal留待下一篇文章编写,因为InheritableThreadLocal只能通过父子线(1->1)程传递变量,线程池里面的线程有可能是多个父线程共享的(也就是1个父线程提交的任务有可能由线程池中的多个子线程执行),因此有可能出现问题。阿里为了解决这个问题编写过一个框架-transmittable-thread-local,解决了父线程和线程池中线程的变量传递问题。小结ThreadLocal线程本地变量是线程实例传递和存储共享变量的桥梁,真正的共享变量还是存放在线程实例本身的属性中。ThreadLocal里面的基本逻辑并不复杂,但是一旦涉及到性能影响、内存回收(弱引用)和惰性删除等环节,其实它考虑到的东西还是相对全面而且有效的。个人博客Throwable's Blog

ThreadLocal源码分析-黄金分割数的使用(上)

前提最近接触到的一个项目要兼容新老系统,最终采用了ThreadLocal(实际上用的是InheritableThreadLocal)用于在子线程获取父线程中共享的变量。问题是解决了,但是后来发现对ThreadLocal的理解不够深入,于是顺便把它的源码阅读理解了一遍。在谈到ThreadLocal之前先卖个关子,先谈谈黄金分割数。本文在阅读ThreadLocal源码的时候是使用JDK8(1.8.0_181)。黄金分割数与斐波那契数列首先复习一下斐波那契数列,下面的推导过程来自某搜索引擎的wiki:斐波那契数列:1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, ...通项公式:假设F(n)为该数列的第n项(n ∈ N*),那么这句话可以写成如下形式:F(n) = F(n-1) + F(n-2)。有趣的是,这样一个完全是自然数的数列,通项公式却是用无理数来表达的。而且当n趋向于无穷大时,前一项与后一项的比值越来越逼近0.618(或者说后一项与前一项的比值小数部分越来越逼近0.618),而这个值0.618就被称为黄金分割数。证明过程如下:黄金分割数的准确值为(根号5 - 1)/2,约等于0.618。黄金分割数的应用黄金分割数被广泛使用在美术、摄影等艺术领域,因为它具有严格的比例性、艺术性、和谐性,蕴藏着丰富的美学价值,能够激发人的美感。当然,这些不是本文研究的方向,我们先尝试求出无符号整型和带符号整型的黄金分割数的具体值:public static void main(String[] args) throws Exception { //黄金分割数 * 2的32次方 = 2654435769 - 这个是无符号32位整数的黄金分割数对应的那个值 long c = (long) ((1L << 32) * (Math.sqrt(5) - 1) / 2); System.out.println(c); //强制转换为带符号为的32位整型,值为-1640531527 int i = (int) c; System.out.println(i); 复制代码通过一个线段图理解一下:也就是2654435769为32位无符号整数的黄金分割值,而-1640531527就是32位带符号整数的黄金分割值。而ThreadLocal中的哈希魔数正是1640531527(十六进制为0x61c88647)。为什么要使用0x61c88647作为哈希魔数?这里提前说一下ThreadLocal在ThreadLocalMap(ThreadLocal在ThreadLocalMap以Key的形式存在)中的哈希求Key下标的规则:哈希算法:keyIndex = ((i + 1) * HASH_INCREMENT) & (length - 1)其中,i为ThreadLocal实例的个数,这里的HASH_INCREMENT就是哈希魔数0x61c88647,length为ThreadLocalMap中可容纳的Entry(K-V结构)的个数(或者称为容量)。在ThreadLocal中的内部类ThreadLocalMap的初始化容量为16,扩容后总是2的幂次方,因此我们可以写个Demo模拟整个哈希的过程:public class Main { private static final int HASH_INCREMENT = 0x61c88647; public static void main(String[] args) throws Exception { hashCode(4); hashCode(16); hashCode(32); private static void hashCode(int capacity) throws Exception { int keyIndex; for (int i = 0; i < capacity; i++) { keyIndex = ((i + 1) * HASH_INCREMENT) & (capacity - 1); System.out.print(keyIndex); System.out.print(" "); System.out.println(); 复制代码上面的例子中,我们分别模拟了ThreadLocalMap容量为4,16,32的情况下,不触发扩容,并且分别"放入"4,16,32个元素到容器中,输出结果如下:3 2 1 0 7 14 5 12 3 10 1 8 15 6 13 4 11 2 9 0 7 14 21 28 3 10 17 24 31 6 13 20 27 2 9 16 23 30 5 12 19 26 1 8 15 22 29 4 11 18 25 0 复制代码每组的元素经过散列算法后恰好填充满了整个容器,也就是实现了完美散列。实际上,这个并不是偶然,其实整个哈希算法可以转换为多项式证明:证明(x - y) * HASH_INCREMENT != 2^n * (n m),在x != y,n != m,HASH_INCREMENT为奇数的情况下恒成立,具体证明可以自行完成。HASH_INCREMENT赋值为0x61c88647的API文档注释如下:连续生成的哈希码之间的差异(增量值),将隐式顺序线程本地id转换为几乎最佳分布的乘法哈希值,这些不同的哈希值最终生成一个2的幂次方的哈希表。ThreadLocal是什么下面引用ThreadLocal的API注释:This class provides thread-local variables. These variables differ from their normal counterparts in that each thread that accesses one (via its get or set method) has its own, independently initialized copy of the variable. ThreadLocal instances are typically private static fields in classes that wish to associate state with a thread (e.g., a user ID or Transaction ID)稍微翻译一下:ThreadLocal提供线程局部变量。这些变量与正常的变量不同,因为每一个线程在访问ThreadLocal实例的时候(通过其get或set方法)都有自己的、独立初始化的变量副本。ThreadLocal实例通常是类中的私有静态字段,使用它的目的是希望将状态(例如,用户ID或事务ID)与线程关联起来。ThreadLocal由Java界的两个大师级的作者编写,Josh Bloch和Doug Lea。Josh Bloch是JDK5语言增强、Java集合(Collection)框架的创办人以及《Effective Java》系列的作者。Doug Lea是JUC(java.util.concurrent)包的作者,Java并发编程的泰斗。所以,ThreadLocal的源码十分值得学习。ThreadLocal的原理ThreadLocal虽然叫线程本地(局部)变量,但是实际上它并不存放任何的信息,可以这样理解:它是线程(Thread)操作ThreadLocalMap中存放的变量的桥梁。它主要提供了初始化、set()、get()、remove()几个方法。这样说可能有点抽象,下面画个图说明一下在线程中使用ThreadLocal实例的set()和get()方法的简单流程图。假设我们有如下的代码,主线程的线程名字是main(也有可能不是main):public class Main { private static final ThreadLocal<String> LOCAL = new ThreadLocal<>(); public static void main(String[] args) throws Exception{ LOCAL.set("doge"); System.out.println(LOCAL.get()); 复制代码线程实例和ThreadLocal实例的关系如下:上面只描述了单线程的情况并且因为是主线程忽略了Thread t = new Thread()这一步,如果有多个线程会稍微复杂一些,但是原理是不变的,ThreadLocal实例总是通过Thread.currentThread()获取到当前操作线程实例,然后去操作线程实例中的ThreadLocalMap类型的成员变量,因此它是一个桥梁,本身不具备存储功能。ThreadLocal源码分析对于ThreadLocal的源码,我们需要重点关注set()、get()、remove()几个方法。ThreadLocal的内部属性//获取下一个ThreadLocal实例的哈希魔数 private final int threadLocalHashCode = nextHashCode(); //原子计数器,主要到它被定义为静态 private static AtomicInteger nextHashCode = new AtomicInteger(); //哈希魔数(增长数),也是带符号的32位整型值黄金分割值的取正 private static final int HASH_INCREMENT = 0x61c88647; //生成下一个哈希魔数 private static int nextHashCode() { return nextHashCode.getAndAdd(HASH_INCREMENT); 复制代码这里需要注意一点,threadLocalHashCode是一个final的属性,而原子计数器变量nextHashCode和生成下一个哈希魔数的方法nextHashCode()是静态变量和静态方法,静态变量只会初始化一次。换而言之,每新建一个ThreadLocal实例,它内部的threadLocalHashCode就会增加0x61c88647。举个例子://t1中的threadLocalHashCode变量为0x61c88647 ThreadLocal t1 = new ThreadLocal(); //t2中的threadLocalHashCode变量为0x61c88647 + 0x61c88647 ThreadLocal t2 = new ThreadLocal(); //t3中的threadLocalHashCode变量为0x61c88647 + 0x61c88647 + 0x61c88647 ThreadLocal t3 = new ThreadLocal(); 复制代码threadLocalHashCode是下面的ThreadLocalMap结构中使用的哈希算法的核心变量,对于每个ThreadLocal实例,它的threadLocalHashCode是唯一的。内部类ThreadLocalMap的基本结构和源码分析ThreadLocal内部类ThreadLocalMap使用了默认修饰符,也就是包(包私有)可访问的。ThreadLocalMap内部定义了一个静态类Entry。我们重点看下ThreadLocalMap的源码,先看成员和结构部分:/** * ThreadLocalMap是一个定制的散列映射,仅适用于维护线程本地变量。 * 它的所有方法都是定义在ThreadLocal类之内。 * 它是包私有的,所以在Thread类中可以定义ThreadLocalMap作为变量。 * 为了处理非常大(指的是值)和长时间的用途,哈希表的Key使用了弱引用(WeakReferences)。 * 引用的队列(弱引用)不再被使用的时候,对应的过期的条目就能通过主动删除移出哈希表。 static class ThreadLocalMap { //注意这里的Entry的Key为WeakReference<ThreadLocal<?>> static class Entry extends WeakReference<ThreadLocal<?>> { //这个是真正的存放的值 Object value; // Entry的Key就是ThreadLocal实例本身,Value就是输入的值 Entry(ThreadLocal<?> k, Object v) { super(k); value = v; //初始化容量,必须是2的幂次方 private static final int INITIAL_CAPACITY = 16; //哈希(Entry)表,必须时扩容,长度必须为2的幂次方 private Entry[] table; //哈希表中元素(Entry)的个数 private int size = 0; //下一次需要扩容的阈值,默认值为0 private int threshold; //设置下一次需要扩容的阈值,设置值为输入值len的三分之二 private void setThreshold(int len) { threshold = len * 2 / 3; // 以len为模增加i private static int nextIndex(int i, int len) { return ((i + 1 < len) ? i + 1 : 0); // 以len为模减少i private static int prevIndex(int i, int len) { return ((i - 1 >= 0) ? i - 1 : len - 1); 复制代码这里注意到十分重要的一点:ThreadLocalMap$Entry是WeakReference(弱引用),并且键值Key为ThreadLocal<?>实例本身,这里使用了无限定的泛型通配符。接着看ThreadLocalMap的构造函数:// 构造ThreadLocal时候使用,对应ThreadLocal的实例方法void createMap(Thread t, T firstValue) ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) { // 哈希表默认容量为16 table = new Entry[INITIAL_CAPACITY]; // 计算第一个元素的哈希码 int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1); table[i] = new Entry(firstKey, firstValue); size = 1; setThreshold(INITIAL_CAPACITY); // 构造InheritableThreadLocal时候使用,基于父线程的ThreadLocalMap里面的内容进行提取放入新的ThreadLocalMap的哈希表中 // 对应ThreadLocal的静态方法static ThreadLocalMap createInheritedMap(ThreadLocalMap parentMap) private ThreadLocalMap(ThreadLocalMap parentMap) { Entry[] parentTable = parentMap.table; int len = parentTable.length; setThreshold(len); table = new Entry[len]; // 基于父ThreadLocalMap的哈希表进行拷贝 for (Entry e : parentTable) { if (e != null) { @SuppressWarnings("unchecked") ThreadLocal<Object> key = (ThreadLocal<Object>) e.get(); if (key != null) { Object value = key.childValue(e.value); Entry c = new Entry(key, value); int h = key.threadLocalHashCode & (len - 1); while (table[h] != null) h = nextIndex(h, len); table[h] = c; size++; 复制代码这里注意一下,ThreadLocal的set()方法调用的时候会懒初始化一个ThreadLocalMap并且放入第一个元素。而ThreadLocalMap的私有构造是提供给静态方法ThreadLocal#createInheritedMap()使用的。接着看ThreadLocalMap提供给ThreadLocal使用的一些实例方法:// 如果Key在哈希表中找不到哈希槽的时候会调用此方法 private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) { Entry[] tab = table; int len = tab.length; // 这里会通过nextIndex尝试遍历整个哈希表,如果找到匹配的Key则返回Entry // 如果哈希表中存在Key == null的情况,调用expungeStaleEntry进行清理 while (e != null) { ThreadLocal<?> k = e.get(); if (k == key) return e; if (k == null) expungeStaleEntry(i); i = nextIndex(i, len); e = tab[i]; return null; // 1.清空staleSlot对应哈希槽的Key和Value // 2.对staleSlot到下一个空的哈希槽之间的所有可能冲突的哈希表部分槽进行重哈希,置空Key为null的槽 // 3.注意返回值是staleSlot之后的下一个空的哈希槽的哈希码 private int expungeStaleEntry(int staleSlot) { Entry[] tab = table; int len = tab.length; // expunge entry at staleSlot // 清空staleSlot对应哈希槽的Key和Value tab[staleSlot].value = null; tab[staleSlot] = null; size--; // Rehash until we encounter null // 下面的过程是对staleSlot到下一个空的哈希槽之间的所有可能冲突的哈希表部分槽进行重哈希,置空Key为null的槽 Entry e; int i; for (i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) { ThreadLocal<?> k = e.get(); if (k == null) { e.value = null; tab[i] = null; size--; } else { int h = k.threadLocalHashCode & (len - 1); if (h != i) { tab[i] = null; // Unlike Knuth 6.4 Algorithm R, we must scan until // null because multiple entries could have been stale. while (tab[h] != null) h = nextIndex(h, len); tab[h] = e; return i; // 这里个方法比较长,作用是替换哈希码为staleSlot的哈希槽中Entry的值 private void replaceStaleEntry(ThreadLocal<?> key, Object value, int staleSlot) { Entry[] tab = table; int len = tab.length; Entry e; // Back up to check for prior stale entry in current run. // We clean out whole runs at a time to avoid continual // incremental rehashing due to garbage collector freeing // up refs in bunches (i.e., whenever the collector runs). int slotToExpunge = staleSlot; // 这个循环主要是为了找到staleSlot之前的最前面的一个Key为null的哈希槽的哈希码 for (int i = prevIndex(staleSlot, len); (e = tab[i]) != null; i = prevIndex(i, len)) if (e.get() == null) slotToExpunge = i; // Find either the key or trailing null slot of run, whichever // occurs first // 遍历staleSlot之后的哈希槽,如果Key匹配则用输入值替换 for (int i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) { ThreadLocal<?> k = e.get(); // If we find key, then we need to swap it // with the stale entry to maintain hash table order. // The newly stale slot, or any other stale slot // encountered above it, can then be sent to expungeStaleEntry // to remove or rehash all of the other entries in run. if (k == key) { e.value = value; tab[i] = tab[staleSlot]; tab[staleSlot] = e; // Start expunge at preceding stale entry if it exists if (slotToExpunge == staleSlot) slotToExpunge = i; cleanSomeSlots(expungeStaleEntry(slotToExpunge), len); return; // If we didn't find stale entry on backward scan, the // first stale entry seen while scanning for key is the // first still present in the run. if (k == null && slotToExpunge == staleSlot) slotToExpunge = i; // Key匹配不了,则新创建一个哈希槽 // If key not found, put new entry in stale slot tab[staleSlot].value = null; tab[staleSlot] = new Entry(key, value); // 这里如果当前的staleSlot和找到前置的slotToExpunge不一致会进行一次清理 // If there are any other stale entries in run, expunge them if (slotToExpunge != staleSlot) cleanSomeSlots(expungeStaleEntry(slotToExpunge), len); // 对当前哈希表中所有的Key为null的Entry调用expungeStaleEntry private void expungeStaleEntries() { Entry[] tab = table; int len = tab.length; for (int j = 0; j < len; j++) { Entry e = tab[j]; if (e != null && e.get() == null) expungeStaleEntry(j); // 清理第i个哈希槽之后的n个哈希槽,如果遍历的时候发现Entry的Key为null,则n会重置为哈希表的长度,expungeStaleEntry有可能会重哈希使得哈希表长度发生变化 private boolean cleanSomeSlots(int i, int n) { boolean removed = false; Entry[] tab = table; int len = tab.length; i = nextIndex(i, len); Entry e = tab[i]; if (e != null && e.get() == null) { n = len; removed = true; i = expungeStaleEntry(i); } while ( (n >>>= 1) != 0); return removed; * 这个方法主要给`ThreadLocal#get()`调用,通过当前ThreadLocal实例获取哈希表中对应的Entry private Entry getEntry(ThreadLocal<?> key) { // 计算Entry的哈希值 int i = key.threadLocalHashCode & (table.length - 1); Entry e = table[i]; if (e != null && e.get() == key) return e; else // 注意这里,如果e为null或者Key对不上,会调用getEntryAfterMiss return getEntryAfterMiss(key, i, e); // 重哈希,必要时进行扩容 private void rehash() { // 清理所有空的哈希槽,并且进行重哈希 expungeStaleEntries(); // Use lower threshold for doubling to avoid hysteresis // 哈希表的哈希元素个数大于3/4阈值时候触发扩容 if (size >= threshold - threshold / 4) resize(); // 扩容,简单的扩大2倍的容量 private void resize() { Entry[] oldTab = table; int oldLen = oldTab.length; int newLen = oldLen * 2; Entry[] newTab = new Entry[newLen]; int count = 0; for (Entry e : oldTab) { if (e != null) { ThreadLocal<?> k = e.get(); if (k == null) { e.value = null; // Help the GC } else { int h = k.threadLocalHashCode & (newLen - 1); while (newTab[h] != null) h = nextIndex(h, newLen); newTab[h] = e; count++; setThreshold(newLen); size = count; table = newTab; // 基于ThreadLocal作为key,对当前的哈希表设置值,此方法由`ThreadLocal#set()`调用 private void set(ThreadLocal<?> key, Object value) { // We don't use a fast path as with get() because it is at // least as common to use set() to create new entries as // it is to replace existing ones, in which case, a fast // path would fail more often than not. Entry[] tab = table; int len = tab.length; int i = key.threadLocalHashCode & (len-1); // 变量哈希表 for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) { ThreadLocal<?> k = e.get(); // Key匹配,直接设置值 if (k == key) { e.value = value; return; // 如果Entry的Key为null,则替换该Key为当前的key,并且设置值 if (k == null) { replaceStaleEntry(key, value, i); return; tab[i] = new Entry(key, value); int sz = ++size; // 清理当前新设置元素的哈希槽下标到sz段的哈希槽,如果清理成功并且sz大于阈值则触发扩容 if (!cleanSomeSlots(i, sz) && sz >= threshold) rehash(); 复制代码简单来说,ThreadLocalMap是ThreadLocal真正的数据存储容器,实际上ThreadLocal数据操作的复杂部分的所有逻辑都在ThreadLocalMap中进行,而ThreadLocalMap实例是Thread的成员变量,在ThreadLocal#set()方法首次调用的时候设置到当前执行的线程实例中。如果在同一个线程中使用多个ThreadLocal实例,实际上,每个ThreadLocal实例对应的是ThreadLocalMap的哈希表中的一个哈希槽。举个例子,在主函数主线程中使用多个ThreadLocal实例:public class ThreadLocalMain { private static final ThreadLocal<Integer> TL_1 = new ThreadLocal<>(); private static final ThreadLocal<String> TL_2 = new ThreadLocal<>(); private static final ThreadLocal<Long> TL_3 = new ThreadLocal<>(); public static void main(String[] args) throws Exception { TL_1.set(1); TL_2.set("1"); TL_3.set(1L); Field field = Thread.class.getDeclaredField("threadLocals"); field.setAccessible(true); Object o = field.get(Thread.currentThread()); System.out.println(o); 复制代码实际上,主线程的threadLocals属性中的哈希表中一般不止我们上面定义的三个ThreadLocal,因为加载主线程的时候还有可能在其他地方使用到ThreadLocal,笔者某次Debug的结果如下:用PPT画图简化一下:上图threadLocalHashCode属性一行的表是为了标出每个Entry的哈希槽的哈希值,实际上,threadLocalHashCode是ThreadLocal@XXXX中的一个属性,这是很显然的,本来threadLocalHashCode就是ThreadLocal的一个成员变量。上面只是简单粗略对ThreadLocalMap的源码进行了流水账的分析,下文会作一些详细的图,说明一下ThreadLocal和ThreadLocalMap中的一些核心操作的过程。

理解和运用Java中的Lambda(下)

Java中Lambda的底层实现原理重点要说三次:Lambda表达式底层不是匿名类实现。Lambda表达式底层不是匿名类实现。Lambda表达式底层不是匿名类实现。在深入学习Lambda表达式之前,笔者也曾经认为Lambda就是匿名类的语法糖:// Lambda Function<String, String> functionX = (String x) -> x; // 错误认知 Function<String, String> functionX = new Function<String, String>() { @Override public Void apply(String x) { return x; 复制代码Lambda就是匿名类的语法糖这个认知是错误的。下面举一个例子,从源码和字节码的角度分析一下Lambda表达式编译和执行的整个流程。public class Sample { public static void main(String[] args) throws Exception { Runnable runnable = () -> { System.out.println("Hello World!"); runnable.run(); String hello = "Hello "; Function<String, String> function = string -> hello + string; function.apply("Doge"); 复制代码添加VM参数-Djdk.internal.lambda.dumpProxyClasses=.运行上面的Sample#main()方法,项目根目录动态生成了两个类如下:import java.lang.invoke.LambdaForm.Hidden; // $FF: synthetic class final class Sample?Lambda$14 implements Runnable { private Sample?Lambda$14() { @Hidden public void run() { Sample.lambda$main$0(); import java.lang.invoke.LambdaForm.Hidden; import java.util.function.Function; // $FF: synthetic class final class Sample?Lambda$15 implements Function { private final String arg$1; private Sample?Lambda$15(String var1) { this.arg$1 = var1; private static Function get$Lambda(String var0) { return new Sample?Lambda$15(var0); @Hidden public Object apply(Object var1) { return Sample.lambda$main$1(this.arg$1, (String)var1); 复制代码反查两个类的字节码,发现了类修饰符为final synthetic。接着直接看封闭类Sample的字节码:public class club/throwable/Sample { <ClassVersion=52> <SourceFile=Sample.java> public Sample() { // <init> //()V <localVar:index=0 , name=this , desc=Lclub/throwable/Sample;, sig=null, start=L1, end=L2> aload0 // reference to self invokespecial java/lang/Object.<init>()V return public static main(java.lang.String[] arg0) throws java/lang/Exception { //([Ljava/lang/String;)V <localVar:index=0 , name=args , desc=[Ljava/lang/String;, sig=null, start=L1, end=L2> <localVar:index=1 , name=runnable , desc=Lclub/throwable/Runnable;, sig=null, start=L3, end=L2> <localVar:index=2 , name=hello , desc=Ljava/lang/String;, sig=null, start=L4, end=L2> <localVar:index=3 , name=function , desc=Ljava/util/function/Function;, sig=Ljava/util/function/Function<Ljava/lang/String;Ljava/lang/String;>;, start=L5, end=L2> invokedynamic java/lang/invoke/LambdaMetafactory.metafactory(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite; : run()Lclub/throwable/Runnable; ()V club/throwable/Sample.lambda$main$0()V (6) ()V astore1 aload1 invokeinterface club/throwable/Runnable.run()V ldc "Hello " (java.lang.String) astore2 aload2 invokedynamic java/lang/invoke/LambdaMetafactory.metafactory(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite; : apply(Ljava/lang/String;)Ljava/util/function/Function; (Ljava/lang/Object;)Ljava/lang/Object; club/throwable/Sample.lambda$main$1(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; (6) (Ljava/lang/String;)Ljava/lang/String; astore3 aload3 ldc "Doge" (java.lang.String) invokeinterface java/util/function/Function.apply(Ljava/lang/Object;)Ljava/lang/Object; return private static synthetic lambda$main$1(java.lang.String arg0, java.lang.String arg1) { //(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; <localVar:index=0 , name=hello , desc=Ljava/lang/String;, sig=null, start=L1, end=L2> <localVar:index=1 , name=string , desc=Ljava/lang/String;, sig=null, start=L1, end=L2> new java/lang/StringBuilder invokespecial java/lang/StringBuilder.<init>()V aload0 // reference to arg0 invokevirtual java/lang/StringBuilder.append(Ljava/lang/String;)Ljava/lang/StringBuilder; aload1 invokevirtual java/lang/StringBuilder.append(Ljava/lang/String;)Ljava/lang/StringBuilder; invokevirtual java/lang/StringBuilder.toString()Ljava/lang/String; areturn private static synthetic lambda$main$0() { //()V getstatic java/lang/System.out:java.io.PrintStream ldc "Hello World!" (java.lang.String) invokevirtual java/io/PrintStream.println(Ljava/lang/String;)V return // The following inner classes couldn't be decompiled: java/lang/invoke/MethodHandles$Lookup 复制代码上面的字节码已经被Bytecode-Viewer工具格式化过,符合于人的阅读习惯,从字节码的阅读,结合前面的分析大概可以得出下面的结论:<1>:Lambda表达式在编译期通过字节码增强技术新增一个模板类实现对应的接口类型,这个模板类的所有属性都使用final修饰,模板类由关键字final synthetic修饰。<2>:封闭类会基于类内的Lambda表达式类型生成private static synthetic修饰的静态方法,该静态方法的方法体就是来源于Lambda方法体,这些静态方法的名称是lambda$封闭类方法名$递增数字。<3>:Lambda表达式调用最终通过字节码指令invokedynamic,忽略中间过程,最后调用到第<2>步中对应的方法。限于篇幅问题,这里把Lambda表达式的底层原理做了简单的梳理(这个推导过程仅限于个人理解,依据尚未充分):<1>:封闭类会基于类内的Lambda表达式类型生成private static synthetic修饰的静态方法,该静态方法的方法体就是来源于Lambda方法体,这些静态方法的名称是lambda$封闭类方法名$递增数字。<2>:Lambda表达式会通过LambdaMetafactory#metafactory()方法,生成一个对应函数式接口的模板类,模板类的接口方法实现引用了第<1>步中定义的静态方法,同时创建一个调用点ConstantCallSite实例,后面会通过Unsafe#defineAnonymousClass()实例化模板类。。<3>:调用点ConstantCallSite实例中的方法句柄MethodHandle会根据不同场景选取不同的实现,MethodHandle的子类很多,这里无法一一展开。<4>:通过invokedynamice指令,基于第<1>步中的模板类实例、第<3>步中的方法句柄以及方法入参进行方法句柄的调用,实际上最终委托到第<1>步中定义的静态方法中执行。如果想要跟踪Lambda表达式的整个调用生命周期,可以以LambdaMetafactory#metafactory()方法为入口开始DEBUG,调用链路十分庞大,需要有足够的耐心。总的来说就是:Lambda表达式是基于JSR-292引入的动态语言调用包java.lang.invoke和Unsafe#defineAnonymousClass()定义的轻量级模板类实现的,主要用到了invokedynamice字节码指令,关联到方法句柄MethodHandle、调用点CallSite等相对复杂的知识点,这里不再详细展开。实战基于JdbcTemplate进行轻量级DAO封装假设订单表的DDL如下:CREATE TABLE `t_order` id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, edit_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, user_id BIGINT UNSIGNED NOT NULL COMMENT '用户ID', order_id VARCHAR(64) NOT NULL COMMENT '订单ID', amount DECIMAL(12, 2) NOT NULL DEFAULT 0 COMMENT '订单金额', INDEX idx_user_id (user_id), UNIQUE uniq_order_id (order_id) ) COMMENT '订单表'; 复制代码下面基于JdbcTemplate封装一个轻量级的OrderDao:// 辅助接口 @FunctionalInterface public interface PreparedStatementProcessor { void process(PreparedStatement ps) throws SQLException; @FunctionalInterface public interface ResultSetConverter<T> { T convert(ResultSet resultSet) throws SQLException; // OrderDao接口 public interface OrderDao { int insertSelective(Order record); int updateSelective(Order record); Order selectOneByOrderId(String orderId); List<Order> selectByUserId(Long userId); // OrderDao实现 @Repository @RequiredArgsConstructor public class MySqlOrderDao implements OrderDao { private final JdbcTemplate jdbcTemplate; private static final ResultSetConverter<Order> CONVERTER = r -> { Order order = new Order(); order.setId(r.getLong("id")); order.setCreateTime(r.getTimestamp("create_time").toLocalDateTime()); order.setEditTime(r.getTimestamp("edit_time").toLocalDateTime()); order.setUserId(r.getLong("user_id")); order.setAmount(r.getBigDecimal("amount")); order.setOrderId(r.getString("order_id")); return order; private static final ResultSetExtractor<List<Order>> MULTI = r -> { List<Order> list = new ArrayList<>(); while (r.next()) { list.add(CONVERTER.convert(r)); return list; private static final ResultSetExtractor<Order> SINGLE = r -> { if (r.next()) { return CONVERTER.convert(r); return null; @Override public int insertSelective(Order record) { List<PreparedStatementProcessor> processors = new ArrayList<>(); StringBuilder sql = new StringBuilder("INSERT INTO t_order("); Cursor cursor = new Cursor(); if (null != record.getId()) { int idx = cursor.add(); sql.append("id,"); processors.add(p -> p.setLong(idx, record.getId())); if (null != record.getOrderId()) { int idx = cursor.add(); sql.append("order_id,"); processors.add(p -> p.setString(idx, record.getOrderId())); if (null != record.getUserId()) { int idx = cursor.add(); sql.append("user_id,"); processors.add(p -> p.setLong(idx, record.getUserId())); if (null != record.getAmount()) { int idx = cursor.add(); sql.append("amount,"); processors.add(p -> p.setBigDecimal(idx, record.getAmount())); if (null != record.getCreateTime()) { int idx = cursor.add(); sql.append("create_time,"); processors.add(p -> p.setTimestamp(idx, Timestamp.valueOf(record.getCreateTime()))); if (null != record.getEditTime()) { int idx = cursor.add(); sql.append("edit_time,"); processors.add(p -> p.setTimestamp(idx, Timestamp.valueOf(record.getEditTime()))); StringBuilder realSql = new StringBuilder(sql.substring(0, sql.lastIndexOf(","))); realSql.append(") VALUES ("); int idx = cursor.idx(); for (int i = 0; i < idx; i++) { if (i != idx - 1) { realSql.append("?,"); } else { realSql.append("?"); realSql.append(")"); // 传入主键的情况 if (null != record.getId()) { return jdbcTemplate.update(realSql.toString(), p -> { for (PreparedStatementProcessor processor : processors) { processor.process(p); } else { // 自增主键的情况 KeyHolder keyHolder = new GeneratedKeyHolder(); int count = jdbcTemplate.update(p -> { PreparedStatement ps = p.prepareStatement(realSql.toString(), Statement.RETURN_GENERATED_KEYS); for (PreparedStatementProcessor processor : processors) { processor.process(ps); return ps; }, keyHolder); record.setId(Objects.requireNonNull(keyHolder.getKey()).longValue()); return count; @Override public int updateSelective(Order record) { List<PreparedStatementProcessor> processors = new ArrayList<>(); StringBuilder sql = new StringBuilder("UPDATE t_order SET "); Cursor cursor = new Cursor(); if (null != record.getOrderId()) { int idx = cursor.add(); sql.append("order_id = ?,"); processors.add(p -> p.setString(idx, record.getOrderId())); if (null != record.getUserId()) { int idx = cursor.add(); sql.append("user_id = ?,"); processors.add(p -> p.setLong(idx, record.getUserId())); if (null != record.getAmount()) { int idx = cursor.add(); sql.append("amount = ?,"); processors.add(p -> p.setBigDecimal(idx, record.getAmount())); if (null != record.getCreateTime()) { int idx = cursor.add(); sql.append("create_time = ?,"); processors.add(p -> p.setTimestamp(idx, Timestamp.valueOf(record.getCreateTime()))); if (null != record.getEditTime()) { int idx = cursor.add(); sql.append("edit_time = ?,"); processors.add(p -> p.setTimestamp(idx, Timestamp.valueOf(record.getEditTime()))); StringBuilder realSql = new StringBuilder(sql.substring(0, sql.lastIndexOf(","))); int idx = cursor.add(); processors.add(p -> p.setLong(idx, record.getId())); realSql.append(" WHERE id = ?"); return jdbcTemplate.update(realSql.toString(), p -> { for (PreparedStatementProcessor processor : processors) { processor.process(p); @Override public Order selectOneByOrderId(String orderId) { return jdbcTemplate.query("SELECT * FROM t_order WHERE order_id = ?", p -> p.setString(1, orderId), SINGLE); @Override public List<Order> selectByUserId(Long userId) { return jdbcTemplate.query("SELECT * FROM t_order WHERE order_id = ?", p -> p.setLong(1, userId), MULTI); private static class Cursor { private int idx; public int add() { idx++; return idx; public int idx() { return idx; 复制代码类似于Mybatis Generator,上面的DAO实现笔者已经做了一个简单的生成器,只要配置好数据源的连接属性和表过滤规则就可以生成对应的实体类和DAO类。基于Optional进行VO设置值// 假设VO有多个层级,每个层级都不知道父节点是否为NULL,如下 // - OrderInfoVo // - UserInfoVo // - AddressInfoVo // - address(属性) // 假设我要为address属性赋值,那么就会产生箭头型代码。 // 常规方法 String address = "xxx"; OrderInfoVo o = ...; if(null != o){ UserInfoVo uiv = o.getUserInfoVo(); if (null != uiv){ AddressInfoVo aiv = uiv.getAddressInfoVo(); if (null != aiv){ aiv.setAddress(address); // 使用Optional和Lambda String address = "xxx"; OrderInfoVo o = ...; Optional.ofNullable(o).map(OrderInfoVo::getUserInfoVo).map(UserInfoVo::getAddressInfoVo).ifPresent(a -> a.setAddress(address)); 复制代码小结Lambda是Java中一个香甜的语法糖,拥抱Lambda,拥抱函数式编程,笔者也经历过抗拒、不看好、上手和真香的过程,目前也大量使用Stream和Lambda,能在保证性能的前提下,尽可能简化代码,解放劳动力。时代在进步,Java也在进步,这是很多人活着和坚持编程事业的信念。参考资料:Lambda ExpressionsDefault MethodsState of the LambdaJDK11部分源码个人博客Throwable's Blog

理解RabbitMQ中的AMQP-0-9-1模型

前提之前有个打算在学习RabbitMQ之前,把AMQP详细阅读一次,挑出里面的重点内容。后来找了下RabbitMQ的官方文档,发现了有一篇文档专门介绍了RabbitMQ中实现的AMQP模型部分,于是直接基于此文档和个人理解写下这篇文章。AMQP协议AMQP全称是Advanced Message Queuing Protocol,它是一个(分布式)消息传递协议,使用和符合此协议的客户端能够基于使用和符合此协议的消息传递中间件代理(Broker,也就是经纪人,个人感觉叫代理合口一些)进行通信。AMQP目前已经推出协议1.0,实现此协议的比较知名的产品有StormMQ、RabbitMQ、Apache Qpid等。RabbitMQ实现的AMQP版本是0.9.1,官方文档中也提供了该协议pdf文本下载,有兴趣可以翻阅一下。消息中间件代理的职责Messaging Broker,这里称为消息中间件代理。它的职责是从发布者(Publisher,或者有些时候称为Producer,生产者)接收消息,然后把消息路由到消费者(Consumer,或者有些时候称为Listener,监听者)。因为消息中间件代理、发布者客户端和消费者客户端都是基于AMQP这一网络消息协议,所以消息中间件代理、发布者客户端和消费者客户端可以在不同的机器上,从而实现分布式通讯和服务解耦。消息中间件代理不仅仅提供了消息接收和消息路由这两个基本功能,还有其他高级的特性如消息持久化功能、监控功能等等。AMQP-0-9-1在RabbitMQ中的基本模型AMQP-0-9-1模型的基本视图是:消息发布者消息发布到交换器(Exchange)中,交换器的角色有点类似于日常见到的邮局或者信箱。然后,交换器把消息的副本分发到队列(Queue)中,分发消息的时候遵循的规则叫做绑定(Binding)。接着,消息中间件代理向订阅队列的消费者发送消息(push模式),或者消费者也可以主动从队列中拉取消息(fetch/pull模式)。发布者在发布消息的时候可以指定消息属性(消息元数据),某些消息元数据可能由消息中间件代理使用,其他消息元数据对于消息中间件代理而言是不透明的,仅供消息消费者使用。由于网络是不可靠的,客户端可能无法接收消息或者处理消息失败,这个时候消息中间件代理无法感知消息是否正确传递到消费者中,因此AMQP模型提供了消息确认(Message Acknowledgement)的概念:当消息传递到消费者,消费者可以自动向消息中间件代理确认消息已经接收成功或者由应用程序开发者选择手动确认消息已经接收成功并且向消息中间件代理确认消息,消息中间件代理只有在接收到该消息(或者消息组)的确认通知后才会从队列中完全删除该消息。在某些情况下,交换器无法正确路由到队列中,那么该消息就会返回给发布者,或者丢弃,或者如果消息中间件代理实现了"死信队列(Dead Letter Queue)"扩展,消息会被放置到死信队列中。消息发布者可以选择使用对应的参数控制路由失败的处理策略。交换器和交换器类型交互器(Exchange)是消息发送的第一站目的地,它的作用就是就收消息并且将其路由到零个或者多个队列。路由消息的算法取决于交互器的类型和路由规则(也就是Binding)。RabbitMQ消息中间件代理支持四种类型的交互器,分别是:交换器类型Broker默认预声明的交换器Direct(空字符串[(AMQP default)])和amq.directFanoutamq.fanoutTopicamq.topicHeadersamq.match (和RabbitMQ中的amq.headers)声明交互器的时候需要提供一些列的属性,其中比较重要的属性如下:Name:交互器的名称。Type:交换器的类型。Durability:(交换器)持久化特性,如果启动此特性,则Broker重启后交换器依然存在,否则交换器会被删除。Auto-delete:是否自动删除,如果启用此特性,当最后一个队列解除与交换器的绑定关系,交换器会被删除。Arguments:可选参数,一般配合插件或者Broker的特性使用。之所以存在Durability和Auto-delete特性是因为并发所有的场景和用例都要求交互器是持久化的。Direct交换器Direct类型的交换器基于消息路由键(RoutingKey)把消息传递到队列中。Direct交换器是消息单播路由的理想实现(当然,用于多播路由也可以),它的工作原理如下:队列使用路由键K绑定到交换器。当具有路由键R的新消息到达交换器的时候,如果K = R,那么交换器会把消息传递到队列中。默认交换器默认交换器(Default Exchange)是一种特殊的Direct交互器,它的名称是空字符串(也就是""),它由消息中间件代理预声明,在RabbitMQ Broker中,它在Web管理界面中的名称是(AMQP default)。每个新创建的队列都会绑定到默认交换器,路由键就是该队列的队列名,也就是所有的队列都可以通过默认交换器进行消息投递,只需要指定路由键为相应的队列名即可。Fanout交换器Fanout其实是一个组合单词,fan也就是扇形,out就是向外发散的意思,Fanout交换器可以想象为"扇形"交换器。Fanout交换器会忽略路由键,它会路由消息到所有绑定到它的队列。也就是说,如果有N个队列绑定到一个Fanout交换器,当一个新的消息发布到该Fanout交换器,那么这条新消息的一个副本会分发到这N个队列中。Fanout交换器是消息广播路由的理想实现。Topic交换器Topic交换器基于路由键和绑定队列和交换器的模式进行匹配从而把消息路由到一个或者多个队列。绑定队列和交换器的Topic模式(这个模式串其实就是声明绑定时候的路由键,和消息发布的路由键并非同一个)一般使用点号(dot,也就是'.')分隔,例如source.target.key,绑定模式支持通配符:符号'#'匹配一个或者多个词,例如:source.target.#可以匹配source.target.doge、source.target.doge.throwable等等。符号'*'只能匹配一个词,例如:source.target.*可以匹配source.target.doge、source.target.throwable等等。对每一条消息,Topic交换器会遍历所有的绑定关系,检查消息指定的路由键是否匹配绑定关系中的路由键,如果匹配,则将消息推送到相应队列。Topic交换器是消息多播路由的理想实现。Headers交换器Headers交换器是一种不常用的交换器,它使用多个属性进行路由,这些属性一般称为消息头,它不使用路由键进行消息路由。消息头(Message Headers)是消息属性(消息元数据)部分,因此,使用Headers交换器在建立队列和交换器的绑定关系的时候需要指定一组键值对,发送消息到Headers交换器时候,需要在消息属性中携带一组键值对作为消息头。消息头属性支持匹配规则x-match如下:x-match = all:表示所有的键值对都匹配才能接受到消息。x-match = any:表示只要存在键值对匹配就能接受到消息。Headers交换器也是忽略路由键的,只依赖于消息属性中的消息头进行消息路由。队列AMQP 0-9-1模型中的队列与其他消息或者任务队列系统中的队列非常相似:它们存储应用程序所使用的消息。队列和交换器的基本属性有类似的地方:Name:队列名称。Durable:是否持久化,开启持久化意味着消息中间件代理重启后队列依然存在,否则队列会被删除。Exclusive:是否独占的,开启队列独占特性意味着队列只能被一个连接使用并且连接关闭之后队列会被删除。Auto-delete:是否自动删除,开启自动删除特性意味着队列至少有一个消费者并且最后一个消费者解除订阅状态(一般是消费者对应的通道关闭)后队列会自动删除。Arguments:队列参数,一般和消息中间件代理或者插件的特性相关,如消息的过期时间(Message TTL)和队列长度等。一个队列只有被声明(Declare)了才能使用,也就是队列的第一次声明就是队列的创建操作(因为第一次声明的时候队列并不存在)。如果使用相同的参数再次声明已经存在的队列,那么此次声明会不生效(当然也不会出现异常)。但是如果使用不相同的参数再次声明已经存在的队列,那么会抛出通道级别的异常,异常代码是406(PRECONDITION_FAILED)。队列名称队列名必须由255字节(bytes)长度以内的UTF-8编码字符组成。实现AMQP 0-9-1规范的消息中间件代理具备自动生成随机队列名的功能,也就是在声明队列的时候,队列名指定为空字符串,那么消息中间件代理会自动生成一个队列名,并且在队列声明的返回结果中带上对应的队列名。以"amq."开头的队列是由消息中间件代理内部生成的,有其特殊的作用,因此不能声明此类名称的新队列,否则会导致通道级别的异常,异常代码为403(ACCESS_REFUSED)。队列的持久化特性持久化的队列会持久化到磁盘中,这种队列在消息中间件代理重启后不会被删除。不开启持久化特性的队列称为瞬时(transient)队列,并非所有的场景都需要开启队列的持久化特性。队列的持久化特性并不意味着路由到它上面的消息是持久化的,也就是队列的持久化跟消息的持久化是两回事。如果息中间件代理挂了,它重启后会重新声明开启了持久化特性的队列,这些队列中只有使用了消息持久化特性的消息会被恢复。绑定绑定(Binding)是交换器路由消息到队列的规则。例如交换器E可以路由消息到队列Q,那么Q必须通过一定的规则绑定到E。绑定中使用的某些交换器的类型决定了它可以使用可选的路由键(RoutingKey)。路由键的作用类似于过滤器,可以筛选某些发布到交换器的消息路由到目标队列。如果发布的消息没有路由到任意一个目标队列,例如,消息已经发布到交换器,交换器中没有任何绑定,这个时候消息会被丢弃或者返回给发布者,取决于消息发布者发布消息时候使用的参数。消费者如果队列只有发布者生产消息,那么是没有意义的,必须有消费者对消息进行使用,或者叫这个操作为消息消费,消息消费的方式有两种:消息代理中间件向消费者推送消息(推模式,代表方法是basic.consume)。消费者主动向消息代理中间件拉取消息(拉模式,代表方法是basic.get)。使用推模式的情况下,消费者必须指定需要订阅的队列。每个队列可以存在多个消费者,或者仅仅注册一个独占的消费者。每个消费者(订阅者)都有一个称为消费者标签(consumer tag)的标识符,消费者标签是一个字符串。通过消费者标签可以实现取消订阅的操作。消息确认消费者应用程序有可能在接收和处理消息的时候崩溃,也有可能因为网络原因导致消息中间件代理投递消息到消费者的时候失败了,这样就会催生一个问题:AMQP消息中间件代理应该在什么时候从队列中删除消息?因此,AMQP 0-9-1规范提供了两种选择:消息中间件代理向应用程序发送消息(使用AMQP方法basic.deliver或basic.get-ok)。应用程序收到消息后向消息中间件代理发送确认(使用AMQP方法basic.ack <= 个人感觉这个地方少写了basic.nack和basic.reject)前一种称为自动确认模型(动作触发的同时进行了消息确认),后一种称为显式确认模型。显式确认模型中,需要消费者主动向消息中间件代理进行消息主动确认,这个消息主动确认动作的执行时机完全由应用程序控制。消息主动确认有三种方式:积极确认(ack)、消极确认(nack)和拒绝(reject)。预取消息预取消息(Prefetching Messages)是一个特性。对于多个消费者共享同一个队列的情况,能够告知消息中间件代理在发送下一个确认之前指定每个消费者一次可以接收消息的消息量。这个特性可以理解为简单的负载均衡技术,在批量发布消息的场景下能够提高吞吐量。消息属性和有效负载AMQP模型中,消息具有属性值。AMQP 0-9-1规范定义了一些常见的属性,一般开发人员不需要太关注这些属性:Content typeContent encodingRouting keyDelivery mode (persistent or not)Message priorityMessage publishing timestampExpiration periodPublisher application id这些通用的属性一般是消息中间件代理使用的,还有可以定制的可选属性header,形式是键值对,类似于HTTP中的请求头。消息属性是在发布消息的时候设置的。AMQP消息还有一个有效载荷(payload,其实就是消息数据体),AMQP代理将其视为不透明的字节数组,也就是AMQP代理不会检查或者修改消息的有效载荷。有些消息可能只包含属性而没有有效负载。通常使用序列化格式(如JSON,Thrift,Protocol Buffers和MessagePack)来序列化和结构化数据,以便将其作为消息有效负载发布。在一般约定下,消息属性中的Content type和Content encoding一般可以表明其序列化的方式。消息发布支持消息的持久化特性,消息持久化特性开启后,消息中间件代理会把消息保存到磁盘中,如果重启代理消息也不会丢失。开启消息持久化特性将会影响性能,主要是因为涉及到刷盘操作。AMQP-0-9-1方法AMQP 0-9-1定义了一些方法,对应了客户端和消息中间件代理之间交互的一些操作方法,这些操作方法的设计跟面向对象编程语言中的方法没有任何共同之处。常用的交换器相关的操作方法有:exchange.declareexchange.declare-OKexchange.deleteexchange.delete-OK在逻辑上,上面几个操作方法在客户端和消息中间件代理之间的交互如下:对于队列,也有类似的操作方法:queue.declarequeue.declare-OKqueue.deletequeue.delete-OK并非所有的AMQP操作方法都有响应结果操作方法,像消息发布方法basic.publish的使用是最广泛的,此操作方法没有对应的响应结果操作方法。有些操作方法可能有多个响应结果(操作方法),例如basic.get。连接(Connection)AMQP的连接(Connection)通常是长期存在的。AMQP是一种使用TCP进行可靠传递的应用程序级协议。AMQP连接使用用户身份验证,可以使用TLS(SSL)进行保护。当应用程序不再需要连接到AMQP代理时,它应该正常关闭AMQP连接,而不是突然关闭底层TCP连接。通道(Channel)某些应用程序需要与AMQP代理程序建立多个连接。但是,不希望同时打开许多TCP连接,因为这样做会消耗系统资源并使配置防火墙变得十分困难。通道(Channel)可以认为是"共享一个单独的TCP连接的轻量级连接",一个AMQP连接可以拥有多个通道。对于使用了多线程处理的应用程序,有一种使用场景十分普遍:每个线程开启一个新的通道使用,这些通道是线程间隔离的。另外,每个特定的通道和其他通道是相互隔离的,每个执行的AMQP操作方法(包括响应)都携带一个通道的唯一标识,这样客户端就能通过该通道的唯一标识得知操作方法是对应哪个通道发生的。虚拟主机(Virtual Host)为了使单个消息中间件代理可以托管多个完全隔离的"环境"(这里的隔离指的是用户组、交互器、队列等),AMQP提供了虚拟主机(Virtual Host)的概念。多个虚拟主机类似于许多主流的Web服务器的虚拟主机,提供了AMQP组件完全隔离的环境。AMQP客户端可以在连接消息中间件代理时指定需要连接的虚拟主机。个人理解关于Exchange、Queue和Binding理解RabbitMQ中的AMQP模型,其实从开发者的角度来看,最重要的是Exchange、Queue、Binding三者的关系,这里谈谈个人的见解。消息的发布第一站总是Exchange,从模型上看,消息发布无法直接发送到队列中。Exchange本身不存储消息,它在接收到消息之后,会基于路由规则也就是Binding,把消息路由到目标Queue中。从实际操作来看,声明路由规则总是在发布消息和消费消息之前,也就是一般步骤如下:1、声明Exchange。2、声明Queue。3、基于Exchange和Queue声明Binding,这个过程有可能自定义一个RoutingKey。4、通过Exchange消息发布,这个过程有可能使用到上一步定义的RoutingKey。5、通过Queue消费消息。我们最关注的两个阶段,消息发布和消息消费中,消息发布实际上只跟Exchange有关,而消息消费实际上只跟Queue有关。Binding实际上就是Exchange和Queue的契约关系,会直接影响消息发布阶段的消息路由。那么,路由失败一般是什么情况导致的?路由失败,其实就是消息已经发布到Exchange,而Exchange中从既有的Binding中无法找到存在的目标Queue用于传递消息副本(一般而言,很少人会发送消息到一个不存在的Exchange)。消息路由失败,从理解AMQP的模型来看,可以从根本上避免的,除非是消息发布者故意胡乱使用或者人为错误使用了未存在的RoutingKey、Exchange或者说是Binding关系而导致的。关于Exchange的类型AMQP-0-9-1模型中支持了四种交换器direct(单播)、fanout(广播)、topic(多播)、headers,实际上,从使用者角度来看,四种交换器的功能是可以相互取代的。例如可以使用fanout类型交换器实现广播,其实使用direct类型交换器也是可以实现广播的,只是对应的direct类型交换器需要通过多个路由键绑定到多个目标队列中。在面对生产环境的技术选型的时候,我们需要考虑性能、维护难度、合理性等角度去考虑选择什么类型的交换器,就上面的广播消息的例子,显然使用fanout类型交换器可以避免声明多个绑定关系,这样在性能、合理性上是更优的选择。关于负载均衡在AMQP-0-9-1模型中,负载均衡的实现是基于消费者而不是基于队列(准确来说应该是消息传递到队列的方式)。实际上,出现消息生产速度大大超过消费者的消费速度的时候,队列中有可能会出现消息积压。AMQP-0-9-1模型中没有提供基于队列负载均衡的特性,也就是出现消息生产速度大大超过消费者的消费速度时候,并不会把消息路由到多个队列中,而是通过预取消息(Prefetching Messages)的特性,确定消息者的消费能力,从而调整消息中间件代理推送消息到对应消费者的数量,这样就能够实现消费速度快的消费者能够消费更多的消息,减少产生有消费者处于饥饿状态和有消费者长期处于忙碌状态的问题。关于消息确认机制AMQP中提供的消息确认机制主要包括积极确认(一般叫ack,Acknowledgement)、消极确认(一般叫nack,Negative Acknowledgement)和拒绝(reject)。消息确认机制是保证消息不丢失的重要措施,当消费者接收到消息中间件代理推送的消息时候,需要主动通知消息中间件代理消息已经确认投递成功,然后消息中间件代理才会从队列中删除对应的消息。没有主动确认的消息就会变为"nack"状态,可以想象为暂存在队列的"nack区"中,这些消息不会投递到消费者,直到消费者重启后,"nack区"中的消息会重新变为"ready"状态,可以重新投递给消费者。关于消息确认机制其实场景比较复杂,后面再做一篇文章专门分析。小结参考资料:AMQP 0-9-1 Model Explained个人博客Throwable's Blog

深入分析Java反射(八)-优化反射调用性能

Java反射的API在JavaSE1.7的时候已经基本完善,但是本文编写的时候使用的是Oracle JDK11,因为JDK11对于sun包下的源码也上传了,可以直接通过IDE查看对应的源码和进行Debug。前提前一篇文章已经介绍了反射调用的底层原理,其实在实际中对大多数Java使用者来说更关系的是如何提升反射调用的性能,本文主要提供几个可行的方案。另外,由于方法调用时频率最高的反射操作,会着重介绍方法的反射调用优化。方法一:选择合适的API选择合适的API主要是在获取反射相关元数据的时候尽量避免使用遍历的方法,例如:获取Field实例:尽量避免频繁使用Class#getDeclaredFields()或者Class#getFields(),应该根据Field的名称直接调用Class#getDeclaredField()或者Class#getField()。获取Method实例:尽量避免频繁使用Class#getDeclaredMethods()或者Class#getMethods(),应该根据Method的名称和参数类型数组调用Class#getDeclaredMethod()或者Class#getMethod()。获取Constructor实例:尽量避免频繁使用Class#getDeclaredConstructors()或者Class#getConstructors(),应该根据Constructor参数类型数组调用Class#getDeclaredConstructor()或者Class#getConstructor()。其实思路很简单,除非我们想要获取Class的所有Field、Method或者Constructor,否则应该避免使用返回一个集合或者数组的API,这样子能减少遍历或者判断带来的性能损耗。方法二:缓存反射操作相关元数据使用缓存机制缓存反射操作相关元数据的原因是因为反射操作相关元数据的实时获取是比较耗时的,这里列举几个相对耗时的场景:获取Class实例:Class#forName(),此方法可以查看源码,耗时相对其他方法高得多。获取Field实例:Class#getDeclaredField()、Class#getDeclaredFields()、Class#getField()、Class#getFields()。获取Method实例:Class#getDeclaredMethod()、Class#getDeclaredMethods()、Class#getMethod()、Class#getMethods()。获取Constructor实例:Class#getDeclaredConstructor()、Class#getDeclaredConstructors()、Class#getConstructor()、Class#getConstructors()。这里举个简单的例子,需要反射调用一个普通JavaBean的Setter和Getter方法:// JavaBean @Data public class JavaBean { private String name; public class Main { private static final Map<Class<?>, List<ReflectionMetadata>> METADATA = new HashMap<>(); private static final Map<String, Class<?>> CLASSES = new HashMap<>(); // 解析的时候尽量放在<cinit>里面 static { Class<?> clazz = JavaBean.class; CLASSES.put(clazz.getName(), clazz); List<ReflectionMetadata> metadataList = new ArrayList<>(); METADATA.put(clazz, metadataList); try { for (Field f : clazz.getDeclaredFields()) { ReflectionMetadata metadata = new ReflectionMetadata(); metadataList.add(metadata); metadata.setTargetClass(clazz); metadata.setField(f); String name = f.getName(); Class<?> type = f.getType(); metadata.setReadMethod(clazz.getDeclaredMethod(String.format("get%s%s", Character.toUpperCase(name.charAt(0)), name.substring(1)))); metadata.setWriteMethod(clazz.getDeclaredMethod(String.format("set%s%s", Character.toUpperCase(name.charAt(0)), name.substring(1)), type)); } catch (Exception e) { throw new IllegalStateException(e); public static void main(String[] args) throws Exception { String fieldName = "name"; Class<JavaBean> javaBeanClass = JavaBean.class; JavaBean javaBean = new JavaBean(); invokeSetter(javaBeanClass, javaBean, fieldName , "Doge"); System.out.println(invokeGetter(javaBeanClass,javaBean, fieldName)); invokeSetter(javaBeanClass.getName(), javaBean, fieldName , "Throwable"); System.out.println(invokeGetter(javaBeanClass.getName(),javaBean, fieldName)); private static void invokeSetter(String className, Object target, String fieldName, Object value) throws Exception { METADATA.get(CLASSES.get(className)).forEach(each -> { Field field = each.getField(); if (field.getName().equals(fieldName)) { try { each.getWriteMethod().invoke(target, value); } catch (Exception e) { throw new IllegalStateException(e); private static void invokeSetter(Class<?> clazz, Object target, String fieldName, Object value) throws Exception { METADATA.get(clazz).forEach(each -> { Field field = each.getField(); if (field.getName().equals(fieldName)) { try { each.getWriteMethod().invoke(target, value); } catch (Exception e) { throw new IllegalStateException(e); private static Object invokeGetter(String className, Object target, String fieldName) throws Exception { for (ReflectionMetadata metadata : METADATA.get(CLASSES.get(className))) { if (metadata.getField().getName().equals(fieldName)) { return metadata.getReadMethod().invoke(target); throw new IllegalStateException(); private static Object invokeGetter(Class<?> clazz, Object target, String fieldName) throws Exception { for (ReflectionMetadata metadata : METADATA.get(clazz)) { if (metadata.getField().getName().equals(fieldName)) { return metadata.getReadMethod().invoke(target); throw new IllegalStateException(); @Data private static class ReflectionMetadata { private Class<?> targetClass; private Field field; private Method readMethod; private Method writeMethod; 复制代码简单来说,解析反射元数据进行缓存的操作最好放在静态代码块或者首次调用的时候(也就是懒加载),这样能够避免真正调用的时候总是需要重新加载一次反射相关元数据。方法三:反射操作转变为直接调用"反射操作转变为直接调用"并不是完全不依赖于反射的类库,这里的做法是把反射操作相关元数据直接放置在类的成员变量中,这样就能省去从缓存中读取反射相关元数据的消耗,而所谓"直接调用"一般是通过继承或者实现接口实现。有一些高性能的反射类库也会使用一些创新的方法:例如使用成员属性缓存反射相关元数据,并且把方法调用通过数字建立索引[Number->Method]或者建立索引类(像CGLIB的FastClass),这种做法在父类或者接口方法比较少的时候会有一定的性能提升,但是实际上性能评估需要从具体的场景通过测试分析结果而不能盲目使用,使用这个思想的类库有CGLIB、ReflectASM等。"反射操作转变为直接调用"的最典型的实现就是JDK的动态代理,这里翻出之前动态代理那篇文章的例子来说:// 接口 public interface Simple { void sayHello(String name); // 接口实现 public class DefaultSimple implements Simple { @Override public void sayHello(String name) { System.out.println(String.format("%s say hello!", name)); // 场景类 public class Main { public static void main(String[] args) throws Exception { Simple simple = new DefaultSimple(); Object target = Proxy.newProxyInstance(Main.class.getClassLoader(), new Class[]{Simple.class}, new InvocationHandler() { @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println("Before say hello..."); method.invoke(simple, args); System.out.println("After say hello..."); return null; Simple proxy = (Simple) target; proxy.sayHello("throwable"); // 代理类 public final class $Proxy0 extends Proxy implements Simple { private static Method m1; private static Method m3; private static Method m2; private static Method m0; public $Proxy0(InvocationHandler var1) throws { super(var1); public final boolean equals(Object var1) throws { try { return (Boolean)super.h.invoke(this, m1, new Object[]{var1}); } catch (RuntimeException | Error var3) { throw var3; } catch (Throwable var4) { throw new UndeclaredThrowableException(var4); public final void sayHello(String var1) throws { try { super.h.invoke(this, m3, new Object[]{var1}); } catch (RuntimeException | Error var3) { throw var3; } catch (Throwable var4) { throw new UndeclaredThrowableException(var4); public final String toString() throws { try { return (String)super.h.invoke(this, m2, (Object[])null); } catch (RuntimeException | Error var2) { throw var2; } catch (Throwable var3) { throw new UndeclaredThrowableException(var3); public final int hashCode() throws { try { return (Integer)super.h.invoke(this, m0, (Object[])null); } catch (RuntimeException | Error var2) { throw var2; } catch (Throwable var3) { throw new UndeclaredThrowableException(var3); static { try { m1 = Class.forName("java.lang.Object").getMethod("equals", Class.forName("java.lang.Object")); m3 = Class.forName("club.throwable.jdk.sample.reflection.proxy.Simple").getMethod("sayHello", Class.forName("java.lang.String")); m2 = Class.forName("java.lang.Object").getMethod("toString"); m0 = Class.forName("java.lang.Object").getMethod("hashCode"); } catch (NoSuchMethodException var2) { throw new NoSuchMethodError(var2.getMessage()); } catch (ClassNotFoundException var3) { throw new NoClassDefFoundError(var3.getMessage()); 复制代码这样做的话Simple接口实例虽然最终是通过反射调用sayHello(String var1)方法,但是相关元数据在静态代码块中创建并且已经缓存在类成员属性中,那么反射调用方法的性能已经优化到极致,剩下的都只是Native方法的耗时,这一点使用者在编码层面已经没有办法优化,只能通过升级JVM(JDK)、使用JIT编译器等非编码层面的手段提升反射性能。小结本文主要从编码层面分析反射操作一些性能优化的可行经验或者方案,或许有其他更好的优化方案,具体还是需要看使用场景。个人博客Throwable's Blog

一个基于RabbitMQ的可复用的事务消息方案

前提分布式事务是微服务实践中一个比较棘手的问题,在笔者所实施的微服务实践方案中,都采用了折中或者规避强一致性的方案。参考Ebay多年前提出的本地消息表方案,基于RabbitMQ和MySQL(JDBC)做了轻量级的封装,实现了低入侵性的事务消息模块。本文的内容就是详细分析整个方案的设计思路和实施。环境依赖如下:JDK1.8+spring-boot-start-web:2.x.x、spring-boot-start-jdbc:2.x.x、spring-boot-start-amqp:2.x.xHikariCP:3.x.x(spring-boot-start-jdbc自带)、mysql-connector-java:5.1.48redisson:3.12.1方案设计思路事务消息原则上只适合弱一致性(或者说「最终一致性」)的场景,常见的弱一致性场景如:用户服务完成了注册动作,向短信服务推送一条营销相关的消息。信贷体系中,订单服务保存订单完毕,向审批服务推送一条待审批的订单记录信息。......「强一致性的场景一般不应该选用事务消息」。一般情况下,要求强一致性说明要严格同步,也就是所有操作必须同时成功或者同时失败,这样就会引入同步带来的额外消耗。如果一个事务消息模块设计合理,补偿、查询、监控等等功能都完毕,由于系统交互是异步的,整体吞吐要比严格同步高。在笔者负责的业务系统中基于事务消息使用还定制了一条基本原则:「消息内容正确的前提下,消费方出现异常需要自理」。❝简单来说就是:上游保证了自身的业务正确性,成功推送了正确的消息到RabbitMQ就认为上游义务已经结束。❞为了降低代码的入侵性,事务消息需要借助Spring的「编程式事务」或者「声明式事务」。编程式事务一般依赖于TransactionTemplate,而声明式事务依托于AOP模块,依赖于注解@Transactional。接着需要自定义一个事务消息功能模块,新增一个事务消息记录表(其实就是「本地消息表」),用于保存每一条需要发送的消息记录。事务消息功能模块的主要功能是:保存消息记录。推送消息到RabbitMQ服务端。消息记录的查询、补偿推送等等。事务执行的逻辑单元在事务执行的逻辑单元里面,需要进行待推送的事务消息记录的保存,也就是:「本地(业务)逻辑和事务消息记录保存操作绑定在同一个事务」。发送消息到RabbitMQ服务端这一步需要延后到「事务提交之后」,这样才能保证事务提交成功和消息成功发送到RabbitMQ服务端这两个操作是一致的。为了把「保存待发送的事务消息」和「发送消息到RabbitMQ」两个动作从使用者感知角度合并为一个动作,这里需要用到Spring特有的事务同步器TransactionSynchronization,这里分析一下事务同步器的主要方法的回调位置,主要参考AbstractPlatformTransactionManager#commit()或者AbstractPlatformTransactionManager#processCommit()方法:上图仅仅演示了事务正确提交的场景(不包含异常的场景)。这里可以明确知道,事务同步器TransactionSynchronization的afterCommit()和afterCompletion(int status)方法都在真正的事务提交点AbstractPlatformTransactionManager#doCommit()之后回调,因此可以选用这两个方法其中之一用于执行推送消息到RabbitMQ服务端,整体的伪代码如下:@Transactional public Dto businessMethod(){ business transaction code block ... // 保存事务消息 [saveTransactionMessageRecord()] // 注册事务同步器 - 在afterCommit()方法中推送消息到RabbitMQ [register TransactionSynchronization,send message in method afterCommit()] business transaction code block ... 复制代码上面伪代码中,「保存事务消息」和「注册事务同步器」两个步骤可以安插在事务方法中的任意位置,也就是说与执行顺序无关。事务消息的补偿虽然之前提到笔者建议下游服务自理自身服务消费异常的场景,但是有些时候迫于无奈还是需要上游把对应的消息重新推送,这个算是特殊的场景。另外还有一个场景需要考虑:事务提交之后触发事务同步器TransactionSynchronization的afterCommit()方法失败。这是一个低概率的场景,但是在生产中一定会出现,一个比较典型的原因就是:「事务提交完成后尚未来得及触发TransactionSynchronization#afterCommit()方法进行推送服务实例就被重启」。如下图所示:为了统一处理补偿推送的问题,使用了有限状态判断消息是否已经推送成功:在事务方法内,保存事务消息的时候,标记消息记录推送状态为「处理中」。事务同步器接口TransactionSynchronization的afterCommit()方法的实现中,推送对应的消息到RabbitMQ,然后更变事务消息记录的状态为「推送成功」。还有一种极为特殊的情况是RabbitMQ服务端本身出现故障导致消息推送异常,这种情况下需要进行重试(补偿推送),「经验证明短时间内的反复重试是没有意义的」,故障的服务一般不会瞬时恢复,所以可以考虑使用「指数退避算法」进行重试,同时需要限制最大重试次数。指数值、间隔值和最大重试次数上限需要根据实际情况设定,否则容易出现消息延时过大或者重试过于频繁等问题。方案实施引入核心依赖:<properties> <spring.boot.version>2.2.4.RELEASE</spring.boot.version> <redisson.version>3.12.1</redisson.version> <mysql.connector.version>5.1.48</mysql.connector.version> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>${spring.boot.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>${mysql.connector.version}</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId> </dependency> <dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>${redisson.version}</version> </dependency> </dependencies> 复制代码spring-boot-starter-jdbc、mysql-connector-java和spring-boot-starter-aop是MySQL事务相关,而spring-boot-starter-amqp是RabbitMQ客户端的封装,redisson主要使用其分布式锁,用于补偿定时任务的加锁执行(以防止服务多个节点并发执行补偿推送)。表设计事务消息模块主要涉及两张表,以MySQL为例,建表DDL如下:CREATE TABLE `t_transactional_message` id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, edit_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, creator VARCHAR(20) NOT NULL DEFAULT 'admin', editor VARCHAR(20) NOT NULL DEFAULT 'admin', deleted TINYINT NOT NULL DEFAULT 0, current_retry_times TINYINT NOT NULL DEFAULT 0 COMMENT '当前重试次数', max_retry_times TINYINT NOT NULL DEFAULT 5 COMMENT '最大重试次数', queue_name VARCHAR(255) NOT NULL COMMENT '队列名', exchange_name VARCHAR(255) NOT NULL COMMENT '交换器名', exchange_type VARCHAR(8) NOT NULL COMMENT '交换类型', routing_key VARCHAR(255) COMMENT '路由键', business_module VARCHAR(32) NOT NULL COMMENT '业务模块', business_key VARCHAR(255) NOT NULL COMMENT '业务键', next_schedule_time DATETIME NOT NULL COMMENT '下一次调度时间', message_status TINYINT NOT NULL DEFAULT 0 COMMENT '消息状态', init_backoff BIGINT UNSIGNED NOT NULL DEFAULT 10 COMMENT '退避初始化值,单位为秒', backoff_factor TINYINT NOT NULL DEFAULT 2 COMMENT '退避因子(也就是指数)', INDEX idx_queue_name (queue_name), INDEX idx_create_time (create_time), INDEX idx_next_schedule_time (next_schedule_time), INDEX idx_business_key (business_key) ) COMMENT '事务消息表'; CREATE TABLE `t_transactional_message_content` id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, message_id BIGINT UNSIGNED NOT NULL COMMENT '事务消息记录ID', content TEXT COMMENT '消息内容' ) COMMENT '事务消息内容表'; 复制代码因为此模块有可能扩展出一个后台管理模块,所以要把消息的管理和状态相关字段和大体积的消息内容分别存放在两个表,从而避免大批量查询消息记录的时候MySQL服务IO使用率过高的问题(这是和上一个公司的DBA团队商讨后得到的一个比较合理的方案)。预留了两个业务字段business_module和business_key用于标识业务模块和业务键(一般是唯一识别号,例如订单号)。一般情况下,如果服务通过配置自行提前声明队列和交换器的绑定关系,那么发送RabbitMQ消息的时候其实只依赖于exchangeName和routingKey两个字段(header类型的交换器是特殊的,也比较少用,这里暂时不用考虑),考虑到服务可能会遗漏声明操作,发送消息的时候会基于队列进行首次绑定声明并且缓存相关的信息(RabbitMQ中的队列-交换器绑定声明只要每次声明绑定关系的参数一致,则不会抛出异常)。方案代码设计下面的方案设计描述中,暂时忽略了消息事务管理后台的API设计,这些可以在后期补充。定义贫血模型实体类TransactionalMessage和TransactionalMessageContent:@Data public class TransactionalMessage { private Long id; private LocalDateTime createTime; private LocalDateTime editTime; private String creator; private String editor; private Integer deleted; private Integer currentRetryTimes; private Integer maxRetryTimes; private String queueName; private String exchangeName; private String exchangeType; private String routingKey; private String businessModule; private String businessKey; private LocalDateTime nextScheduleTime; private Integer messageStatus; private Long initBackoff; private Integer backoffFactor; @Data public class TransactionalMessageContent { private Long id; private Long messageId; private String content; 复制代码然后定义dao接口(这里暂时不展开实现的细节代码,存储使用MySQL,如果要替换为其他类型的数据库,只需要使用不同的实现即可):public interface TransactionalMessageDao { void insertSelective(TransactionalMessage record); void updateStatusSelective(TransactionalMessage record); List<TransactionalMessage> queryPendingCompensationRecords(LocalDateTime minScheduleTime, LocalDateTime maxScheduleTime, int limit); public interface TransactionalMessageContentDao { void insert(TransactionalMessageContent record); List<TransactionalMessageContent> queryByMessageIds(String messageIds); 复制代码接着定义事务消息服务接口TransactionalMessageService:// 对外提供的服务类接口 public interface TransactionalMessageService { void sendTransactionalMessage(Destination destination, TxMessage message); @Getter @RequiredArgsConstructor public enum ExchangeType { FANOUT("fanout"), DIRECT("direct"), TOPIC("topic"), DEFAULT(""), private final String type; // 发送消息的目的地 public interface Destination { ExchangeType exchangeType(); String queueName(); String exchangeName(); String routingKey(); @Builder public class DefaultDestination implements Destination { private ExchangeType exchangeType; private String queueName; private String exchangeName; private String routingKey; @Override public ExchangeType exchangeType() { return exchangeType; @Override public String queueName() { return queueName; @Override public String exchangeName() { return exchangeName; @Override public String routingKey() { return routingKey; // 事务消息 public interface TxMessage { String businessModule(); String businessKey(); String content(); @Builder public class DefaultTxMessage implements TxMessage { private String businessModule; private String businessKey; private String content; @Override public String businessModule() { return businessModule; @Override public String businessKey() { return businessKey; @Override public String content() { return content; // 消息状态 @RequiredArgsConstructor public enum TxMessageStatus { SUCCESS(1), * 待处理 PENDING(0), * 处理失败 FAIL(-1), private final Integer status; 复制代码TransactionalMessageService的实现类是事务消息的核心功能实现,代码如下:@Slf4j @Service @RequiredArgsConstructor public class RabbitTransactionalMessageService implements TransactionalMessageService { private final AmqpAdmin amqpAdmin; private final TransactionalMessageManagementService managementService; private static final ConcurrentMap<String, Boolean> QUEUE_ALREADY_DECLARE = new ConcurrentHashMap<>(); @Override public void sendTransactionalMessage(Destination destination, TxMessage message) { String queueName = destination.queueName(); String exchangeName = destination.exchangeName(); String routingKey = destination.routingKey(); ExchangeType exchangeType = destination.exchangeType(); // 原子性的预声明 QUEUE_ALREADY_DECLARE.computeIfAbsent(queueName, k -> { Queue queue = new Queue(queueName); amqpAdmin.declareQueue(queue); Exchange exchange = new CustomExchange(exchangeName, exchangeType.getType()); amqpAdmin.declareExchange(exchange); Binding binding = BindingBuilder.bind(queue).to(exchange).with(routingKey).noargs(); amqpAdmin.declareBinding(binding); return true; TransactionalMessage record = new TransactionalMessage(); record.setQueueName(queueName); record.setExchangeName(exchangeName); record.setExchangeType(exchangeType.getType()); record.setRoutingKey(routingKey); record.setBusinessModule(message.businessModule()); record.setBusinessKey(message.businessKey()); String content = message.content(); // 保存事务消息记录 managementService.saveTransactionalMessageRecord(record, content); // 注册事务同步器 TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() { @Override public void afterCommit() { managementService.sendMessageSync(record, content); 复制代码消息记录状态和内容持久化的管理统一放在TransactionalMessageManagementService中:@Slf4j @RequiredArgsConstructor @Service public class TransactionalMessageManagementService { private final TransactionalMessageDao messageDao; private final TransactionalMessageContentDao contentDao; private final RabbitTemplate rabbitTemplate; private static final LocalDateTime END = LocalDateTime.of(2999, 1, 1, 0, 0, 0); private static final long DEFAULT_INIT_BACKOFF = 10L; private static final int DEFAULT_BACKOFF_FACTOR = 2; private static final int DEFAULT_MAX_RETRY_TIMES = 5; private static final int LIMIT = 100; public void saveTransactionalMessageRecord(TransactionalMessage record, String content) { record.setMessageStatus(TxMessageStatus.PENDING.getStatus()); record.setNextScheduleTime(calculateNextScheduleTime(LocalDateTime.now(), DEFAULT_INIT_BACKOFF, DEFAULT_BACKOFF_FACTOR, 0)); record.setCurrentRetryTimes(0); record.setInitBackoff(DEFAULT_INIT_BACKOFF); record.setBackoffFactor(DEFAULT_BACKOFF_FACTOR); record.setMaxRetryTimes(DEFAULT_MAX_RETRY_TIMES); messageDao.insertSelective(record); TransactionalMessageContent messageContent = new TransactionalMessageContent(); messageContent.setContent(content); messageContent.setMessageId(record.getId()); contentDao.insert(messageContent); public void sendMessageSync(TransactionalMessage record, String content) { try { rabbitTemplate.convertAndSend(record.getExchangeName(), record.getRoutingKey(), content); if (log.isDebugEnabled()) { log.debug("发送消息成功,目标队列:{},消息内容:{}", record.getQueueName(), content); // 标记成功 markSuccess(record); } catch (Exception e) { // 标记失败 markFail(record, e); private void markSuccess(TransactionalMessage record) { // 标记下一次执行时间为最大值 record.setNextScheduleTime(END); record.setCurrentRetryTimes(record.getCurrentRetryTimes().compareTo(record.getMaxRetryTimes()) >= 0 ? record.getMaxRetryTimes() : record.getCurrentRetryTimes() + 1); record.setMessageStatus(TxMessageStatus.SUCCESS.getStatus()); record.setEditTime(LocalDateTime.now()); messageDao.updateStatusSelective(record); private void markFail(TransactionalMessage record, Exception e) { log.error("发送消息失败,目标队列:{}", record.getQueueName(), e); record.setCurrentRetryTimes(record.getCurrentRetryTimes().compareTo(record.getMaxRetryTimes()) >= 0 ? record.getMaxRetryTimes() : record.getCurrentRetryTimes() + 1); // 计算下一次的执行时间 LocalDateTime nextScheduleTime = calculateNextScheduleTime( record.getNextScheduleTime(), record.getInitBackoff(), record.getBackoffFactor(), record.getCurrentRetryTimes() record.setNextScheduleTime(nextScheduleTime); record.setMessageStatus(TxMessageStatus.FAIL.getStatus()); record.setEditTime(LocalDateTime.now()); messageDao.updateStatusSelective(record); * 计算下一次执行时间 * @param base 基础时间 * @param initBackoff 退避基准值 * @param backoffFactor 退避指数 * @param round 轮数 * @return LocalDateTime private LocalDateTime calculateNextScheduleTime(LocalDateTime base, long initBackoff, long backoffFactor, long round) { double delta = initBackoff * Math.pow(backoffFactor, round); return base.plusSeconds((long) delta); * 推送补偿 - 里面的参数应该根据实际场景定制 public void processPendingCompensationRecords() { // 时间的右值为当前时间减去退避初始值,这里预防把刚保存的消息也推送了 LocalDateTime max = LocalDateTime.now().plusSeconds(-DEFAULT_INIT_BACKOFF); // 时间的左值为右值减去1小时 LocalDateTime min = max.plusHours(-1); Map<Long, TransactionalMessage> collect = messageDao.queryPendingCompensationRecords(min, max, LIMIT) .stream() .collect(Collectors.toMap(TransactionalMessage::getId, x -> x)); if (!collect.isEmpty()) { StringJoiner joiner = new StringJoiner(",", "(", ")"); collect.keySet().forEach(x -> joiner.add(x.toString())); contentDao.queryByMessageIds(joiner.toString()) .forEach(item -> { TransactionalMessage message = collect.get(item.getMessageId()); sendMessageSync(message, item.getContent()); 复制代码这里有一点尚待优化:更新事务消息记录状态的方法可以优化为批量更新,在limit比较大的时候,批量更新的效率会更高。最后是定时任务的配置类:@Slf4j @RequiredArgsConstructor @Configuration @EnableScheduling public class ScheduleJobAutoConfiguration { private final TransactionalMessageManagementService managementService; * 这里用的是本地的Redis,实际上要做成配置 private final RedissonClient redisson = Redisson.create(); @Scheduled(fixedDelay = 10000) public void transactionalMessageCompensationTask() throws Exception { RLock lock = redisson.getLock("transactionalMessageCompensationTask"); // 等待时间5秒,预期300秒执行完毕,这两个值需要按照实际场景定制 boolean tryLock = lock.tryLock(5, 300, TimeUnit.SECONDS); if (tryLock) { try { long start = System.currentTimeMillis(); log.info("开始执行事务消息推送补偿定时任务..."); managementService.processPendingCompensationRecords(); long end = System.currentTimeMillis(); long delta = end - start; // 以防锁过早释放 if (delta < 5000) { Thread.sleep(5000 - delta); log.info("执行事务消息推送补偿定时任务完毕,耗时:{} ms...", end - start); } finally { lock.unlock(); 复制代码基本代码编写完,整个项目的结构如下:最后添加两个测试类:@RequiredArgsConstructor @Component public class MockBusinessRunner implements CommandLineRunner { private final MockBusinessService mockBusinessService; @Override public void run(String... args) throws Exception { mockBusinessService.saveOrder(); @Slf4j @RequiredArgsConstructor @Service public class MockBusinessService { private final JdbcTemplate jdbcTemplate; private final TransactionalMessageService transactionalMessageService; private final ObjectMapper objectMapper; @Transactional(rollbackFor = Exception.class) public void saveOrder() throws Exception { String orderId = UUID.randomUUID().toString(); BigDecimal amount = BigDecimal.valueOf(100L); Map<String, Object> message = new HashMap<>(); message.put("orderId", orderId); message.put("amount", amount); jdbcTemplate.update("INSERT INTO t_order(order_id,amount) VALUES (?,?)", p -> { p.setString(1, orderId); p.setBigDecimal(2, amount); String content = objectMapper.writeValueAsString(message); transactionalMessageService.sendTransactionalMessage( DefaultDestination.builder() .exchangeName("tm.test.exchange") .queueName("tm.test.queue") .routingKey("tm.test.key") .exchangeType(ExchangeType.DIRECT) .build(), DefaultTxMessage.builder() .businessKey(orderId) .businessModule("SAVE_ORDER") .content(content) .build() log.info("保存订单:{}成功...", orderId); 复制代码某次测试结果如下:2020-02-05 21:10:13.287 INFO 49556 --- [ main] club.throwable.cm.MockBusinessService : 保存订单:07a75323-460b-42cb-aa63-1a0a45ce19bf成功... 复制代码模拟订单数据成功保存,而且RabbitMQ消息在事务成功提交后正常发送到RabbitMQ服务端中,如RabbitMQ控制台数据所示。小结事务消息模块的设计仅仅是使异步消息推送这个功能实现趋向于完备,其实一个合理的异步消息交互系统,一定会提供同步查询接口,这一点是基于异步消息没有回调或者没有响应的特性导致的。一般而言,一个系统的吞吐量和系统的异步化处理占比成正相关(这一点可以参考Amdahl's Law),所以在系统架构设计实际中应该尽可能使用异步交互,提高系统吞吐量同时减少同步阻塞带来的无谓等待。事务消息模块可以扩展出一个后台管理,甚至可以配合Micrometer、Prometheus和Grafana体系做实时数据监控。本文demo项目仓库:rabbit-transactional-messagedemo必须本地安装MySQL、Redis和RabbitMQ才能正常启动,本地必须新建一个数据库命名local。

一文彻底理解Redis序列化协议,你也可以编写Redis客户端(上)

前提最近学习Netty的时候想做一个基于Redis服务协议的编码解码模块,过程中顺便阅读了Redis服务序列化协议RESP,结合自己的理解对文档进行了翻译并且简单实现了RESP基于Java语言的解析。编写本文的使用使用的JDK版本为[8+]。RESP简介Redis客户端与Redis服务端基于一个称作RESP的协议进行通信,RESP全称为Redis Serialization Protocol,也就是Redis序列化协议。虽然RESP为Redis设计,但是它也可以应用在其他客户端-服务端(Client-Server)的软件项目中。RESP在设计的时候折中考虑了如下几点:易于实现。快速解析。可读性高。RESP可以序列化不同的数据类型,如整型、字符串、数组还有一种特殊的Error类型。需要执行的Redis命令会封装为类似于字符串数组的请求然后通过Redis客户端发送到Redis服务端。Redis服务端会基于特定的命令类型选择对应的一种数据类型进行回复(这一句是意译,原文是:Redis replies with a command-specific data type)。RESP是二进制安全的(binary-safe),并且在RESP下不需要处理从一个进程传输到另一个进程的批量数据,因为它使用了前缀长度(prefixed-length,后面会分析,就是在每个数据块的前缀已经定义好数据块的个数,类似于Netty里面的定长编码解码)来传输批量数据。注意:此处概述的协议仅仅使用在客户端-服务端通信,Redis Cluster使用不同的二进制协议在多个节点之间交换消息(也就是Redis集群中的节点之间并不使用RESP通信)。网络层Redis客户端通过创建一个在6379端口的TCP连接,连接到Redis服务端。虽然RESP在底层通信协议技术上是非TCP特定的,但在Redis的上下文中,RESP仅用于TCP连接(或类似的面向流的连接,如Unix套接字)。请求-响应模型Redis服务端接收由不同参数组成的命令,接收到命令并将其处理之后会把回复发送回Redis客户端。这是最简单的模型,但是有两种例外的情况:Redis支持管道(Pipelining,流水线,多数情况下习惯称为管道)操作。使用管道的情况下,Redis客户端可以一次发送多个命令,然后等待一次性的回复(文中的回复是replies,理解为Redis服务端会一次性返回一个批量回复结果)。当Redis客户端订阅Pub/Sub信道时,该协议会更改语义并成为推送协议(push protocol),也就是说,客户端不再需要发送命令,因为Redis服务端将自动向客户端(订阅了改信道的客户端)发送新消息(这里的意思是:在订阅/发布模式下,消息是由Redis服务端主动推送给订阅了特定信道的Redis客户端)。除了上述两个特例之外,Redis协议是一种简单的请求-响应协议。RESP支持的数据类型RESP在Redis 1.2中引入,在Redis 2.0,RESP正式成为与Redis服务端通信的标准方案。也就是如果需要编写Redis客户端,你就必须在客户端中实现此协议。RESP本质上是一种序列化协议,它支持的数据类型如下:单行字符串、错误消息、整型数字、定长字符串和RESP数组。RESP在Redis中用作请求-响应协议的方式如下:Redis客户端将命令封装为RESP的数组类型(数组元素都是定长字符串类型,注意这一点,很重要)发送到Redis服务器。Redis服务端根据命令实现选择对应的RESP数据类型之一进行回复。在RESP中,数据类型取决于数据报的第一个字节:单行字符串的第一个字节为+。错误消息的第一个字节为-。整型数字的第一个字节为:。定长字符串的第一个字节为$。RESP数组的第一个字节为*。另外,在RESP中可以使用定长字符串或者数组的特殊变体来表示Null值,后面会提及。在RESP中,协议的不同部分始终以\r\n(CRLF)终止。目前RESP中5种数据类型的小结如下:数据类型本文翻译名称基本特征例子Simple String单行字符串第一个字节是+,最后两个字节是\r\n,其他字节是字符串内容+OK\r\nError错误消息第一个字节是-,最后两个字节是\r\n,其他字节是异常消息的文本内容-ERR\r\nInteger整型数字第一个字节是:,最后两个字节是\r\n,其他字节是数字的文本内容:100\r\nBulk String定长字符串第一个字节是$,紧接着的字节是内容字符串长度\r\n,最后两个字节是\r\n,其他字节是字符串内容$4\r\ndoge\r\nArrayRESP数组第一个字节是*,紧接着的字节是元素个数\r\n,最后两个字节是\r\n,其他字节是各个元素的内容,每个元素可以是任意一种数据类型*2\r\n:100\r\n$4\r\ndoge\r\n下面的小节是对每种数据类型的更细致的分析。RESP简单字符串-Simple String简单字符串的编码方式如下:(1)第一个字节为+。(2)紧接着的是一个不能包含CR或者LF字符的字符串。(3)以CRLF终止。简单字符串能够保证在最小开销的前提下传输非二进制安全的字符串。例如很多Redis命令执行成功后服务端需要回复OK字符串,此时通过简单字符串编码为5字节的数据报如下:+OK\r\n 复制代码如果需要发送二进制安全的字符串,那么需要使用定长字符串。当Redis服务端用简单字符串响应时,Redis客户端库应该向调用者返回一个字符串,该响应到调用者的字符串由+之后直到字符串内容末尾的字符组成(其实就是上面提到的第(2)部分的内容),不包括最后的CRLF字节。RESP错误消息-Error错误消息类型是RESP特定的数据类型。实际上,错误消息类型和简单字符串类型基本一致,只是其第一个字节为-。错误消息类型跟简单字符串类型的最大区别是:错误消息作为Redis服务端响应的时候,对于客户端而言应该感知为异常,而错误消息中的字符串内容应该感知为Redis服务端返回的错误信息。错误消息的编码方式如下:(1)第一个字节为-。(2)紧接着的是一个不能包含CR或者LF字符的字符串。(3)以CRLF终止。一个简单的例子如下:-Error message\r\n 复制代码Redis服务端只有在真正发生错误或者感知错误的时候才会回复错误消息,例如尝试对错误的数据类型执行操作或者命令不存在等等。Redis客户端接收到错误消息的时候,应该触发异常(一般情况就是直接抛出异常,可以根据错误消息的内容进行异常分类)。下面是错误消息响应的一些例子:-ERR unknown command 'foobar' -WRONGTYPE Operation against a key holding the wrong kind of value 复制代码-之后的第一个单词到第一个空格或换行符之间的内容,代表返回的错误类型。这只是Redis使用的约定,不是RESP错误消息格式的一部分。例如,ERR是通用错误,WRONGTYPE则是更具体的错误,表示客户端试图针对错误的数据类型执行操作。这种定义方式称为错误前缀,是一种使客户端能够理解服务器返回的错误类型的方法,而不必依赖于所给出的确切消息定义,该消息可能会随时间而变化。客户端实现可以针对不同的错误类型返回不同种类的异常,或者可以通过将错误类型的名称作为字符串直接提供给调用方来提供捕获错误的通用方法。但是,不应该将错误消息分类处理的功能视为至关重要的功能,因为它作用并不巨大,并且有些的客户端实现可能会简单地返回特定值去屏蔽错误消息作为通用的异常处理,例如直接返回false。RESP整型数字-Integer整型数字的编码方式如下:(1)第一个字节为:。(2)紧接着的是一个不能包含CR或者LF字符的字符串,也就是数字要先转换为字符序列,最终要输出为字节。(3)以CRLF终止。例如::0\r\n :1000\r\n 复制代码许多Redis命令返回整型数字,像INCR,LLEN和LASTSAVE命令等等。返回的整型数字没有特殊的含义,像INCR返回的是增量的总量,而LASTSAVE是UNIX时间戳。但是Redis服务端保证返回的整型数字在带符号的64位整数范围内。有些情况下,返回的整型数字会指代true或者false。如EXISTS或者SISMEMBER命令执行返回1代表true,0代表false。有些情况下,返回的整型数字会指代命令是否真正产生了效果。如SADD,SREM和SETNX命令执行返回1代表命令执行生效,0代表命令执行不生效(等价于命令没有执行)。下面的一组命令执行后都是返回整型数字:SETNX, DEL, EXISTS, INCR, INCRBY, DECR, DECRBY, DBSIZE, LASTSAVE, RENAMENX, MOVE, LLEN, SADD, SREM, SISMEMBER, SCARD。RESP定长字符串-Bulk String定长字符串用于表示一个最大长度为512MB的二进制安全的字符串(Bulk,本身有体积大的含义)。定长字符串的编码方式如下:(1)第一个字节为$。(2)紧接着的是组成字符串的字节数长度(称为prefixed length,也就是前缀长度),前缀长度分块以CRLF终止。(3)然后是一个不能包含CR或者LF字符的字符串,也就是数字要先转换为字符序列,最终要输出为字节。(4)以CRLF终止。举个例子,doge使用定长字符串编码如下:第一个字节前缀长度CRLF字符串内容CRLF定长字符串$4\r\ndoge\r\n===>$4\r\ndoge\r\nfoobar使用定长字符串编码如下:第一个字节前缀长度CRLF字符串内容CRLF定长字符串$6\r\nfoobar\r\n===>$6\r\nfoobar\r\n表示空字符串(Empty String,对应Java中的"") 的时候,使用定长字符串编码如下:第一个字节前缀长度CRLFCRLF定长字符串$0\r\n\r\n===>$0\r\n\r\n定长字符串也可以使用特殊的格式来表示Null值,指代值不存在。在这种特殊格式中,前缀长度为-1,并且没有数据,因此使用定长字符串对Null值进行编码如下:第一个字节前缀长度CRLF定长字符串$-1\r\n===>$-1\r\n当Redis服务端返回定长字符串编码的Null值的时候,客户端不应该返回空字符串,而应该返回对应编程语言中的Null对象。例如Ruby中对应nil,C语言中对应NULL,Java中对应null,以此类推。RESP数组-ArrayRedis客户端使用RESP数组发送命令到Redis服务端。与此相似,某些Redis命令执行完毕后服务端需要使用RESP数组类型将元素集合返回给客户端,如返回一个元素列表的LRANGE命令。RESP数组和我们认知中的数组并不完全一致,它的编码格式如下:(1)第一个字节为*。(2)紧接着的是组成RESP数组的元素个数(十进制数,但是最终需要转换为字节序列,如10需要转换为1和0两个相邻的字节),元素个数分块以CRLF终止。(3)RESP数组的每个元素内容,每个元素可以是任意的RESP数据类型。一个空的RESP数组的编码如下:*0\r\n 复制代码一个包含2个定长字符串元素内容分别为foo和bar的RESP数组的编码如下:*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n 复制代码通用格式就是:*<count>CRLF作为RESP数组的前缀部分,而组成RESP数组的其他数据类型的元素只是一个接一个地串联在一起。例如一个包含3个整数类型元素的RESP数组的编码如下:*3\r\n:1\r\n:2\r\n:3\r\n 复制代码RESP数组的元素不一定是同一种数据类型,可以包含混合类型的元素。例如下面是一个包含4个整数类型元素和1个定长字符串类型元素(一共有5个元素)的RESP数组的编码(为了看得更清楚,分多行进行编码,实际上不能这样做):# 元素个数 *5\r\n # 第1个整型类型的元素 :1\r\n # 第2个整型类型的元素 :2\r\n # 第3个整型类型的元素 :3\r\n # 第4个整型类型的元素 :4\r\n # 定长字符串类型的元素 $6\r\n foobar\r\n 复制代码Redis服务端响应报的首行*5\r\n定义了后面会紧跟着5个回复数据,然后每个回复数据分别作元素项,构成了用于传输的多元素定长回复(Multi Bulk Reply,感觉比较难翻译,这里的大概意思就是每个回复行都是整个回复报中的一个项)。这里可以类比为Java中的ArrayList(泛型擦除),有点类似于下面的伪代码:List encode = new ArrayList(); // 添加元素个数 encode.add(elementCount); encode.add(CRLF); // 添加第1个整型类型的元素 - 1 encode.add(':'); encode.add(1); encode.add(CRLF); // 添加第2个整型类型的元素 - 2 encode.add(':'); encode.add(2); encode.add(CRLF); // 添加第3个整型类型的元素 - 3 encode.add(':'); encode.add(3); encode.add(CRLF); // 添加第4个整型类型的元素 - 4 encode.add(':'); encode.add(4); encode.add(CRLF); // 添加定长字符串类型的元素 encode.add('$'); // 前缀长度 encode.add(6); // 字符串内容 encode.add("foobar"); encode.add(CRLF); 复制代码RESP数组中也存在Null值的概念,下面称为RESP Null Array。处于历史原因,RESP数组中采用了另一种特殊的编码格式定义Null值,区别于定长字符串中的Null值字符串。例如,BLPOP命令执行超时的时候,就会返回一个RESP Null Array类型的响应。RESP Null Array的编码如下:*-1\r\n 复制代码当Redis服务端的回复是RESP Null Array类型的时候,客户端应该返回一个Null对象,而不是一个空数组或者空列表。这一点比较重要,它是区分回复是空数组(也就是命令正确执行完毕,返回结果正常)或者其他原因(如BLPOP命令的超时等)的关键。RESP数组的元素也可以是RESP数组,下面是一个包含2个RESP数组类型的元素的RESP数组,编码如下(为了看得更清楚,分多行进行编码,实际上不能这样做):# 元素个数 *2\r\n # 第1个RESP数组元素 *3\r\n :1\r\n :2\r\n :3\r\n # 第2个RESP数组元素 *2\r\n +Foo\r\n -Bar\r\n 复制代码上面的RESP数组的包含2个RESP数组类型的元素,第1个RESP数组元素包含3个整型类型的元素,而第2个RESP数组元素包含1个简单字符串类型的元素和1个错误消息类型的元素。RESP数组中的Null元素RESP数组中的单个元素也有Null值的概念,下面称为Null元素。Redis服务端回复如果是RESP数组类型,并且RESP数组中存在Null元素,那么意味着元素丢失,绝对不能用空字符串替代。缺少指定键的前提下,当与GET模式选项一起使用时,SORT命令可能会发生这种情况。下面是一个包含Null元素的RESP数组的例子(为了看得更清楚,分多行进行编码,实际上不能这样做):*3\r\n $3\r\n foo\r\n $-1\r\n $3\r\n bar\r\n 复制代码RESP数组中的第2个元素是Null元素,客户端API最终返回的内容应该是:# Ruby ["foo",nil,"bar"] # Java ["foo",null,"bar"] 复制代码RESP其他相关内容主要包括:将命令发送到Redis服务端的示例。批量命令与管道。内联命令(Inline Commands)。其实文档中还有一节使用C语言编写高性能RESP解析器,这里不做翻译,因为掌握RESP的相关内容后,可以基于任何语言编写解析器。将命令发送到Redis服务端如果已经相对熟悉RESP中的序列化格式,那么编写Redis客户端类库就会变得很容易。我们可以进一步指定客户端和服务器之间的交互方式:Redis客户端向Redis服务端发送仅仅包含定长字符串类型元素的RESP数组。Redis服务端可以采用任意一种RESP数据类型向Redis客户端进行回复,具体的数据类型一般取决于命令类型。下面是典型的交互例子:Redis客户端发送命令LLEN mylist以获得KEY为mylist的长度,Redis服务端将以整数类型进行回复,如以下示例所示(C是客户端,S服务器),伪代码如下:C: *2\r\n C: $4\r\n C: LLEN\r\n C: $6\r\n C: mylist\r\n S: :48293\r\n 复制代码为了简单起见,我们使用换行符来分隔协议的不同部分(这里指上面的代码分行展示),但是实际交互的时候Redis客户端在发送*2\r\n$4\r\nLLEN\r\n$6\r\nmylist\r\n的时候是整体发送的。批量命令与管道Redis客户端可以使用相同的连接发送批量命令。Redis支持管道特性,因此Redis客户端可以通过一次写操作发送多个命令,而无需在发送下一个命令之前读取Redis服务端对上一个命令的回复。批量发送命令之后,所有的回复可以在最后得到(合并为一个回复)。更多相关信息可以查看Using pipelining to speedup Redis queries。内联命令有些场景下,我们可能只有telnet命令可以使用,在这种条件下,我们需要发送命令到Redis服务端。尽管Redis协议易于实现,但在交互式会话中并不理想,并且redis-cli有些情况下不一定可用。处于这类原因,Redis设计了一种专为人类设计的命令格式,称为内联命令(Inline Command格式。以下是服务器/客户端使用内联命令进行聊天的示例(S代表服务端,C代表客户端):C: PING S: +PONG 复制代码以下是使用内联命令返回整数的另一个示例:C: EXISTS somekey S: :0 复制代码基本上只需在telnet会话中编写以空格分隔的参数。由于除了统一的请求协议之外没有命令会以*开头,Redis能够检测到这种情况并解析输入的命令。

项目架构级别规约框架Archunit调研

背景最近在做一个新项目的时候引入了一个架构方面的需求,就是需要检查项目的编码规范、模块分类规范、类依赖规范等,刚好接触到,正好做个调研。很多时候,我们会制定项目的规范,例如:硬性规定项目包结构中service层不能引用controller层的类(这个例子有点极端)。硬性规定定义在controller包下的Controller类的类名称以"Controller"结尾,方法的入参类型命名以"Request"结尾,返回参数命名以"Response"结尾。枚举类型必须放在common.constant包下,以类名称Enum结尾。还有很多其他可能需要定制的规范,最终可能会输出一个文档。但是,谁能保证所有参数开发的人员都会按照文档的规范进行开发?为了保证规范的实行,Archunit以单元测试的形式通过扫描类路径(甚至Jar)包下的所有类,通过单元测试的形式对各个规范进行代码编写,如果项目代码中有违背对应的单测规范,那么单元测试将会不通过,这样就可以从CI/CD层面彻底把控项项目架构和编码规范。本文的编写日期是2019-02-16,当时Archunit的最新版本为0.9.3,使用JDK 8。简介Archunit是一个免费、简单、可扩展的类库,用于检查Java代码的体系结构。提供检查包和类的依赖关系、调用层次和切面的依赖关系、循环依赖检查等其他功能。它通过导入所有类的代码结构,基于Java字节码分析实现这一点。Archunit的主要关注点是使用任何普通的Java单元测试框架自动测试代码体系结构和编码规则。引入依赖一般来说,目前常用的测试框架是Junit4,需要引入Junit4和Archunit:<dependency> <groupId>com.tngtech.archunit</groupId> <artifactId>archunit</artifactId> <version>0.9.3</version> <scope>test</scope> </dependency> <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>4.12</version> <scope>test</scope> </dependency> 复制代码项目依赖slf4j,因此最好在测试依赖中引入一个slf4j的实现,例如logback:<dependency> <groupId>ch.qos.logback</groupId> <artifactId>logback-classic</artifactId> <version>1.2.3</version> <scope>test</scope> </dependency> 复制代码如何使用主要从下面的两个方面介绍一下的使用:指定参数进行类扫描。内建规则定义。指定参数进行类扫描需要对代码或者依赖规则进行判断前提是要导入所有需要分析的类,类扫描导入依赖于ClassFileImporter,底层依赖于ASM字节码框架针对类文件的字节码进行解析,性能会比基于反射的类扫描框架高很多。ClassFileImporter的构造可选参数为ImportOption(s),扫描规则可以通过ImportOption接口实现,默认提供可选的规则有:// 不包含测试类 ImportOption.Predefined.DONT_INCLUDE_TESTS // 不包含Jar包里面的类 ImportOption.Predefined.DONT_INCLUDE_JARS // 不包含Jar和Jrt包里面的类,JDK9的特性 ImportOption.Predefined.DONT_INCLUDE_ARCHIVES 复制代码举个例子,我们实现一个自定义的ImportOption实现,用于指定需要排除扫描的包路径:public class DontIncludePackagesImportOption implements ImportOption { private final Set<Pattern> EXCLUDED_PATTERN; public DontIncludePackagesImportOption(String... packages) { EXCLUDED_PATTERN = new HashSet<>(8); for (String eachPackage : packages) { EXCLUDED_PATTERN.add(Pattern.compile(String.format(".*/%s/.*", eachPackage.replace("/", ".")))); @Override public boolean includes(Location location) { for (Pattern pattern : EXCLUDED_PATTERN) { if (location.matches(pattern)) { return false; return true; 复制代码ImportOption接口只有一个方法:boolean includes(Location location) 复制代码其中,Location包含了路径信息、是否Jar文件等判断属性的元数据,方便使用正则表达式或者直接的逻辑判断。接着我们可以通过上面实现的DontIncludePackagesImportOption去构造ClassFileImporter实例:ImportOptions importOptions = new ImportOptions() // 不扫描jar包 .with(ImportOption.Predefined.DONT_INCLUDE_JARS) // 排除不扫描的包 .with(new DontIncludePackagesImportOption("com.sample..support")); ClassFileImporter classFileImporter = new ClassFileImporter(importOptions); 复制代码得到ClassFileImporter实例后我们可以通过对应的方法导入项目中的类:// 指定类型导入单个类 public JavaClass importClass(Class<?> clazz) // 指定类型导入多个类 public JavaClasses importClasses(Class<?>... classes) public JavaClasses importClasses(Collection<Class<?>> classes) // 通过指定路径导入类 public JavaClasses importUrl(URL url) public JavaClasses importUrls(Collection<URL> urls) public JavaClasses importLocations(Collection<Location> locations) // 通过类路径导入类 public JavaClasses importClasspath() public JavaClasses importClasspath(ImportOptions options) // 通过文件路径导入类 public JavaClasses importPath(String path) public JavaClasses importPath(Path path) public JavaClasses importPaths(String... paths) public JavaClasses importPaths(Path... paths) public JavaClasses importPaths(Collection<Path> paths) // 通过Jar文件对象导入类 public JavaClasses importJar(JarFile jar) public JavaClasses importJars(JarFile... jarFiles) public JavaClasses importJars(Iterable<JarFile> jarFiles) // 通过包路径导入类 - 这个是比较常用的方法 public JavaClasses importPackages(Collection<String> packages) public JavaClasses importPackages(String... packages) public JavaClasses importPackagesOf(Class<?>... classes) public JavaClasses importPackagesOf(Collection<Class<?>> classes) 复制代码导入类的方法提供了多维度的参数,用起来会十分便捷。例如想导入com.sample包下面的所有类,只需要这样:public class ClassFileImporterTest { @Test public void testImportBootstarpClass() throws Exception { ImportOptions importOptions = new ImportOptions() // 不扫描jar包 .with(ImportOption.Predefined.DONT_INCLUDE_JARS) // 排除不扫描的包 .with(new DontIncludePackagesImportOption("com.sample..support")); ClassFileImporter classFileImporter = new ClassFileImporter(importOptions); long start = System.currentTimeMillis(); JavaClasses javaClasses = classFileImporter.importPackages("com.sample"); long end = System.currentTimeMillis(); System.out.println(String.format("Found %d classes,cost %d ms", javaClasses.size(), end - start)); 复制代码得到的JavaClasses是JavaClass的集合,可以简单类比为反射中Class的集合,后面使用的代码规则和依赖规则判断都是强依赖于JavaClasses或者JavaClass。内建规则定义类扫描和类导入完成之后,我们需要定检查规则,然后应用于所有导入的类,这样子就能完成对所有的类进行规则的过滤 - 或者说把规则应用于所有类并且进行断言。规则定义依赖于ArchRuleDefinition类,创建出来的规则是ArchRule实例,规则实例的创建过程一般使用ArchRuleDefinition类的流式方法,这些流式方法定义上符合人类思考的思维逻辑,上手比较简单,举个例子:ArchRule archRule = ArchRuleDefinition.noClasses() // 在service包下的所有类 .that().resideInAPackage("..service..") // 不能调用controller包下的任意类 .should().accessClassesThat().resideInAPackage("..controller..") // 断言描述 - 不满足规则的时候打印出来的原因 .because("不能在service包中调用controller中的类"); // 对所有的JavaClasses进行判断 archRule.check(classes); 复制代码上面展示了自定义新的ArchRule的例子,中已经为我们内置了一些常用的ArchRule实现,它们位于GeneralCodingRules中:NO_CLASSES_SHOULD_ACCESS_STANDARD_STREAMS:不能调用System.out、System.err或者(Exception.)printStackTrace。NO_CLASSES_SHOULD_THROW_GENERIC_EXCEPTIONS:类不能直接抛出通用异常Throwable、Exception或者RuntimeException。NO_CLASSES_SHOULD_USE_JAVA_UTIL_LOGGING:不能使用java.util.logging包路径下的日志组件。更多内建的ArchRule或者通用的内置规则使用,可以参考官方例子。基本使用例子基本使用例子,主要从一些常见的编码规范或者项目规范编写规则对项目所有类进行检查。包依赖关系检查ArchRule archRule = ArchRuleDefinition.noClasses() .that().resideInAPackage("..com.source..") .should().dependOnClassesThat().resideInAPackage("..com.target.."); 复制代码ArchRule archRule = ArchRuleDefinition.classes() .that().resideInAPackage("..com.foo..") .should().onlyAccessClassesThat().resideInAnyPackage("..com.source..", "..com.foo.."); 复制代码类依赖关系检查ArchRule archRule = ArchRuleDefinition.classes() .that().haveNameMatching(".*Bar") .should().onlyBeAccessed().byClassesThat().haveSimpleName("Bar"); 复制代码类包含于包的关系检查ArchRule archRule = ArchRuleDefinition.classes() .that().haveSimpleNameStartingWith("Foo") .should().resideInAPackage("com.foo"); 复制代码继承关系检查ArchRule archRule = ArchRuleDefinition.classes() .that().implement(Collection.class) .should().haveSimpleNameEndingWith("Connection"); 复制代码ArchRule archRule = ArchRuleDefinition.classes() .that().areAssignableTo(EntityManager.class) .should().onlyBeAccessed().byAnyPackage("..persistence.."); 复制代码注解检查ArchRule archRule = ArchRuleDefinition.classes() .that().areAssignableTo(EntityManager.class) .should().onlyBeAccessed().byClassesThat().areAnnotatedWith(Transactional.class) 复制代码逻辑层调用关系检查例如项目结构如下:- com.myapp.controller SomeControllerOne.class SomeControllerTwo.class - com.myapp.service SomeServiceOne.class SomeServiceTwo.class - com.myapp.persistence SomePersistenceManager 复制代码例如我们规定:包路径com.myapp.controller中的类不能被其他层级包引用。包路径com.myapp.service中的类只能被com.myapp.controller中的类引用。包路径com.myapp.persistence中的类只能被com.myapp.service中的类引用。编写规则如下:layeredArchitecture() .layer("Controller").definedBy("..controller..") .layer("Service").definedBy("..service..") .layer("Persistence").definedBy("..persistence..") .whereLayer("Controller").mayNotBeAccessedByAnyLayer() .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller") .whereLayer("Persistence").mayOnlyBeAccessedByLayers("Service") 复制代码循环依赖关系检查例如项目结构如下:- com.myapp.moduleone ClassOneInModuleOne.class ClassTwoInModuleOne.class - com.myapp.moduletwo ClassOneInModuleTwo.class ClassTwoInModuleTwo.class - com.myapp.modulethree ClassOneInModuleThree.class ClassTwoInModuleThree.class 复制代码例如我们规定:com.myapp.moduleone、com.myapp.moduletwo和com.myapp.modulethree三个包路径中的类不能形成一个循环依赖缓,例如:ClassOneInModuleOne -> ClassOneInModuleTwo -> ClassOneInModuleThree -> ClassOneInModuleOne 复制代码编写规则如下:slices().matching("com.myapp.(*)..").should().beFreeOfCycles() 复制代码核心API把API分为三层,最重要的是"Core"层、"Lang"层和"Library"层。Core层APIArchUnit的Core层API大部分类似于Java原生反射API,例如JavaMethod和JavaField对应于原生反射中的Method和Field,它们提供了诸如getName()、getMethods()、getType()和getParameters()等方法。此外ArchUnit扩展一些API用于描述依赖代码之间关系,例如JavaMethodCall, JavaConstructorCall或JavaFieldAccess。还提供了例如Java类与其他Java类之间的导入访问关系的API如JavaClass#getAccessesFromSelf()。而需要导入类路径下或者Jar包中已经编译好的Java类,ArchUnit提供了ClassFileImporter完成此功能:JavaClasses classes = new ClassFileImporter().importPackages("com.mycompany.myapp"); 复制代码Lang层APICore层的API十分强大,提供了需要关于Java程序静态结构的信息,但是直接使用Core层的API对于单元测试会缺乏表现力,特别表现在架构规则方面。出于这个原因,ArchUnit提供了Lang层的API,它提供了一种强大的语法来以抽象的方式表达规则。Lang层的API大多数是采用流式编程方式定义方法,例如指定包定义和调用关系的规则如下:ArchRule rule = classes() // 定义在service包下的所欲类 .that().resideInAPackage("..service..") // 只能被controller包或者service包中的类访问 .should().onlyBeAccessed().byAnyPackage("..controller..", "..service.."); 复制代码编写好规则后就可以基于导入所有编译好的类进行扫描:JavaClasses classes = new ClassFileImporter().importPackage("com.myapp"); ArchRule rule = // 定义的规则 rule.check(classes); 复制代码Library层APILibrary层API通过静态工厂方法提供了更多复杂而强大的预定义规则,入口类是:com.tngtech.archunit.library.Architectures 复制代码目前,这只能为分层架构提供方便的检查,但将来可能会扩展为六边形架构\管道和过滤器,业务逻辑和技术基础架构的分离等样式。还有其他几个相对强大的功能:代码切片功能,入口是com.tngtech.archunit.library.dependencies.SlicesRuleDefinition。一般编码规则,入口是com.tngtech.archunit.library.GeneralCodingRules。PlantUML组件支持,功能位于包路径com.tngtech.archunit.library.plantuml下。编写复杂的规则一般来说,内建的规则不一定能够满足一些复杂的规范校验规则,因此需要编写自定义的规则。这里仅仅举一个前文提到的相对复杂的规则:定义在controller包下的Controller类的类名称以"Controller"结尾,方法的入参类型命名以"Request"结尾,返回参数命名以"Response"结尾。官方提供的自定义规则的例子如下:DescribedPredicate<JavaClass> haveAFieldAnnotatedWithPayload = new DescribedPredicate<JavaClass>("have a field annotated with @Payload"){ @Override public boolean apply(JavaClass input) { boolean someFieldAnnotatedWithPayload = // iterate fields and check for @Payload return someFieldAnnotatedWithPayload; ArchCondition<JavaClass> onlyBeAccessedBySecuredMethods = new ArchCondition<JavaClass>("only be accessed by @Secured methods") { @Override public void check(JavaClass item, ConditionEvents events) { for (JavaMethodCall call : item.getMethodCallsToSelf()) { if (!call.getOrigin().isAnnotatedWith(Secured.class)) { String message = String.format( "Method %s is not @Secured", call.getOrigin().getFullName()); events.add(SimpleConditionEvent.violated(call, message)); classes().that(haveAFieldAnnotatedWithPayload).should(onlyBeAccessedBySecuredMethods); 复制代码我们只需要模仿它的实现即可,具体如下:public class ArchunitTest { @Test public void controller_class_rule() { JavaClasses classes = new ClassFileImporter().importPackages("club.throwable"); DescribedPredicate<JavaClass> predicate = new DescribedPredicate<JavaClass>("定义在club.throwable.controller包下的所有类") { @Override public boolean apply(JavaClass input) { return null != input.getPackageName() && input.getPackageName().contains("club.throwable.controller"); ArchCondition<JavaClass> condition1 = new ArchCondition<JavaClass>("类名称以Controller结尾") { @Override public void check(JavaClass javaClass, ConditionEvents conditionEvents) { String name = javaClass.getName(); if (!name.endsWith("Controller")) { conditionEvents.add(SimpleConditionEvent.violated(javaClass, String.format("当前控制器类[%s]命名不以\"Controller\"结尾", name))); ArchCondition<JavaClass> condition2 = new ArchCondition<JavaClass>("方法的入参类型命名以\"Request\"结尾,返回参数命名以\"Response\"结尾") { @Override public void check(JavaClass javaClass, ConditionEvents conditionEvents) { Set<JavaMethod> javaMethods = javaClass.getMethods(); String className = javaClass.getName(); // 其实这里要做严谨一点需要考虑是否使用了泛型参数,这里暂时简化了 for (JavaMethod javaMethod : javaMethods) { Method method = javaMethod.reflect(); Class<?>[] parameterTypes = method.getParameterTypes(); for (Class parameterType : parameterTypes) { if (!parameterType.getName().endsWith("Request")) { conditionEvents.add(SimpleConditionEvent.violated(method, String.format("当前控制器类[%s]的[%s]方法入参不以\"Request\"结尾", className, method.getName()))); Class<?> returnType = method.getReturnType(); if (!returnType.getName().endsWith("Response")) { conditionEvents.add(SimpleConditionEvent.violated(method, String.format("当前控制器类[%s]的[%s]方法返回参数不以\"Response\"结尾", className, method.getName()))); ArchRuleDefinition.classes() .that(predicate) .should(condition1) .andShould(condition2) .because("定义在controller包下的Controller类的类名称以\"Controller\"结尾,方法的入参类型命名以\"Request\"结尾,返回参数命名以\"Response\"结尾") .check(classes); 复制代码因为导入了所有需要的编译好的类的静态属性,基本上是可以编写所有能够想出来的规约,更多的内容或者实现可以自行摸索。小结通过最近的一个项目引入了Archunit,并且进行了一些编码规范和架构规范的规约,起到了十分明显的效果。之前口头或者书面文档的规范可以通过单元测试直接控制,项目构建的时候强制必须执行单元测试,只有所有单测通过才能构建和打包(禁止使用-Dmaven.test.skip=true参数),起到了十分明显的成效。参考资料:User Guide

一个低级错误引发Netty编码解码中文异常

前言最近在调研Netty的使用,在编写编码解码模块的时候遇到了一个中文字符串编码和解码异常的情况,后来发现是笔者犯了个低级错误。这里做一个小小的回顾。错误重现在设计Netty的自定义协议的时候,发现了字符串类型的属性,一旦出现中文就会出现解码异常的现象,这个异常并不一定出现了Exception,而是出现了解码之后字符截断出现了人类不可读的字符。编码和解码器的实现如下:// 实体 @Data public class ChineseMessage implements Serializable { private long id; private String message; // 编码器 - <错误示范,不要拷贝> public class ChineseMessageEncoder extends MessageToByteEncoder<ChineseMessage> { @Override protected void encode(ChannelHandlerContext ctx, ChineseMessage target, ByteBuf out) throws Exception { // 写入ID out.writeLong(target.getId()); String message = target.getMessage(); int length = message.length(); // 写入Message长度 out.writeInt(length); // 写入Message字符序列 out.writeCharSequence(message, StandardCharsets.UTF_8); // 解码器 public class ChineseMessageDecoder extends ByteToMessageDecoder { @Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception { // 读取ID long id = in.readLong(); // 读取Message长度 int length = in.readInt(); // 读取Message字符序列 CharSequence charSequence = in.readCharSequence(length, StandardCharsets.UTF_8); ChineseMessage message = new ChineseMessage(); message.setId(id); message.setMessage(charSequence.toString()); out.add(message); 复制代码简单地编写客户端和服务端代码,然后用客户端服务端发送一条带中文的消息:// 服务端日志 接收到客户端的请求:ChineseMessage(id=1, message=张) io.netty.handler.codec.DecoderException: java.lang.IndexOutOfBoundsException: readerIndex(15) + length(8) exceeds writerIndex(21) ...... // 客户端日志 接收到服务端的响应:ChineseMessage(id=2, message=张) io.netty.handler.codec.DecoderException: java.lang.IndexOutOfBoundsException: readerIndex(15) + length(8) exceeds writerIndex(21) ...... 复制代码其实,问题就隐藏在编码解码模块中。由于笔者前两个月一直996,在疯狂编写CRUD代码,业余在看Netty的时候,有一些基础知识一时短路没有回忆起来。笔者带着这个问题在各大搜索引擎中搜索,有可能是姿势不对或者关键字不准,没有得到答案,加之,很多博客文章都是照搬其他人的Demo,而这些Demo里面恰好都是用英文编写消息体例子,所以这个问题一时陷入了困局(2019年国庆假期之前卡住了大概几天,业务忙也没有花时间去想)。灵光一现2019年国庆假期前夕,由于团队一直在赶进度做一个前后端不分离的CRUD后台管理系统,当时有几个同事在做一个页面的时候讨论一个乱码的问题。在他们讨论的过程中,无意蹦出了两个让笔者突然清醒的词语:乱码和UTF-8。笔者第一时间想到的是刚用Cnblogs的时候写过的一篇文章:《小伙子又乱码了吧-Java字符编码原理总结》(现在看起来标题起得挺二的)。当时有对字符编码的原理做过一些探究,想想有点惭愧,1年多前看过的东西差不多忘记得一干二净。直接说原因:UTF-8编码的中文,大部分情况下一个中文字符长度占据3个字节(3 byte,也就是32 x 3或者32 x 4个位),而Java中字符串长度的获取方法String#length()是返回String实例中的Char数组的长度。但是我们多数情况下会使用Netty的字节缓冲区ByteBuf,而ByteBuf读取字符序列的方法需要预先指定读取的长度ByteBuf#readCharSequence(int length, Charset charset);,因此,在编码的时候需要预先写入字符串序列的长度。但是有一个隐藏的问题是:ByteBuf#readCharSequence(int length, Charset charset)方法底层会创建一个length长度的byte数组作为缓冲区读取数据,由于UTF-8中1 char = 3 or 4 byte,因此ChineseMessageEncoder在写入字符序列长度的时候虽然字符个数是对的,但是每个字符总是丢失2个-3个byte的长度,而ChineseMessageDecoder在读取字符序列长度的时候总是读到一个比原来短的长度,也就是最终会拿到一个不完整或者错误的字符串序列。解决方案UTF-8编码的中文在大多数情况下占3个字节,在一些有生僻字的情况下可能占4个字节。可以暴力点直接让写入字节缓冲区的字符序列长度扩大三倍,只需修改编码器的代码:public class ChineseMessageEncoder extends MessageToByteEncoder<ChineseMessage> { @Override protected void encode(ChannelHandlerContext ctx, ChineseMessage target, ByteBuf out) throws Exception { // 写入ID out.writeLong(target.getId()); String message = target.getMessage(); int length = message.length() * 3; // <1> 直接扩大字节序列的预读长度 // 写入Message长度 out.writeInt(length); // 写入Message字符序列 out.writeCharSequence(message, StandardCharsets.UTF_8); 复制代码当然,这样做太暴力,硬编码的做法既不规范也不友好。其实Netty已经提供了内置的工具类io.netty.buffer.ByteBufUtil:// 获取UTF-8字符的最大字节序列长度 public static int utf8MaxBytes(CharSequence seq){} // 写入UTF-8字符序列,返回写入的字节长度 - 建议使用此方法 public static int writeUtf8(ByteBuf buf, CharSequence seq){} 复制代码我们可以先记录一下writerIndex,先写一个假的值(例如0),再使用ByteBufUtil#writeUtf8()写字符序列,然后根据返回的写入的字节长度,通过writerIndex覆盖之前写入的假值:public class ChineseMessageEncoder extends MessageToByteEncoder<ChineseMessage> { @Override protected void encode(ChannelHandlerContext ctx, ChineseMessage target, ByteBuf out) throws Exception { out.writeLong(target.getId()); String message = target.getMessage(); // 记录写入游标 int writerIndex = out.writerIndex(); // 预写入一个假的length out.writeInt(0); // 写入UTF-8字符序列 int length = ByteBufUtil.writeUtf8(out, message); // 覆盖length out.setInt(writerIndex, length); 复制代码至此,问题解决。如果遇到其他Netty编码解码问题,解决的思路是一致的。小结Netty学习过程中,编码解码占一半,网络协议知识和调优占另一半。Netty的源码很优秀,很有美感,阅读起来很舒适。Netty真好玩。附录引入依赖:<dependency> <groupId>io.netty</groupId> <artifactId>netty-all</artifactId> <version>4.1.41.Final</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.10</version> <scope>provided</scope> </dependency> 复制代码代码:// 实体 @Data public class ChineseMessage implements Serializable { private long id; private String message; // 编码器 public class ChineseMessageEncoder extends MessageToByteEncoder<ChineseMessage> { @Override protected void encode(ChannelHandlerContext ctx, ChineseMessage target, ByteBuf out) throws Exception { out.writeLong(target.getId()); String message = target.getMessage(); int writerIndex = out.writerIndex(); out.writeInt(0); int length = ByteBufUtil.writeUtf8(out, message); out.setInt(writerIndex, length); // 解码器 public class ChineseMessageDecoder extends ByteToMessageDecoder { @Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception { long id = in.readLong(); int length = in.readInt(); CharSequence charSequence = in.readCharSequence(length, StandardCharsets.UTF_8); ChineseMessage message = new ChineseMessage(); message.setId(id); message.setMessage(charSequence.toString()); out.add(message); // 客户端 @Slf4j public class ChineseNettyClient { public static void main(String[] args) throws Exception { EventLoopGroup workerGroup = new NioEventLoopGroup(); Bootstrap bootstrap = new Bootstrap(); try { bootstrap.group(workerGroup); bootstrap.channel(NioSocketChannel.class); bootstrap.option(ChannelOption.SO_KEEPALIVE, true); bootstrap.option(ChannelOption.TCP_NODELAY, Boolean.TRUE); bootstrap.handler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) throws Exception { ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(1024, 0, 4, 0, 4)); ch.pipeline().addLast(new LengthFieldPrepender(4)); ch.pipeline().addLast(new ChineseMessageEncoder()); ch.pipeline().addLast(new ChineseMessageDecoder()); ch.pipeline().addLast(new SimpleChannelInboundHandler<ChineseMessage>() { @Override protected void channelRead0(ChannelHandlerContext ctx, ChineseMessage message) throws Exception { log.info("接收到服务端的响应:{}", message); ChannelFuture future = bootstrap.connect("localhost", 9092).sync(); System.out.println("客户端启动成功..."); Channel channel = future.channel(); ChineseMessage message = new ChineseMessage(); message.setId(1L); message.setMessage("张大狗"); channel.writeAndFlush(message); future.channel().closeFuture().sync(); } finally { workerGroup.shutdownGracefully(); // 服务端 @Slf4j public class ChineseNettyServer { public static void main(String[] args) throws Exception { int port = 9092; ServerBootstrap bootstrap = new ServerBootstrap(); EventLoopGroup bossGroup = new NioEventLoopGroup(); EventLoopGroup workerGroup = new NioEventLoopGroup(); try { bootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) throws Exception { ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(1024, 0, 4, 0, 4)); ch.pipeline().addLast(new LengthFieldPrepender(4)); ch.pipeline().addLast(new ChineseMessageEncoder()); ch.pipeline().addLast(new ChineseMessageDecoder()); ch.pipeline().addLast(new SimpleChannelInboundHandler<ChineseMessage>() { @Override protected void channelRead0(ChannelHandlerContext ctx, ChineseMessage message) throws Exception { log.info("接收到客户端的请求:{}", message); ChineseMessage chineseMessage = new ChineseMessage(); chineseMessage.setId(message.getId() + 1L); chineseMessage.setMessage("张小狗"); ctx.writeAndFlush(chineseMessage); ChannelFuture future = bootstrap.bind(port).sync(); log.info("启动Server成功..."); future.channel().closeFuture().sync(); } finally { workerGroup.shutdownGracefully(); bossGroup.shutdownGracefully(); 复制代码链接Github Page:www.throwable.club/2019/10/03/…Coding Page:throwable.coding.me/2019/10/03/…

Redis高级客户端Lettuce详解(下)

高阶特性Lettuce有很多高阶使用特性,这里只列举个人认为常用的两点:配置客户端资源。使用连接池。更多其他特性可以自行参看官方文档。配置客户端资源客户端资源的设置与Lettuce的性能、并发和事件处理相关。线程池或者线程组相关配置占据客户端资源配置的大部分(EventLoopGroups和EventExecutorGroup),这些线程池或者线程组是连接程序的基础组件。一般情况下,客户端资源应该在多个Redis客户端之间共享,并且在不再使用的时候需要自行关闭。笔者认为,客户端资源是面向Netty的。注意:除非特别熟悉或者花长时间去测试调整下面提到的参数,否则在没有经验的前提下凭直觉修改默认值,有可能会踩坑。客户端资源接口是ClientResources,实现类是DefaultClientResources。构建DefaultClientResources实例:// 默认 ClientResources resources = DefaultClientResources.create(); // 建造器 ClientResources resources = DefaultClientResources.builder() .ioThreadPoolSize(4) .computationThreadPoolSize(4) .build() 复制代码使用:ClientResources resources = DefaultClientResources.create(); // 非集群 RedisClient client = RedisClient.create(resources, uri); // 集群 RedisClusterClient clusterClient = RedisClusterClient.create(resources, uris); // ...... client.shutdown(); clusterClient.shutdown(); // 关闭资源 resources.shutdown(); 复制代码客户端资源基本配置:属性描述默认值ioThreadPoolSizeI/O线程数Runtime.getRuntime().availableProcessors()computationThreadPoolSize任务线程数Runtime.getRuntime().availableProcessors()客户端资源高级配置:属性描述默认值eventLoopGroupProviderEventLoopGroup提供商-eventExecutorGroupProviderEventExecutorGroup提供商-eventBus事件总线DefaultEventBuscommandLatencyCollectorOptions命令延时收集器配置DefaultCommandLatencyCollectorOptionscommandLatencyCollector命令延时收集器DefaultCommandLatencyCollectorcommandLatencyPublisherOptions命令延时发布器配置DefaultEventPublisherOptionsdnsResolverDNS处理器JDK或者Netty提供reconnectDelay重连延时配置Delay.exponential()nettyCustomizerNetty自定义配置器-tracing轨迹记录器-非集群客户端RedisClient的属性配置:Redis非集群客户端RedisClient本身提供了配置属性方法:RedisClient client = RedisClient.create(uri); client.setOptions(ClientOptions.builder() .autoReconnect(false) .pingBeforeActivateConnection(true) .build()); 复制代码非集群客户端的配置属性列表:属性描述默认值pingBeforeActivateConnection连接激活之前是否执行PING命令falseautoReconnect是否自动重连truecancelCommandsOnReconnectFailure重连失败是否拒绝命令执行falsesuspendReconnectOnProtocolFailure底层协议失败是否挂起重连操作falserequestQueueSize请求队列容量2147483647(Integer#MAX_VALUE)disconnectedBehavior失去连接时候的行为DEFAULTsslOptionsSSL配置-socketOptionsSocket配置10 seconds Connection-Timeout, no keep-alive, no TCP noDelaytimeoutOptions超时配置-publishOnScheduler发布反应式信号数据的调度器使用I/O线程集群客户端属性配置:Redis集群客户端RedisClusterClient本身提供了配置属性方法:RedisClusterClient client = RedisClusterClient.create(uri); ClusterTopologyRefreshOptions topologyRefreshOptions = ClusterTopologyRefreshOptions.builder() .enablePeriodicRefresh(refreshPeriod(10, TimeUnit.MINUTES)) .enableAllAdaptiveRefreshTriggers() .build(); client.setOptions(ClusterClientOptions.builder() .topologyRefreshOptions(topologyRefreshOptions) .build()); 复制代码集群客户端的配置属性列表:属性描述默认值enablePeriodicRefresh是否允许周期性更新集群拓扑视图falserefreshPeriod更新集群拓扑视图周期60秒enableAdaptiveRefreshTrigger设置自适应更新集群拓扑视图触发器RefreshTrigger-adaptiveRefreshTriggersTimeout自适应更新集群拓扑视图触发器超时设置30秒refreshTriggersReconnectAttempts自适应更新集群拓扑视图触发重连次数5dynamicRefreshSources是否允许动态刷新拓扑资源truecloseStaleConnections是否允许关闭陈旧的连接truemaxRedirects集群重定向次数上限5validateClusterNodeMembership是否校验集群节点的成员关系true使用连接池引入连接池依赖commons-pool2:<dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> <version>2.7.0</version> </dependency 复制代码基本使用如下:@Test public void testUseConnectionPool() throws Exception { RedisURI redisUri = RedisURI.builder() .withHost("localhost") .withPort(6379) .withTimeout(Duration.of(10, ChronoUnit.SECONDS)) .build(); RedisClient redisClient = RedisClient.create(redisUri); GenericObjectPoolConfig poolConfig = new GenericObjectPoolConfig(); GenericObjectPool<StatefulRedisConnection<String, String>> pool = ConnectionPoolSupport.createGenericObjectPool(redisClient::connect, poolConfig); try (StatefulRedisConnection<String, String> connection = pool.borrowObject()) { RedisCommands<String, String> command = connection.sync(); SetArgs setArgs = SetArgs.Builder.nx().ex(5); command.set("name", "throwable", setArgs); String n = command.get("name"); log.info("Get value:{}", n); pool.close(); redisClient.shutdown(); 复制代码其中,同步连接的池化支持需要用ConnectionPoolSupport,异步连接的池化支持需要用AsyncConnectionPoolSupport(Lettuce5.1之后才支持)。几个常见的渐进式删除例子渐进式删除Hash中的域-属性:@Test public void testDelBigHashKey() throws Exception { // SCAN参数 ScanArgs scanArgs = ScanArgs.Builder.limit(2); // TEMP游标 ScanCursor cursor = ScanCursor.INITIAL; // 目标KEY String key = "BIG_HASH_KEY"; prepareHashTestData(key); log.info("开始渐进式删除Hash的元素..."); int counter = 0; MapScanCursor<String, String> result = COMMAND.hscan(key, cursor, scanArgs); // 重置TEMP游标 cursor = ScanCursor.of(result.getCursor()); cursor.setFinished(result.isFinished()); Collection<String> fields = result.getMap().values(); if (!fields.isEmpty()) { COMMAND.hdel(key, fields.toArray(new String[0])); counter++; } while (!(ScanCursor.FINISHED.getCursor().equals(cursor.getCursor()) && ScanCursor.FINISHED.isFinished() == cursor.isFinished())); log.info("渐进式删除Hash的元素完毕,迭代次数:{} ...", counter); private void prepareHashTestData(String key) throws Exception { COMMAND.hset(key, "1", "1"); COMMAND.hset(key, "2", "2"); COMMAND.hset(key, "3", "3"); COMMAND.hset(key, "4", "4"); COMMAND.hset(key, "5", "5"); 复制代码渐进式删除集合中的元素:@Test public void testDelBigSetKey() throws Exception { String key = "BIG_SET_KEY"; prepareSetTestData(key); // SCAN参数 ScanArgs scanArgs = ScanArgs.Builder.limit(2); // TEMP游标 ScanCursor cursor = ScanCursor.INITIAL; log.info("开始渐进式删除Set的元素..."); int counter = 0; ValueScanCursor<String> result = COMMAND.sscan(key, cursor, scanArgs); // 重置TEMP游标 cursor = ScanCursor.of(result.getCursor()); cursor.setFinished(result.isFinished()); List<String> values = result.getValues(); if (!values.isEmpty()) { COMMAND.srem(key, values.toArray(new String[0])); counter++; } while (!(ScanCursor.FINISHED.getCursor().equals(cursor.getCursor()) && ScanCursor.FINISHED.isFinished() == cursor.isFinished())); log.info("渐进式删除Set的元素完毕,迭代次数:{} ...", counter); private void prepareSetTestData(String key) throws Exception { COMMAND.sadd(key, "1", "2", "3", "4", "5"); 复制代码渐进式删除有序集合中的元素:@Test public void testDelBigZSetKey() throws Exception { // SCAN参数 ScanArgs scanArgs = ScanArgs.Builder.limit(2); // TEMP游标 ScanCursor cursor = ScanCursor.INITIAL; // 目标KEY String key = "BIG_ZSET_KEY"; prepareZSetTestData(key); log.info("开始渐进式删除ZSet的元素..."); int counter = 0; ScoredValueScanCursor<String> result = COMMAND.zscan(key, cursor, scanArgs); // 重置TEMP游标 cursor = ScanCursor.of(result.getCursor()); cursor.setFinished(result.isFinished()); List<ScoredValue<String>> scoredValues = result.getValues(); if (!scoredValues.isEmpty()) { COMMAND.zrem(key, scoredValues.stream().map(ScoredValue<String>::getValue).toArray(String[]::new)); counter++; } while (!(ScanCursor.FINISHED.getCursor().equals(cursor.getCursor()) && ScanCursor.FINISHED.isFinished() == cursor.isFinished())); log.info("渐进式删除ZSet的元素完毕,迭代次数:{} ...", counter); private void prepareZSetTestData(String key) throws Exception { COMMAND.zadd(key, 0, "1"); COMMAND.zadd(key, 0, "2"); COMMAND.zadd(key, 0, "3"); COMMAND.zadd(key, 0, "4"); COMMAND.zadd(key, 0, "5"); 复制代码在SpringBoot中使用Lettuce个人认为,spring-data-redis中的API封装并不是很优秀,用起来比较重,不够灵活,这里结合前面的例子和代码,在SpringBoot脚手架项目中配置和整合Lettuce。先引入依赖:<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>2.1.8.RELEASE</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>io.lettuce</groupId> <artifactId>lettuce-core</artifactId> <version>5.1.8.RELEASE</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.10</version> <scope>provided</scope> </dependency> </dependencies> 复制代码一般情况下,每个应用应该使用单个Redis客户端实例和单个连接实例,这里设计一个脚手架,适配单机、普通主从、哨兵和集群四种使用场景。对于客户端资源,采用默认的实现即可。对于Redis的连接属性,比较主要的有Host、Port和Password,其他可以暂时忽略。基于约定大于配置的原则,先定制一系列属性配置类(其实有些配置是可以完全共用,但是考虑到要清晰描述类之间的关系,这里拆分多个配置属性类和多个配置方法):@Data @ConfigurationProperties(prefix = "lettuce") public class LettuceProperties { private LettuceSingleProperties single; private LettuceReplicaProperties replica; private LettuceSentinelProperties sentinel; private LettuceClusterProperties cluster; @Data public class LettuceSingleProperties { private String host; private Integer port; private String password; @EqualsAndHashCode(callSuper = true) @Data public class LettuceReplicaProperties extends LettuceSingleProperties { @EqualsAndHashCode(callSuper = true) @Data public class LettuceSentinelProperties extends LettuceSingleProperties { private String masterId; @EqualsAndHashCode(callSuper = true) @Data public class LettuceClusterProperties extends LettuceSingleProperties { 复制代码配置类如下,主要使用@ConditionalOnProperty做隔离,一般情况下,很少有人会在一个应用使用一种以上的Redis连接场景:@RequiredArgsConstructor @Configuration @ConditionalOnClass(name = "io.lettuce.core.RedisURI") @EnableConfigurationProperties(value = LettuceProperties.class) public class LettuceAutoConfiguration { private final LettuceProperties lettuceProperties; @Bean(destroyMethod = "shutdown") public ClientResources clientResources() { return DefaultClientResources.create(); @Bean @ConditionalOnProperty(name = "lettuce.single.host") public RedisURI singleRedisUri() { LettuceSingleProperties singleProperties = lettuceProperties.getSingle(); return RedisURI.builder() .withHost(singleProperties.getHost()) .withPort(singleProperties.getPort()) .withPassword(singleProperties.getPassword()) .build(); @Bean(destroyMethod = "shutdown") @ConditionalOnProperty(name = "lettuce.single.host") public RedisClient singleRedisClient(ClientResources clientResources, @Qualifier("singleRedisUri") RedisURI redisUri) { return RedisClient.create(clientResources, redisUri); @Bean(destroyMethod = "close") @ConditionalOnProperty(name = "lettuce.single.host") public StatefulRedisConnection<String, String> singleRedisConnection(@Qualifier("singleRedisClient") RedisClient singleRedisClient) { return singleRedisClient.connect(); @Bean @ConditionalOnProperty(name = "lettuce.replica.host") public RedisURI replicaRedisUri() { LettuceReplicaProperties replicaProperties = lettuceProperties.getReplica(); return RedisURI.builder() .withHost(replicaProperties.getHost()) .withPort(replicaProperties.getPort()) .withPassword(replicaProperties.getPassword()) .build(); @Bean(destroyMethod = "shutdown") @ConditionalOnProperty(name = "lettuce.replica.host") public RedisClient replicaRedisClient(ClientResources clientResources, @Qualifier("replicaRedisUri") RedisURI redisUri) { return RedisClient.create(clientResources, redisUri); @Bean(destroyMethod = "close") @ConditionalOnProperty(name = "lettuce.replica.host") public StatefulRedisMasterSlaveConnection<String, String> replicaRedisConnection(@Qualifier("replicaRedisClient") RedisClient replicaRedisClient, @Qualifier("replicaRedisUri") RedisURI redisUri) { return MasterSlave.connect(replicaRedisClient, new Utf8StringCodec(), redisUri); @Bean @ConditionalOnProperty(name = "lettuce.sentinel.host") public RedisURI sentinelRedisUri() { LettuceSentinelProperties sentinelProperties = lettuceProperties.getSentinel(); return RedisURI.builder() .withPassword(sentinelProperties.getPassword()) .withSentinel(sentinelProperties.getHost(), sentinelProperties.getPort()) .withSentinelMasterId(sentinelProperties.getMasterId()) .build(); @Bean(destroyMethod = "shutdown") @ConditionalOnProperty(name = "lettuce.sentinel.host") public RedisClient sentinelRedisClient(ClientResources clientResources, @Qualifier("sentinelRedisUri") RedisURI redisUri) { return RedisClient.create(clientResources, redisUri); @Bean(destroyMethod = "close") @ConditionalOnProperty(name = "lettuce.sentinel.host") public StatefulRedisMasterSlaveConnection<String, String> sentinelRedisConnection(@Qualifier("sentinelRedisClient") RedisClient sentinelRedisClient, @Qualifier("sentinelRedisUri") RedisURI redisUri) { return MasterSlave.connect(sentinelRedisClient, new Utf8StringCodec(), redisUri); @Bean @ConditionalOnProperty(name = "lettuce.cluster.host") public RedisURI clusterRedisUri() { LettuceClusterProperties clusterProperties = lettuceProperties.getCluster(); return RedisURI.builder() .withHost(clusterProperties.getHost()) .withPort(clusterProperties.getPort()) .withPassword(clusterProperties.getPassword()) .build(); @Bean(destroyMethod = "shutdown") @ConditionalOnProperty(name = "lettuce.cluster.host") public RedisClusterClient redisClusterClient(ClientResources clientResources, @Qualifier("clusterRedisUri") RedisURI redisUri) { return RedisClusterClient.create(clientResources, redisUri); @Bean(destroyMethod = "close") @ConditionalOnProperty(name = "lettuce.cluster") public StatefulRedisClusterConnection<String, String> clusterConnection(RedisClusterClient clusterClient) { return clusterClient.connect(); 复制代码最后为了让IDE识别我们的配置,可以添加IDE亲缘性,/META-INF文件夹下新增一个文件spring-configuration-metadata.json,内容如下:{ "properties": [ "name": "lettuce.single", "type": "club.throwable.spring.lettuce.LettuceSingleProperties", "description": "单机配置", "sourceType": "club.throwable.spring.lettuce.LettuceProperties" "name": "lettuce.replica", "type": "club.throwable.spring.lettuce.LettuceReplicaProperties", "description": "主从配置", "sourceType": "club.throwable.spring.lettuce.LettuceProperties" "name": "lettuce.sentinel", "type": "club.throwable.spring.lettuce.LettuceSentinelProperties", "description": "哨兵配置", "sourceType": "club.throwable.spring.lettuce.LettuceProperties" "name": "lettuce.single", "type": "club.throwable.spring.lettuce.LettuceClusterProperties", "description": "集群配置", "sourceType": "club.throwable.spring.lettuce.LettuceProperties" 复制代码如果想IDE亲缘性做得更好,可以添加/META-INF/additional-spring-configuration-metadata.json进行更多细节定义。简单使用如下:@Slf4j @Component public class RedisCommandLineRunner implements CommandLineRunner { @Autowired @Qualifier("singleRedisConnection") private StatefulRedisConnection<String, String> connection; @Override public void run(String... args) throws Exception { RedisCommands<String, String> redisCommands = connection.sync(); redisCommands.setex("name", 5, "throwable"); log.info("Get value:{}", redisCommands.get("name")); // Get value:throwable 复制代码小结本文算是基于Lettuce的官方文档,对它的使用进行全方位的分析,包括主要功能、配置都做了一些示例,限于篇幅部分特性和配置细节没有分析。Lettuce已经被spring-data-redis接纳作为官方的Redis客户端驱动,所以值得信赖,它的一些API设计确实比较合理,扩展性高的同时灵活性也高。个人建议,基于Lettuce包自行添加配置到SpringBoot应用用起来会得心应手,毕竟RedisTemplate实在太笨重,而且还屏蔽了Lettuce一些高级特性和灵活的API。参考资料:Lettuce Reference Guide

Redis高级客户端Lettuce详解(上)

前提Lettuce是一个Redis的Java驱动包,初识她的时候是使用RedisTemplate的时候遇到点问题Debug到底层的一些源码,发现spring-data-redis的驱动包在某个版本之后替换为Lettuce。Lettuce翻译为生菜,没错,就是吃的那种生菜,所以它的Logo长这样:既然能被Spring生态所认可,Lettuce想必有过人之处,于是笔者花时间阅读她的官方文档,整理测试示例,写下这篇文章。编写本文时所使用的版本为Lettuce 5.1.8.RELEASE,SpringBoot 2.1.8.RELEASE,JDK [8,11]。超长警告:这篇文章断断续续花了两周完成,超过4万字.....Lettuce简介Lettuce是一个高性能基于Java编写的Redis驱动框架,底层集成了Project Reactor提供天然的反应式编程,通信框架集成了Netty使用了非阻塞IO,5.x版本之后融合了JDK1.8的异步编程特性,在保证高性能的同时提供了十分丰富易用的API,5.1版本的新特性如下:支持Redis的新增命令ZPOPMIN, ZPOPMAX, BZPOPMIN, BZPOPMAX。支持通过Brave模块跟踪Redis命令执行。支持Redis Streams。支持异步的主从连接。支持异步连接池。新增命令最多执行一次模式(禁止自动重连)。全局命令超时设置(对异步和反应式命令也有效)。......等等注意一点:Redis的版本至少需要2.6,当然越高越好,API的兼容性比较强大。只需要引入单个依赖就可以开始愉快地使用Lettuce:Maven<dependency> <groupId>io.lettuce</groupId> <artifactId>lettuce-core</artifactId> <version>5.1.8.RELEASE</version> </dependency> 复制代码Gradledependencies { compile 'io.lettuce:lettuce-core:5.1.8.RELEASE' 复制代码连接Redis单机、哨兵、集群模式下连接Redis需要一个统一的标准去表示连接的细节信息,在Lettuce中这个统一的标准是RedisURI。可以通过三种方式构造一个RedisURI实例:定制的字符串URI语法:RedisURI uri = RedisURI.create("redis://localhost/"); 复制代码使用建造器(RedisURI.Builder):RedisURI uri = RedisURI.builder().withHost("localhost").withPort(6379).build(); 复制代码直接通过构造函数实例化:RedisURI uri = new RedisURI("localhost", 6379, 60, TimeUnit.SECONDS); 复制代码定制的连接URI语法单机(前缀为redis://)格式:redis://[password@]host[:port][/databaseNumber][?[timeout=timeout[d|h|m|s|ms|us|ns]] 完整:redis://mypassword@127.0.0.1:6379/0?timeout=10s 简单:redis://localhost 复制代码单机并且使用SSL(前缀为rediss://) <== 注意后面多了个s格式:rediss://[password@]host[:port][/databaseNumber][?[timeout=timeout[d|h|m|s|ms|us|ns]] 完整:rediss://mypassword@127.0.0.1:6379/0?timeout=10s 简单:rediss://localhost 复制代码单机Unix Domain Sockets模式(前缀为redis-socket://)格式:redis-socket://path[?[timeout=timeout[d|h|m|s|ms|us|ns]][&_database=database_]] 完整:redis-socket:///tmp/redis?timeout=10s&_database=0 复制代码哨兵(前缀为redis-sentinel://)格式:redis-sentinel://[password@]host[:port][,host2[:port2]][/databaseNumber][?[timeout=timeout[d|h|m|s|ms|us|ns]]#sentinelMasterId 完整:redis-sentinel://mypassword@127.0.0.1:6379,127.0.0.1:6380/0?timeout=10s#mymaster 复制代码超时时间单位:d 天h 小时m 分钟s 秒钟ms 毫秒us 微秒ns 纳秒个人建议使用RedisURI提供的建造器,毕竟定制的URI虽然简洁,但是比较容易出现人为错误。鉴于笔者没有SSL和Unix Domain Socket的使用场景,下面不对这两种连接方式进行列举。基本使用Lettuce使用的时候依赖于四个主要组件:RedisURI:连接信息。RedisClient:Redis客户端,特殊地,集群连接有一个定制的RedisClusterClient。Connection:Redis连接,主要是StatefulConnection或者StatefulRedisConnection的子类,连接的类型主要由连接的具体方式(单机、哨兵、集群、订阅发布等等)选定,比较重要。RedisCommands:Redis命令API接口,基本上覆盖了Redis发行版本的所有命令,提供了同步(sync)、异步(async)、反应式(reative)的调用方式,对于使用者而言,会经常跟RedisCommands系列接口打交道。一个基本使用例子如下:@Test public void testSetGet() throws Exception { RedisURI redisUri = RedisURI.builder() // <1> 创建单机连接的连接信息 .withHost("localhost") .withPort(6379) .withTimeout(Duration.of(10, ChronoUnit.SECONDS)) .build(); RedisClient redisClient = RedisClient.create(redisUri); // <2> 创建客户端 StatefulRedisConnection<String, String> connection = redisClient.connect(); // <3> 创建线程安全的连接 RedisCommands<String, String> redisCommands = connection.sync(); // <4> 创建同步命令 SetArgs setArgs = SetArgs.Builder.nx().ex(5); String result = redisCommands.set("name", "throwable", setArgs); Assertions.assertThat(result).isEqualToIgnoringCase("OK"); result = redisCommands.get("name"); Assertions.assertThat(result).isEqualTo("throwable"); // ... 其他操作 connection.close(); // <5> 关闭连接 redisClient.shutdown(); // <6> 关闭客户端 复制代码注意:<5>:关闭连接一般在应用程序停止之前操作,一个应用程序中的一个Redis驱动实例不需要太多的连接(一般情况下只需要一个连接实例就可以,如果有多个连接的需要可以考虑使用连接池,其实Redis目前处理命令的模块是单线程,在客户端多个连接多线程调用理论上没有效果)。<6>:关闭客户端一般应用程序停止之前操作,如果条件允许的话,基于后开先闭原则,客户端关闭应该在连接关闭之后操作。APILettuce主要提供三种API:同步(sync):RedisCommands。异步(async):RedisAsyncCommands。反应式(reactive):RedisReactiveCommands。先准备好一个单机Redis连接备用:private static StatefulRedisConnection<String, String> CONNECTION; private static RedisClient CLIENT; @BeforeClass public static void beforeClass() { RedisURI redisUri = RedisURI.builder() .withHost("localhost") .withPort(6379) .withTimeout(Duration.of(10, ChronoUnit.SECONDS)) .build(); CLIENT = RedisClient.create(redisUri); CONNECTION = CLIENT.connect(); @AfterClass public static void afterClass() throws Exception { CONNECTION.close(); CLIENT.shutdown(); 复制代码Redis命令API的具体实现可以直接从StatefulRedisConnection实例获取,见其接口定义:public interface StatefulRedisConnection<K, V> extends StatefulConnection<K, V> { boolean isMulti(); RedisCommands<K, V> sync(); RedisAsyncCommands<K, V> async(); RedisReactiveCommands<K, V> reactive(); 复制代码值得注意的是,在不指定编码解码器RedisCodec的前提下,RedisClient创建的StatefulRedisConnection实例一般是泛型实例StatefulRedisConnection<String,String>,也就是所有命令API的KEY和VALUE都是String类型,这种使用方式能满足大部分的使用场景。当然,必要的时候可以定制编码解码器RedisCodec<K,V>。同步API先构建RedisCommands实例:private static RedisCommands<String, String> COMMAND; @BeforeClass public static void beforeClass() { COMMAND = CONNECTION.sync(); 复制代码基本使用:@Test public void testSyncPing() throws Exception { String pong = COMMAND.ping(); Assertions.assertThat(pong).isEqualToIgnoringCase("PONG"); @Test public void testSyncSetAndGet() throws Exception { SetArgs setArgs = SetArgs.Builder.nx().ex(5); COMMAND.set("name", "throwable", setArgs); String value = COMMAND.get("name"); log.info("Get value: {}", value); // Get value: throwable 复制代码同步API在所有命令调用之后会立即返回结果。如果熟悉Jedis的话,RedisCommands的用法其实和它相差不大。异步API先构建RedisAsyncCommands实例:private static RedisAsyncCommands<String, String> ASYNC_COMMAND; @BeforeClass public static void beforeClass() { ASYNC_COMMAND = CONNECTION.async(); 复制代码基本使用:@Test public void testAsyncPing() throws Exception { RedisFuture<String> redisFuture = ASYNC_COMMAND.ping(); log.info("Ping result:{}", redisFuture.get()); // Ping result:PONG 复制代码RedisAsyncCommands所有方法执行返回结果都是RedisFuture实例,而RedisFuture接口的定义如下:public interface RedisFuture<V> extends CompletionStage<V>, Future<V> { String getError(); boolean await(long timeout, TimeUnit unit) throws InterruptedException; 复制代码也就是,RedisFuture可以无缝使用Future或者JDK1.8中引入的CompletableFuture提供的方法。举个例子:@Test public void testAsyncSetAndGet1() throws Exception { SetArgs setArgs = SetArgs.Builder.nx().ex(5); RedisFuture<String> future = ASYNC_COMMAND.set("name", "throwable", setArgs); // CompletableFuture#thenAccept() future.thenAccept(value -> log.info("Set命令返回:{}", value)); // Future#get() future.get(); // Set命令返回:OK @Test public void testAsyncSetAndGet2() throws Exception { SetArgs setArgs = SetArgs.Builder.nx().ex(5); CompletableFuture<Void> result = (CompletableFuture<Void>) ASYNC_COMMAND.set("name", "throwable", setArgs) .thenAcceptBoth(ASYNC_COMMAND.get("name"), (s, g) -> { log.info("Set命令返回:{}", s); log.info("Get命令返回:{}", g); result.get(); // Set命令返回:OK // Get命令返回:throwable 复制代码如果能熟练使用CompletableFuture和函数式编程技巧,可以组合多个RedisFuture完成一些列复杂的操作。反应式APILettuce引入的反应式编程框架是Project Reactor,如果没有反应式编程经验可以先自行了解一下Project Reactor。构建RedisReactiveCommands实例:private static RedisReactiveCommands<String, String> REACTIVE_COMMAND; @BeforeClass public static void beforeClass() { REACTIVE_COMMAND = CONNECTION.reactive(); 复制代码根据Project Reactor,RedisReactiveCommands的方法如果返回的结果只包含0或1个元素,那么返回值类型是Mono,如果返回的结果包含0到N(N大于0)个元素,那么返回值是Flux。举个例子:@Test public void testReactivePing() throws Exception { Mono<String> ping = REACTIVE_COMMAND.ping(); ping.subscribe(v -> log.info("Ping result:{}", v)); Thread.sleep(1000); // Ping result:PONG @Test public void testReactiveSetAndGet() throws Exception { SetArgs setArgs = SetArgs.Builder.nx().ex(5); REACTIVE_COMMAND.set("name", "throwable", setArgs).block(); REACTIVE_COMMAND.get("name").subscribe(value -> log.info("Get命令返回:{}", value)); Thread.sleep(1000); // Get命令返回:throwable @Test public void testReactiveSet() throws Exception { REACTIVE_COMMAND.sadd("food", "bread", "meat", "fish").block(); Flux<String> flux = REACTIVE_COMMAND.smembers("food"); flux.subscribe(log::info); REACTIVE_COMMAND.srem("food", "bread", "meat", "fish").block(); Thread.sleep(1000); // meat // bread // fish 复制代码举个更加复杂的例子,包含了事务、函数转换等:@Test public void testReactiveFunctional() throws Exception { REACTIVE_COMMAND.multi().doOnSuccess(r -> { REACTIVE_COMMAND.set("counter", "1").doOnNext(log::info).subscribe(); REACTIVE_COMMAND.incr("counter").doOnNext(c -> log.info(String.valueOf(c))).subscribe(); }).flatMap(s -> REACTIVE_COMMAND.exec()) .doOnNext(transactionResult -> log.info("Discarded:{}", transactionResult.wasDiscarded())) .subscribe(); Thread.sleep(1000); // OK // Discarded:false 复制代码这个方法开启一个事务,先把counter设置为1,再将counter自增1。发布和订阅非集群模式下的发布订阅依赖于定制的连接StatefulRedisPubSubConnection,集群模式下的发布订阅依赖于定制的连接StatefulRedisClusterPubSubConnection,两者分别来源于RedisClient#connectPubSub()系列方法和RedisClusterClient#connectPubSub():非集群模式:// 可能是单机、普通主从、哨兵等非集群模式的客户端 RedisClient client = ... StatefulRedisPubSubConnection<String, String> connection = client.connectPubSub(); connection.addListener(new RedisPubSubListener<String, String>() { ... }); // 同步命令 RedisPubSubCommands<String, String> sync = connection.sync(); sync.subscribe("channel"); // 异步命令 RedisPubSubAsyncCommands<String, String> async = connection.async(); RedisFuture<Void> future = async.subscribe("channel"); // 反应式命令 RedisPubSubReactiveCommands<String, String> reactive = connection.reactive(); reactive.subscribe("channel").subscribe(); reactive.observeChannels().doOnNext(patternMessage -> {...}).subscribe() 复制代码集群模式:// 使用方式其实和非集群模式基本一致 RedisClusterClient clusterClient = ... StatefulRedisClusterPubSubConnection<String, String> connection = clusterClient.connectPubSub(); connection.addListener(new RedisPubSubListener<String, String>() { ... }); RedisPubSubCommands<String, String> sync = connection.sync(); sync.subscribe("channel"); // ... 复制代码这里用单机同步命令的模式举一个Redis键空间通知(Redis Keyspace Notifications)的例子:@Test public void testSyncKeyspaceNotification() throws Exception { RedisURI redisUri = RedisURI.builder() .withHost("localhost") .withPort(6379) // 注意这里只能是0号库 .withDatabase(0) .withTimeout(Duration.of(10, ChronoUnit.SECONDS)) .build(); RedisClient redisClient = RedisClient.create(redisUri); StatefulRedisConnection<String, String> redisConnection = redisClient.connect(); RedisCommands<String, String> redisCommands = redisConnection.sync(); // 只接收键过期的事件 redisCommands.configSet("notify-keyspace-events", "Ex"); StatefulRedisPubSubConnection<String, String> connection = redisClient.connectPubSub(); connection.addListener(new RedisPubSubAdapter<>() { @Override public void psubscribed(String pattern, long count) { log.info("pattern:{},count:{}", pattern, count); @Override public void message(String pattern, String channel, String message) { log.info("pattern:{},channel:{},message:{}", pattern, channel, message); RedisPubSubCommands<String, String> commands = connection.sync(); commands.psubscribe("__keyevent@0__:expired"); redisCommands.setex("name", 2, "throwable"); Thread.sleep(10000); redisConnection.close(); connection.close(); redisClient.shutdown(); // pattern:__keyevent@0__:expired,count:1 // pattern:__keyevent@0__:expired,channel:__keyevent@0__:expired,message:name 复制代码实际上,在实现RedisPubSubListener的时候可以单独抽离,尽量不要设计成匿名内部类的形式。事务和批量命令执行事务相关的命令就是WATCH、UNWATCH、EXEC、MULTI和DISCARD,在RedisCommands系列接口中有对应的方法。举个例子:// 同步模式 @Test public void testSyncMulti() throws Exception { COMMAND.multi(); COMMAND.setex("name-1", 2, "throwable"); COMMAND.setex("name-2", 2, "doge"); TransactionResult result = COMMAND.exec(); int index = 0; for (Object r : result) { log.info("Result-{}:{}", index, r); index++; // Result-0:OK // Result-1:OK 复制代码Redis的Pipeline也就是管道机制可以理解为把多个命令打包在一次请求发送到Redis服务端,然后Redis服务端把所有的响应结果打包好一次性返回,从而节省不必要的网络资源(最主要是减少网络请求次数)。Redis对于Pipeline机制如何实现并没有明确的规定,也没有提供特殊的命令支持Pipeline机制。Jedis中底层采用BIO(阻塞IO)通讯,所以它的做法是客户端缓存将要发送的命令,最后需要触发然后同步发送一个巨大的命令列表包,再接收和解析一个巨大的响应列表包。Pipeline在Lettuce中对使用者是透明的,由于底层的通讯框架是Netty,所以网络通讯层面的优化Lettuce不需要过多干预,换言之可以这样理解:Netty帮Lettuce从底层实现了Redis的Pipeline机制。但是,Lettuce的异步API也提供了手动Flush的方法:@Test public void testAsyncManualFlush() { // 取消自动flush ASYNC_COMMAND.setAutoFlushCommands(false); List<RedisFuture<?>> redisFutures = Lists.newArrayList(); int count = 5000; for (int i = 0; i < count; i++) { String key = "key-" + (i + 1); String value = "value-" + (i + 1); redisFutures.add(ASYNC_COMMAND.set(key, value)); redisFutures.add(ASYNC_COMMAND.expire(key, 2)); long start = System.currentTimeMillis(); ASYNC_COMMAND.flushCommands(); boolean result = LettuceFutures.awaitAll(10, TimeUnit.SECONDS, redisFutures.toArray(new RedisFuture[0])); Assertions.assertThat(result).isTrue(); log.info("Lettuce cost:{} ms", System.currentTimeMillis() - start); // Lettuce cost:1302 ms 复制代码上面只是从文档看到的一些理论术语,但是现实是骨感的,对比了下Jedis的Pipeline提供的方法,发现了Jedis的Pipeline执行耗时比较低:@Test public void testJedisPipeline() throws Exception { Jedis jedis = new Jedis(); Pipeline pipeline = jedis.pipelined(); int count = 5000; for (int i = 0; i < count; i++) { String key = "key-" + (i + 1); String value = "value-" + (i + 1); pipeline.set(key, value); pipeline.expire(key, 2); long start = System.currentTimeMillis(); pipeline.syncAndReturnAll(); log.info("Jedis cost:{} ms", System.currentTimeMillis() - start); // Jedis cost:9 ms 复制代码个人猜测Lettuce可能底层并非合并所有命令一次发送(甚至可能是单条发送),具体可能需要抓包才能定位。依此来看,如果真的有大量执行Redis命令的场景,不妨可以使用Jedis的Pipeline。注意:由上面的测试推断RedisTemplate的executePipelined()方法是假的Pipeline执行方法,使用RedisTemplate的时候请务必注意这一点。Lua脚本执行Lettuce中执行Redis的Lua命令的同步接口如下:public interface RedisScriptingCommands<K, V> { <T> T eval(String var1, ScriptOutputType var2, K... var3); <T> T eval(String var1, ScriptOutputType var2, K[] var3, V... var4); <T> T evalsha(String var1, ScriptOutputType var2, K... var3); <T> T evalsha(String var1, ScriptOutputType var2, K[] var3, V... var4); List<Boolean> scriptExists(String... var1); String scriptFlush(); String scriptKill(); String scriptLoad(V var1); String digest(V var1); 复制代码异步和反应式的接口方法定义差不多,不同的地方就是返回值类型,一般我们常用的是eval()、evalsha()和scriptLoad()方法。举个简单的例子:private static RedisCommands<String, String> COMMANDS; private static String RAW_LUA = "local key = KEYS[1]\n" + "local value = ARGV[1]\n" + "local timeout = ARGV[2]\n" + "redis.call('SETEX', key, tonumber(timeout), value)\n" + "local result = redis.call('GET', key)\n" + "return result;"; private static AtomicReference<String> LUA_SHA = new AtomicReference<>(); @Test public void testLua() throws Exception { LUA_SHA.compareAndSet(null, COMMANDS.scriptLoad(RAW_LUA)); String[] keys = new String[]{"name"}; String[] args = new String[]{"throwable", "5000"}; String result = COMMANDS.evalsha(LUA_SHA.get(), ScriptOutputType.VALUE, keys, args); log.info("Get value:{}", result); // Get value:throwable 复制代码高可用和分片为了Redis的高可用,一般会采用普通主从(Master/Replica,这里笔者称为普通主从模式,也就是仅仅做了主从复制,故障需要手动切换)、哨兵和集群。普通主从模式可以独立运行,也可以配合哨兵运行,只是哨兵提供自动故障转移和主节点提升功能。普通主从和哨兵都可以使用MasterSlave,通过入参包括RedisClient、编码解码器以及一个或者多个RedisURI获取对应的Connection实例。这里注意一点,MasterSlave中提供的方法如果只要求传入一个RedisURI实例,那么Lettuce会进行拓扑发现机制,自动获取Redis主从节点信息;如果要求传入一个RedisURI集合,那么对于普通主从模式来说所有节点信息是静态的,不会进行发现和更新。拓扑发现的规则如下:对于普通主从(Master/Replica)模式,不需要感知RedisURI指向从节点还是主节点,只会进行一次性的拓扑查找所有节点信息,此后节点信息会保存在静态缓存中,不会更新。对于哨兵模式,会订阅所有哨兵实例并侦听订阅/发布消息以触发拓扑刷新机制,更新缓存的节点信息,也就是哨兵天然就是动态发现节点信息,不支持静态配置。拓扑发现机制的提供API为TopologyProvider,需要了解其原理的可以参考具体的实现。对于集群(Cluster)模式,Lettuce提供了一套独立的API。另外,如果Lettuce连接面向的是非单个Redis节点,连接实例提供了数据读取节点偏好(ReadFrom)设置,可选值有:MASTER:只从Master节点中读取。MASTER_PREFERRED:优先从Master节点中读取。SLAVE_PREFERRED:优先从Slavor节点中读取。SLAVE:只从Slavor节点中读取。NEAREST:使用最近一次连接的Redis实例读取。普通主从模式假设现在有三个Redis服务形成树状主从关系如下:节点一:localhost:6379,角色为Master。节点二:localhost:6380,角色为Slavor,节点一的从节点。节点三:localhost:6381,角色为Slavor,节点二的从节点。首次动态节点发现主从模式的节点信息需要如下构建连接:@Test public void testDynamicReplica() throws Exception { // 这里只需要配置一个节点的连接信息,不一定需要是主节点的信息,从节点也可以 RedisURI uri = RedisURI.builder().withHost("localhost").withPort(6379).build(); RedisClient redisClient = RedisClient.create(uri); StatefulRedisMasterSlaveConnection<String, String> connection = MasterSlave.connect(redisClient, new Utf8StringCodec(), uri); // 只从从节点读取数据 connection.setReadFrom(ReadFrom.SLAVE); // 执行其他Redis命令 connection.close(); redisClient.shutdown(); 复制代码如果需要指定静态的Redis主从节点连接属性,那么可以这样构建连接:@Test public void testStaticReplica() throws Exception { List<RedisURI> uris = new ArrayList<>(); RedisURI uri1 = RedisURI.builder().withHost("localhost").withPort(6379).build(); RedisURI uri2 = RedisURI.builder().withHost("localhost").withPort(6380).build(); RedisURI uri3 = RedisURI.builder().withHost("localhost").withPort(6381).build(); uris.add(uri1); uris.add(uri2); uris.add(uri3); RedisClient redisClient = RedisClient.create(); StatefulRedisMasterSlaveConnection<String, String> connection = MasterSlave.connect(redisClient, new Utf8StringCodec(), uris); // 只从主节点读取数据 connection.setReadFrom(ReadFrom.MASTER); // 执行其他Redis命令 connection.close(); redisClient.shutdown(); 复制代码哨兵模式由于Lettuce自身提供了哨兵的拓扑发现机制,所以只需要随便配置一个哨兵节点的RedisURI实例即可:@Test public void testDynamicSentinel() throws Exception { RedisURI redisUri = RedisURI.builder() .withPassword("你的密码") .withSentinel("localhost", 26379) .withSentinelMasterId("哨兵Master的ID") .build(); RedisClient redisClient = RedisClient.create(); StatefulRedisMasterSlaveConnection<String, String> connection = MasterSlave.connect(redisClient, new Utf8StringCodec(), redisUri); // 只允许从从节点读取数据 connection.setReadFrom(ReadFrom.SLAVE); RedisCommands<String, String> command = connection.sync(); SetArgs setArgs = SetArgs.Builder.nx().ex(5); command.set("name", "throwable", setArgs); String value = command.get("name"); log.info("Get value:{}", value); // Get value:throwable 复制代码集群模式鉴于笔者对Redis集群模式并不熟悉,Cluster模式下的API使用本身就有比较多的限制,所以这里只简单介绍一下怎么用。先说几个特性:下面的API提供跨槽位(Slot)调用的功能:RedisAdvancedClusterCommands。RedisAdvancedClusterAsyncCommands。RedisAdvancedClusterReactiveCommands。静态节点选择功能:masters:选择所有主节点执行命令。slaves:选择所有从节点执行命令,其实就是只读模式。all nodes:命令可以在所有节点执行。集群拓扑视图动态更新功能:手动更新,主动调用RedisClusterClient#reloadPartitions()。后台定时更新。自适应更新,基于连接断开和MOVED/ASK命令重定向自动更新。Redis集群搭建详细过程可以参考官方文档,假设已经搭建好集群如下(192.168.56.200是笔者的虚拟机Host):192.168.56.200:7001 => 主节点,槽位0-5460。192.168.56.200:7002 => 主节点,槽位5461-10922。192.168.56.200:7003 => 主节点,槽位10923-16383。192.168.56.200:7004 => 7001的从节点。192.168.56.200:7005 => 7002的从节点。192.168.56.200:7006 => 7003的从节点。简单的集群连接和使用方式如下:@Test public void testSyncCluster(){ RedisURI uri = RedisURI.builder().withHost("192.168.56.200").build(); RedisClusterClient redisClusterClient = RedisClusterClient.create(uri); StatefulRedisClusterConnection<String, String> connection = redisClusterClient.connect(); RedisAdvancedClusterCommands<String, String> commands = connection.sync(); commands.setex("name",10, "throwable"); String value = commands.get("name"); log.info("Get value:{}", value); // Get value:throwable 复制代码节点选择:@Test public void testSyncNodeSelection() { RedisURI uri = RedisURI.builder().withHost("192.168.56.200").withPort(7001).build(); RedisClusterClient redisClusterClient = RedisClusterClient.create(uri); StatefulRedisClusterConnection<String, String> connection = redisClusterClient.connect(); RedisAdvancedClusterCommands<String, String> commands = connection.sync(); // commands.all(); // 所有节点 // commands.masters(); // 主节点 // 从节点只读 NodeSelection<String, String> replicas = commands.slaves(); NodeSelectionCommands<String, String> nodeSelectionCommands = replicas.commands(); // 这里只是演示,一般应该禁用keys *命令 Executions<List<String>> keys = nodeSelectionCommands.keys("*"); keys.forEach(key -> log.info("key: {}", key)); connection.close(); redisClusterClient.shutdown(); 复制代码定时更新集群拓扑视图(每隔十分钟更新一次,这个时间自行考量,不能太频繁):@Test public void testPeriodicClusterTopology() throws Exception { RedisURI uri = RedisURI.builder().withHost("192.168.56.200").withPort(7001).build(); RedisClusterClient redisClusterClient = RedisClusterClient.create(uri); ClusterTopologyRefreshOptions options = ClusterTopologyRefreshOptions .builder() .enablePeriodicRefresh(Duration.of(10, ChronoUnit.MINUTES)) .build(); redisClusterClient.setOptions(ClusterClientOptions.builder().topologyRefreshOptions(options).build()); StatefulRedisClusterConnection<String, String> connection = redisClusterClient.connect(); RedisAdvancedClusterCommands<String, String> commands = connection.sync(); commands.setex("name", 10, "throwable"); String value = commands.get("name"); log.info("Get value:{}", value); Thread.sleep(Integer.MAX_VALUE); connection.close(); redisClusterClient.shutdown(); 复制代码自适应更新集群拓扑视图:@Test public void testAdaptiveClusterTopology() throws Exception { RedisURI uri = RedisURI.builder().withHost("192.168.56.200").withPort(7001).build(); RedisClusterClient redisClusterClient = RedisClusterClient.create(uri); ClusterTopologyRefreshOptions options = ClusterTopologyRefreshOptions.builder() .enableAdaptiveRefreshTrigger( ClusterTopologyRefreshOptions.RefreshTrigger.MOVED_REDIRECT, ClusterTopologyRefreshOptions.RefreshTrigger.PERSISTENT_RECONNECTS .adaptiveRefreshTriggersTimeout(Duration.of(30, ChronoUnit.SECONDS)) .build(); redisClusterClient.setOptions(ClusterClientOptions.builder().topologyRefreshOptions(options).build()); StatefulRedisClusterConnection<String, String> connection = redisClusterClient.connect(); RedisAdvancedClusterCommands<String, String> commands = connection.sync(); commands.setex("name", 10, "throwable"); String value = commands.get("name"); log.info("Get value:{}", value); Thread.sleep(Integer.MAX_VALUE); connection.close(); redisClusterClient.shutdown(); 复制代码动态命令和自定义命令自定义命令是Redis命令有限集,不过可以更细粒度指定KEY、ARGV、命令类型、编码解码器和返回值类型,依赖于dispatch()方法:// 自定义实现PING方法 @Test public void testCustomPing() throws Exception { RedisURI redisUri = RedisURI.builder() .withHost("localhost") .withPort(6379) .withTimeout(Duration.of(10, ChronoUnit.SECONDS)) .build(); RedisClient redisClient = RedisClient.create(redisUri); StatefulRedisConnection<String, String> connect = redisClient.connect(); RedisCommands<String, String> sync = connect.sync(); RedisCodec<String, String> codec = StringCodec.UTF8; String result = sync.dispatch(CommandType.PING, new StatusOutput<>(codec)); log.info("PING:{}", result); connect.close(); redisClient.shutdown(); // PING:PONG // 自定义实现Set方法 @Test public void testCustomSet() throws Exception { RedisURI redisUri = RedisURI.builder() .withHost("localhost") .withPort(6379) .withTimeout(Duration.of(10, ChronoUnit.SECONDS)) .build(); RedisClient redisClient = RedisClient.create(redisUri); StatefulRedisConnection<String, String> connect = redisClient.connect(); RedisCommands<String, String> sync = connect.sync(); RedisCodec<String, String> codec = StringCodec.UTF8; sync.dispatch(CommandType.SETEX, new StatusOutput<>(codec), new CommandArgs<>(codec).addKey("name").add(5).addValue("throwable")); String result = sync.get("name"); log.info("Get value:{}", result); connect.close(); redisClient.shutdown(); // Get value:throwable 复制代码动态命令是基于Redis命令有限集,并且通过注解和动态代理完成一些复杂命令组合的实现。主要注解在io.lettuce.core.dynamic.annotation包路径下。简单举个例子:public interface CustomCommand extends Commands { // SET [key] [value] @Command("SET ?0 ?1") String setKey(String key, String value); // SET [key] [value] @Command("SET :key :value") String setKeyNamed(@Param("key") String key, @Param("value") String value); // MGET [key1] [key2] @Command("MGET ?0 ?1") List<String> mGet(String key1, String key2); * 方法名作为命令 @CommandNaming(strategy = CommandNaming.Strategy.METHOD_NAME) String mSet(String key1, String value1, String key2, String value2); @Test public void testCustomDynamicSet() throws Exception { RedisURI redisUri = RedisURI.builder() .withHost("localhost") .withPort(6379) .withTimeout(Duration.of(10, ChronoUnit.SECONDS)) .build(); RedisClient redisClient = RedisClient.create(redisUri); StatefulRedisConnection<String, String> connect = redisClient.connect(); RedisCommandFactory commandFactory = new RedisCommandFactory(connect); CustomCommand commands = commandFactory.getCommands(CustomCommand.class); commands.setKey("name", "throwable"); commands.setKeyNamed("throwable", "doge"); log.info("MGET ===> " + commands.mGet("name", "throwable")); commands.mSet("key1", "value1","key2", "value2"); log.info("MGET ===> " + commands.mGet("key1", "key2")); connect.close(); redisClient.shutdown(); // MGET ===> [throwable, doge] // MGET ===> [value1, value2]

使用Redis实现延时任务(二)

前提前一篇文章通过Redis的有序集合Sorted Set和调度框架Quartz实例一版简单的延时任务,但是有两个相对重要的问题没有解决:分片。监控。这篇文章的内容就是要完善这两个方面的功能。前置文章:使用Redis实现延时任务(一)。为什么需要分片这里重新贴一下查询脚本dequeue.lua的内容:-- 参考jesque的部分Lua脚本实现 local zset_key = KEYS[1] local hash_key = KEYS[2] local min_score = ARGV[1] local max_score = ARGV[2] local offset = ARGV[3] local limit = ARGV[4] -- TYPE命令的返回结果是{'ok':'zset'}这样子,这里利用next做一轮迭代 local status, type = next(redis.call('TYPE', zset_key)) if status ~= nil and status == 'ok' then if type == 'zset' then local list = redis.call('ZREVRANGEBYSCORE', zset_key, max_score, min_score, 'LIMIT', offset, limit) if list ~= nil and #list > 0 then -- unpack函数能把table转化为可变参数 redis.call('ZREM', zset_key, unpack(list)) local result = redis.call('HMGET', hash_key, unpack(list)) redis.call('HDEL', hash_key, unpack(list)) return result return nil 复制代码这个脚本一共用到了四个命令ZREVRANGEBYSCORE、ZREM、HMGET和HDEL(TYPE命令的时间复杂度可以忽略):命令时间复杂度参数说明ZREVRANGEBYSCOREO(log(N)+M)N是有序集合中的元素总数,M是返回的元素的数量ZREMO(M*log(N))N是有序集合中的元素总数,M是成功移除的元素的数量HMGETO(L)L是成功返回的域的数量HDELO(L)L是要删除的域的数量接下来需要结合场景和具体参数分析,假如在生产环境,有序集合的元素总量维持在10000每小时(也就是说业务量是每小时下单1万笔),由于查询Sorted Set和Hash的数据同时做了删除,那么30分钟内常驻在这两个集合中的数据有5000条,也就是上面表中的N = 5000。假设我们初步定义查询的LIMIT值为100,也就是上面的M值为100,假设Redis中每个操作单元的耗时简单认为是T,那么分析一下5000条数据处理的耗时:序号集合基数ZREVRANGEBYSCOREZREMHMGETHDEL15000log(5000T) + 100Tlog(5000T) * 100100T100T24900log(4900T) + 100Tlog(4900T) * 100100T100T34800log(4800T) + 100Tlog(4800T) * 100100T100T..................理论上,脚本用到的四个命令中,ZREM命令的耗时是最大的,而ZREVRANGEBYSCORE和ZREM的时间复杂度函数都是M * log(N),因此控制集合元素基数N对于降低Lua脚本运行的耗时是有一定帮助的。分片上面分析了dequeue.lua的时间复杂度,准备好的分片方案有两个:方案一:单Redis实例,对Sorted Set和Hash两个集合的数据进行分片。方案二:基于多个Redis实例(可以是哨兵或者集群),实施方案一的分片操作。为了简单起见,后面的例子中分片的数量(shardingCount)设计为2,生产中分片数量应该根据实际情况定制。预设使用长整型的用户ID字段userId取模进行分片,假定测试数据中的userId是均匀分布的。通用实体:@Data public class OrderMessage { private String orderId; private BigDecimal amount; private Long userId; private String timestamp; 复制代码延迟队列接口:public interface OrderDelayQueue { void enqueue(OrderMessage message); List<OrderMessage> dequeue(String min, String max, String offset, String limit, int index); List<OrderMessage> dequeue(int index); String enqueueSha(); String dequeueSha(); 复制代码单Redis实例分片单Redis实例分片比较简单,示意图如下:编写队列实现代码如下(部分参数写死,仅供参考,切勿照搬到生产中):@RequiredArgsConstructor @Component public class RedisOrderDelayQueue implements OrderDelayQueue, InitializingBean { private static final String MIN_SCORE = "0"; private static final String OFFSET = "0"; private static final String LIMIT = "10"; * 分片数量 private static final long SHARDING_COUNT = 2L; private static final String ORDER_QUEUE_PREFIX = "ORDER_QUEUE_"; private static final String ORDER_DETAIL_QUEUE_PREFIX = "ORDER_DETAIL_QUEUE_"; private static final String ENQUEUE_LUA_SCRIPT_LOCATION = "/lua/enqueue.lua"; private static final String DEQUEUE_LUA_SCRIPT_LOCATION = "/lua/dequeue.lua"; private static final AtomicReference<String> ENQUEUE_LUA_SHA = new AtomicReference<>(); private static final AtomicReference<String> DEQUEUE_LUA_SHA = new AtomicReference<>(); private final JedisProvider jedisProvider; @Override public void enqueue(OrderMessage message) { List<String> args = Lists.newArrayList(); args.add(message.getOrderId()); args.add(String.valueOf(System.currentTimeMillis())); args.add(message.getOrderId()); args.add(JSON.toJSONString(message)); List<String> keys = Lists.newArrayList(); long index = message.getUserId() % SHARDING_COUNT; keys.add(ORDER_QUEUE_PREFIX + index); keys.add(ORDER_DETAIL_QUEUE_PREFIX + index); try (Jedis jedis = jedisProvider.provide()) { jedis.evalsha(ENQUEUE_LUA_SHA.get(), keys, args); @Override public List<OrderMessage> dequeue(int index) { // 30分钟之前 String maxScore = String.valueOf(System.currentTimeMillis() - 30 * 60 * 1000); return dequeue(MIN_SCORE, maxScore, OFFSET, LIMIT, index); @SuppressWarnings("unchecked") @Override public List<OrderMessage> dequeue(String min, String max, String offset, String limit, int index) { List<String> args = new ArrayList<>(); args.add(min); args.add(max); args.add(offset); args.add(limit); List<OrderMessage> result = Lists.newArrayList(); List<String> keys = Lists.newArrayList(); keys.add(ORDER_QUEUE_PREFIX + index); keys.add(ORDER_DETAIL_QUEUE_PREFIX + index); try (Jedis jedis = jedisProvider.provide()) { List<String> eval = (List<String>) jedis.evalsha(DEQUEUE_LUA_SHA.get(), keys, args); if (null != eval) { for (String e : eval) { result.add(JSON.parseObject(e, OrderMessage.class)); return result; @Override public String enqueueSha() { return ENQUEUE_LUA_SHA.get(); @Override public String dequeueSha() { return DEQUEUE_LUA_SHA.get(); @Override public void afterPropertiesSet() throws Exception { // 加载Lua脚本 loadLuaScript(); private void loadLuaScript() throws Exception { try (Jedis jedis = jedisProvider.provide()) { ClassPathResource resource = new ClassPathResource(ENQUEUE_LUA_SCRIPT_LOCATION); String luaContent = StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8); String sha = jedis.scriptLoad(luaContent); ENQUEUE_LUA_SHA.compareAndSet(null, sha); resource = new ClassPathResource(DEQUEUE_LUA_SCRIPT_LOCATION); luaContent = StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8); sha = jedis.scriptLoad(luaContent); DEQUEUE_LUA_SHA.compareAndSet(null, sha); 复制代码消费者定时任务的实现如下:DisallowConcurrentExecution @Component public class OrderMessageConsumer implements Job { private static final Logger LOGGER = LoggerFactory.getLogger(OrderMessageConsumer.class); private static final AtomicInteger COUNTER = new AtomicInteger(); * 初始化业务线程池 private static final ExecutorService BUSINESS_WORKER_POOL = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors(), r -> { Thread thread = new Thread(r); thread.setDaemon(true); thread.setName("OrderMessageConsumerWorker-" + COUNTER.getAndIncrement()); return thread; @Autowired private OrderDelayQueue orderDelayQueue; @Override public void execute(JobExecutionContext context) throws JobExecutionException { // 这里为了简单起见,分片的下标暂时使用Quartz的任务执行上下文存放 int shardingIndex = context.getMergedJobDataMap().getInt("shardingIndex"); LOGGER.info("订单消息消费者定时任务开始执行,shardingIndex:[{}]...", shardingIndex); List<OrderMessage> dequeue = orderDelayQueue.dequeue(shardingIndex); if (null != dequeue) { final CountDownLatch latch = new CountDownLatch(1); BUSINESS_WORKER_POOL.execute(new ConsumeTask(latch, dequeue, shardingIndex)); try { latch.await(); } catch (InterruptedException ignore) { //ignore LOGGER.info("订单消息消费者定时任务执行完毕,shardingIndex:[{}]...", shardingIndex); @RequiredArgsConstructor private static class ConsumeTask implements Runnable { private final CountDownLatch latch; private final List<OrderMessage> messages; private final int shardingIndex; @Override public void run() { try { for (OrderMessage message : messages) { LOGGER.info("shardingIndex:[{}],处理订单消息,内容:{}", shardingIndex, JSON.toJSONString(message)); // 模拟耗时 TimeUnit.MILLISECONDS.sleep(50); } catch (Exception ignore) { } finally { latch.countDown(); 复制代码启动定时任务和写入测试数据的CommandLineRunner实现如下:@Component public class QuartzJobStartCommandLineRunner implements CommandLineRunner { @Autowired private Scheduler scheduler; @Autowired private JedisProvider jedisProvider; @Override public void run(String... args) throws Exception { int shardingCount = 2; // 准备测试数据 prepareOrderMessageData(shardingCount); for (ConsumerTask task : prepareConsumerTasks(shardingCount)) { scheduler.scheduleJob(task.getJobDetail(), task.getTrigger()); private void prepareOrderMessageData(int shardingCount) throws Exception { DateTimeFormatter f = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"); try (Jedis jedis = jedisProvider.provide()) { List<OrderMessage> messages = Lists.newArrayList(); for (int i = 0; i < 100; i++) { OrderMessage message = new OrderMessage(); message.setAmount(BigDecimal.valueOf(i)); message.setOrderId("ORDER_ID_" + i); message.setUserId((long) i); message.setTimestamp(LocalDateTime.now().format(f)); messages.add(message); for (OrderMessage message : messages) { // 30分钟前 Double score = Double.valueOf(String.valueOf(System.currentTimeMillis() - 30 * 60 * 1000)); long index = message.getUserId() % shardingCount; jedis.hset("ORDER_DETAIL_QUEUE_" + index, message.getOrderId(), JSON.toJSONString(message)); jedis.zadd("ORDER_QUEUE_" + index, score, message.getOrderId()); private List<ConsumerTask> prepareConsumerTasks(int shardingCount) { List<ConsumerTask> tasks = Lists.newArrayList(); for (int i = 0; i < shardingCount; i++) { JobDetail jobDetail = JobBuilder.newJob(OrderMessageConsumer.class) .withIdentity("OrderMessageConsumer-" + i, "DelayTask") .usingJobData("shardingIndex", i) .build(); Trigger trigger = TriggerBuilder.newTrigger() .withIdentity("OrderMessageConsumerTrigger-" + i, "DelayTask") .withSchedule(SimpleScheduleBuilder.simpleSchedule().withIntervalInSeconds(10).repeatForever()) .build(); tasks.add(new ConsumerTask(jobDetail, trigger)); return tasks; @Getter @RequiredArgsConstructor private static class ConsumerTask { private final JobDetail jobDetail; private final Trigger trigger; 复制代码启动应用,输出如下:2019-08-28 00:13:20.648 INFO 50248 --- [ main] c.t.s.s.NoneJdbcSpringApplication : Started NoneJdbcSpringApplication in 1.35 seconds (JVM running for 5.109) 2019-08-28 00:13:20.780 INFO 50248 --- [ryBean_Worker-1] c.t.s.sharding.OrderMessageConsumer : 订单消息消费者定时任务开始执行,shardingIndex:[0]... 2019-08-28 00:13:20.781 INFO 50248 --- [ryBean_Worker-2] c.t.s.sharding.OrderMessageConsumer : 订单消息消费者定时任务开始执行,shardingIndex:[1]... 2019-08-28 00:13:20.788 INFO 50248 --- [onsumerWorker-1] c.t.s.sharding.OrderMessageConsumer : shardingIndex:[1],处理订单消息,内容:{"amount":99,"orderId":"ORDER_ID_99","timestamp":"2019-08-28 00:13:20.657","userId":99} 2019-08-28 00:13:20.788 INFO 50248 --- [onsumerWorker-0] c.t.s.sharding.OrderMessageConsumer : shardingIndex:[0],处理订单消息,内容:{"amount":98,"orderId":"ORDER_ID_98","timestamp":"2019-08-28 00:13:20.657","userId":98} 2019-08-28 00:13:20.840 INFO 50248 --- [onsumerWorker-1] c.t.s.sharding.OrderMessageConsumer : shardingIndex:[1],处理订单消息,内容:{"amount":97,"orderId":"ORDER_ID_97","timestamp":"2019-08-28 00:13:20.657","userId":97} 2019-08-28 00:13:20.840 INFO 50248 --- [onsumerWorker-0] c.t.s.sharding.OrderMessageConsumer : shardingIndex:[0],处理订单消息,内容:{"amount":96,"orderId":"ORDER_ID_96","timestamp":"2019-08-28 00:13:20.657","userId":96} // ... 省略大量输出 2019-08-28 00:13:21.298 INFO 50248 --- [ryBean_Worker-1] c.t.s.sharding.OrderMessageConsumer : 订单消息消费者定时任务执行完毕,shardingIndex:[0]... 2019-08-28 00:13:21.298 INFO 50248 --- [ryBean_Worker-2] c.t.s.sharding.OrderMessageConsumer : 订单消息消费者定时任务执行完毕,shardingIndex:[1]... // ... 省略大量输出 复制代码多Redis实例分片单Redis实例分片其实存在一个问题,就是Redis实例总是单线程处理客户端的命令,即使客户端是多个线程执行Redis命令,示意图如下:这种情况下,虽然通过分片降低了Lua脚本命令的复杂度,但是Redis的命令处理模型(单线程)也有可能成为另一个性能瓶颈隐患。因此,可以考虑基于多Redis实例进行分片。这里为了简单起见,用两个单点的Redis实例做编码示例。代码如下:// Jedis提供者 @Component public class JedisProvider implements InitializingBean { private final Map<Long, JedisPool> pools = Maps.newConcurrentMap(); private JedisPool defaultPool; @Override public void afterPropertiesSet() throws Exception { JedisPool pool = new JedisPool("localhost"); defaultPool = pool; pools.put(0L, pool); // 这个是虚拟机上的redis实例 pool = new JedisPool("192.168.56.200"); pools.put(1L, pool); public Jedis provide(Long index) { return pools.getOrDefault(index, defaultPool).getResource(); // 订单消息 @Data public class OrderMessage { private String orderId; private BigDecimal amount; private Long userId; // 订单延时队列接口 public interface OrderDelayQueue { void enqueue(OrderMessage message); List<OrderMessage> dequeue(String min, String max, String offset, String limit, long index); List<OrderMessage> dequeue(long index); String enqueueSha(long index); String dequeueSha(long index); // 延时队列实现 @RequiredArgsConstructor @Component public class RedisOrderDelayQueue implements OrderDelayQueue, InitializingBean { private static final String MIN_SCORE = "0"; private static final String OFFSET = "0"; private static final String LIMIT = "10"; private static final long SHARDING_COUNT = 2L; private static final String ORDER_QUEUE = "ORDER_QUEUE"; private static final String ORDER_DETAIL_QUEUE = "ORDER_DETAIL_QUEUE"; private static final String ENQUEUE_LUA_SCRIPT_LOCATION = "/lua/enqueue.lua"; private static final String DEQUEUE_LUA_SCRIPT_LOCATION = "/lua/dequeue.lua"; private static final ConcurrentMap<Long, String> ENQUEUE_LUA_SHA = Maps.newConcurrentMap(); private static final ConcurrentMap<Long, String> DEQUEUE_LUA_SHA = Maps.newConcurrentMap(); private final JedisProvider jedisProvider; @Override public void enqueue(OrderMessage message) { List<String> args = Lists.newArrayList(); args.add(message.getOrderId()); args.add(String.valueOf(System.currentTimeMillis() - 30 * 60 * 1000)); args.add(message.getOrderId()); args.add(JSON.toJSONString(message)); List<String> keys = Lists.newArrayList(); long index = message.getUserId() % SHARDING_COUNT; keys.add(ORDER_QUEUE); keys.add(ORDER_DETAIL_QUEUE); try (Jedis jedis = jedisProvider.provide(index)) { jedis.evalsha(ENQUEUE_LUA_SHA.get(index), keys, args); @Override public List<OrderMessage> dequeue(long index) { // 30分钟之前 String maxScore = String.valueOf(System.currentTimeMillis() - 30 * 60 * 1000); return dequeue(MIN_SCORE, maxScore, OFFSET, LIMIT, index); @SuppressWarnings("unchecked") @Override public List<OrderMessage> dequeue(String min, String max, String offset, String limit, long index) { List<String> args = new ArrayList<>(); args.add(min); args.add(max); args.add(offset); args.add(limit); List<OrderMessage> result = Lists.newArrayList(); List<String> keys = Lists.newArrayList(); keys.add(ORDER_QUEUE); keys.add(ORDER_DETAIL_QUEUE); try (Jedis jedis = jedisProvider.provide(index)) { List<String> eval = (List<String>) jedis.evalsha(DEQUEUE_LUA_SHA.get(index), keys, args); if (null != eval) { for (String e : eval) { result.add(JSON.parseObject(e, OrderMessage.class)); return result; @Override public String enqueueSha(long index) { return ENQUEUE_LUA_SHA.get(index); @Override public String dequeueSha(long index) { return DEQUEUE_LUA_SHA.get(index); @Override public void afterPropertiesSet() throws Exception { // 加载Lua脚本 loadLuaScript(); private void loadLuaScript() throws Exception { for (long i = 0; i < SHARDING_COUNT; i++) { try (Jedis jedis = jedisProvider.provide(i)) { ClassPathResource resource = new ClassPathResource(ENQUEUE_LUA_SCRIPT_LOCATION); String luaContent = StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8); String sha = jedis.scriptLoad(luaContent); ENQUEUE_LUA_SHA.put(i, sha); resource = new ClassPathResource(DEQUEUE_LUA_SCRIPT_LOCATION); luaContent = StreamUtils.copyToString(resource.getInputStream(), StandardCharsets.UTF_8); sha = jedis.scriptLoad(luaContent); DEQUEUE_LUA_SHA.put(i, sha); // 消费者 public class OrderMessageConsumer implements Job { private static final Logger LOGGER = LoggerFactory.getLogger(OrderMessageConsumer.class); private static final AtomicInteger COUNTER = new AtomicInteger(); // 初始化业务线程池 private final ExecutorService businessWorkerPool = Executors.newSingleThreadExecutor(r -> { Thread thread = new Thread(r); thread.setDaemon(true); thread.setName("OrderMessageConsumerWorker-" + COUNTER.getAndIncrement()); return thread; @Autowired private OrderDelayQueue orderDelayQueue; @Override public void execute(JobExecutionContext context) throws JobExecutionException { long shardingIndex = context.getMergedJobDataMap().getLong("shardingIndex"); LOGGER.info("订单消息消费者定时任务开始执行,shardingIndex:[{}]...", shardingIndex); List<OrderMessage> dequeue = orderDelayQueue.dequeue(shardingIndex); if (null != dequeue) { // 这里的倒数栅栏,在线程池资源充足的前提下可以去掉 final CountDownLatch latch = new CountDownLatch(1); businessWorkerPool.execute(new ConsumeTask(latch, dequeue, shardingIndex)); try { latch.await(); } catch (InterruptedException ignore) { //ignore LOGGER.info("订单消息消费者定时任务执行完毕,shardingIndex:[{}]...", shardingIndex); @RequiredArgsConstructor private static class ConsumeTask implements Runnable { private final CountDownLatch latch; private final List<OrderMessage> messages; private final long shardingIndex; @Override public void run() { try { for (OrderMessage message : messages) { LOGGER.info("shardingIndex:[{}],处理订单消息,内容:{}", shardingIndex, JSON.toJSONString(message)); // 模拟处理耗时50毫秒 TimeUnit.MILLISECONDS.sleep(50); } catch (Exception ignore) { } finally { latch.countDown(); // 配置 @Configuration public class QuartzConfiguration { @Bean public AutowiredSupportQuartzJobFactory autowiredSupportQuartzJobFactory() { return new AutowiredSupportQuartzJobFactory(); @Bean public SchedulerFactoryBean schedulerFactoryBean(AutowiredSupportQuartzJobFactory autowiredSupportQuartzJobFactory) { SchedulerFactoryBean factory = new SchedulerFactoryBean(); factory.setSchedulerName("RamScheduler"); factory.setAutoStartup(true); factory.setJobFactory(autowiredSupportQuartzJobFactory); return factory; public static class AutowiredSupportQuartzJobFactory extends AdaptableJobFactory implements BeanFactoryAware { private AutowireCapableBeanFactory autowireCapableBeanFactory; @Override public void setBeanFactory(BeanFactory beanFactory) throws BeansException { this.autowireCapableBeanFactory = (AutowireCapableBeanFactory) beanFactory; @Override protected Object createJobInstance(@Nonnull TriggerFiredBundle bundle) throws Exception { Object jobInstance = super.createJobInstance(bundle); autowireCapableBeanFactory.autowireBean(jobInstance); return jobInstance; // CommandLineRunner @Component public class QuartzJobStartCommandLineRunner implements CommandLineRunner { @Autowired private Scheduler scheduler; @Autowired private JedisProvider jedisProvider; @Override public void run(String... args) throws Exception { long shardingCount = 2; prepareData(shardingCount); for (ConsumerTask task : prepareConsumerTasks(shardingCount)) { scheduler.scheduleJob(task.getJobDetail(), task.getTrigger()); private void prepareData(long shardingCount) { for (long i = 0L; i < shardingCount; i++) { Map<String, Double> z = Maps.newHashMap(); Map<String, String> h = Maps.newHashMap(); for (int k = 0; k < 100; k++) { OrderMessage message = new OrderMessage(); message.setAmount(BigDecimal.valueOf(k)); message.setUserId((long) k); message.setOrderId("ORDER_ID_" + k); // 30 min ago z.put(message.getOrderId(), Double.valueOf(String.valueOf(System.currentTimeMillis() - 30 * 60 * 1000))); h.put(message.getOrderId(), JSON.toJSONString(message)); Jedis jedis = jedisProvider.provide(i); jedis.hmset("ORDER_DETAIL_QUEUE", h); jedis.zadd("ORDER_QUEUE", z); private List<ConsumerTask> prepareConsumerTasks(long shardingCount) { List<ConsumerTask> tasks = Lists.newArrayList(); for (long i = 0; i < shardingCount; i++) { JobDetail jobDetail = JobBuilder.newJob(OrderMessageConsumer.class) .withIdentity("OrderMessageConsumer-" + i, "DelayTask") .usingJobData("shardingIndex", i) .build(); Trigger trigger = TriggerBuilder.newTrigger() .withIdentity("OrderMessageConsumerTrigger-" + i, "DelayTask") .withSchedule(SimpleScheduleBuilder.simpleSchedule().withIntervalInSeconds(10).repeatForever()) .build(); tasks.add(new ConsumerTask(jobDetail, trigger)); return tasks; @Getter @RequiredArgsConstructor private static class ConsumerTask { private final JobDetail jobDetail; private final Trigger trigger; 复制代码新增一个启动函数并且启动,控制台输出如下:// ...省略大量输出 2019-09-01 14:08:27.664 INFO 13056 --- [ main] c.t.multi.NoneJdbcSpringApplication : Started NoneJdbcSpringApplication in 1.333 seconds (JVM running for 5.352) 2019-09-01 14:08:27.724 INFO 13056 --- [eduler_Worker-2] c.throwable.multi.OrderMessageConsumer : 订单消息消费者定时任务开始执行,shardingIndex:[1]... 2019-09-01 14:08:27.724 INFO 13056 --- [eduler_Worker-1] c.throwable.multi.OrderMessageConsumer : 订单消息消费者定时任务开始执行,shardingIndex:[0]... 2019-09-01 14:08:27.732 INFO 13056 --- [onsumerWorker-1] c.throwable.multi.OrderMessageConsumer : shardingIndex:[1],处理订单消息,内容:{"amount":99,"orderId":"ORDER_ID_99","userId":99} 2019-09-01 14:08:27.732 INFO 13056 --- [onsumerWorker-0] c.throwable.multi.OrderMessageConsumer : shardingIndex:[0],处理订单消息,内容:{"amount":99,"orderId":"ORDER_ID_99","userId":99} 2019-09-01 14:08:27.782 INFO 13056 --- [onsumerWorker-0] c.throwable.multi.OrderMessageConsumer : shardingIndex:[0],处理订单消息,内容:{"amount":98,"orderId":"ORDER_ID_98","userId":98} 2019-09-01 14:08:27.782 INFO 13056 --- [onsumerWorker-1] c.throwable.multi.OrderMessageConsumer : shardingIndex:[1],处理订单消息,内容:{"amount":98,"orderId":"ORDER_ID_98","userId":98} // ...省略大量输出 2019-09-01 14:08:28.239 INFO 13056 --- [eduler_Worker-2] c.throwable.multi.OrderMessageConsumer : 订单消息消费者定时任务执行完毕,shardingIndex:[1]... 2019-09-01 14:08:28.240 INFO 13056 --- [eduler_Worker-1] c.throwable.multi.OrderMessageConsumer : 订单消息消费者定时任务执行完毕,shardingIndex:[0]... // ...省略大量输出 复制代码生产中应该避免Redis服务单点,一般常用哨兵配合树状主从的部署方式(参考《Redis开发与运维》),2套Redis哨兵的部署示意图如下:需要什么监控项我们需要相对实时地知道Redis中的延时队列集合有多少积压数据,每次出队的耗时大概是多少等等监控项参数,这样我们才能更好地知道延时队列模块是否正常运行、是否存在性能瓶颈等等。具体的监控项,需要按需定制,这里为了方便举例,只做两个监控项的监控:有序集合Sorted Set中积压的元素数量。每次调用dequeue.lua的耗时。采用的是应用实时上报数据的方式,依赖于spring-boot-starter-actuator、Prometheus、Grafana搭建的监控体系,如果并不熟悉这个体系可以看两篇前置文章:JVM应用度量框架Micrometer实战通过micrometer实时监控线程池的各项指标监控引入依赖:<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-registry-prometheus</artifactId> <version>1.2.0</version> </dependency> 复制代码这里选用Gauge的Meter进行监控数据收集,添加监控类OrderDelayQueueMonitor:// OrderDelayQueueMonitor @Component public class OrderDelayQueueMonitor implements InitializingBean { private static final long SHARDING_COUNT = 2L; private final ConcurrentMap<Long, AtomicLong> remain = Maps.newConcurrentMap(); private final ConcurrentMap<Long, AtomicLong> lua = Maps.newConcurrentMap(); private ScheduledExecutorService executor; @Autowired private JedisProvider jedisProvider; @Override public void afterPropertiesSet() throws Exception { executor = Executors.newSingleThreadScheduledExecutor(r -> { Thread thread = new Thread(r, "OrderDelayQueueMonitor"); thread.setDaemon(true); return thread; for (long i = 0L; i < SHARDING_COUNT; i++) { AtomicLong l = new AtomicLong(); Metrics.gauge("order.delay.queue.lua.cost", Collections.singleton(Tag.of("index", String.valueOf(i))), l, AtomicLong::get); lua.put(i, l); AtomicLong r = new AtomicLong(); Metrics.gauge("order.delay.queue.remain", Collections.singleton(Tag.of("index", String.valueOf(i))), r, AtomicLong::get); remain.put(i, r); // 每五秒上报一次集合中的剩余数据 executor.scheduleWithFixedDelay(new MonitorTask(jedisProvider), 0, 5, TimeUnit.SECONDS); public void recordRemain(Long index, long count) { remain.get(index).set(count); public void recordLuaCost(Long index, long count) { lua.get(index).set(count); @RequiredArgsConstructor private class MonitorTask implements Runnable { private final JedisProvider jedisProvider; @Override public void run() { for (long i = 0L; i < SHARDING_COUNT; i++) { try (Jedis jedis = jedisProvider.provide(i)) { recordRemain(i, jedis.zcount("ORDER_QUEUE", "-inf", "+inf")); 复制代码原来的RedisOrderDelayQueue#dequeue()进行改造:@RequiredArgsConstructor @Component public class RedisOrderDelayQueue implements OrderDelayQueue, InitializingBean { // ... 省略没有改动的代码 private final OrderDelayQueueMonitor orderDelayQueueMonitor; // ... 省略没有改动的代码 @Override public List<OrderMessage> dequeue(String min, String max, String offset, String limit, long index) { List<String> args = new ArrayList<>(); args.add(min); args.add(max); args.add(offset); args.add(limit); List<OrderMessage> result = Lists.newArrayList(); List<String> keys = Lists.newArrayList(); keys.add(ORDER_QUEUE); keys.add(ORDER_DETAIL_QUEUE); try (Jedis jedis = jedisProvider.provide(index)) { long start = System.nanoTime(); List<String> eval = (List<String>) jedis.evalsha(DEQUEUE_LUA_SHA.get(index), keys, args); long end = System.nanoTime(); // 添加dequeue的耗时监控-单位微秒 orderDelayQueueMonitor.recordLuaCost(index, TimeUnit.NANOSECONDS.toMicros(end - start)); if (null != eval) { for (String e : eval) { result.add(JSON.parseObject(e, OrderMessage.class)); return result; // ... 省略没有改动的代码 复制代码其他配置这里简单说一下。application.yaml要开放prometheus端点的访问权限:server: port: 9091 management: endpoints: exposure: include: 'prometheus' 复制代码Prometheus服务配置尽量减少查询的间隔时间,暂定为5秒:# my global config global: scrape_interval: 5s # Set the scrape interval to every 15 seconds. Default is every 1 minute. evaluation_interval: 15s # Evaluate rules every 15 seconds. The default is every 1 minute. # scrape_timeout is set to the global default (10s). # Alertmanager configuration alerting: alertmanagers: - static_configs: - targets: # - alertmanager:9093 # Load rules once and periodically evaluate them according to the global 'evaluation_interval'. rule_files: # - "first_rules.yml" # - "second_rules.yml" # A scrape configuration containing exactly one endpoint to scrape: # Here it's Prometheus itself. scrape_configs: # The job name is added as a label `job=<job_name>` to any timeseries scraped from this config. - job_name: 'prometheus' metrics_path: '/actuator/prometheus' # metrics_path defaults to '/metrics' # scheme defaults to 'http'. static_configs: - targets: ['localhost:9091'] 复制代码Grafana的基本配置项如下:出队耗时 order_delay_queue_lua_cost 分片编号-{{index}} 订单延时队列积压量 order_delay_queue_remain 分片编号-{{index}} 复制代码最终可以在Grafana配置每5秒刷新,见效果如下:这里的监控项更多时候应该按需定制,说实话,监控的工作往往是最复杂和繁琐的。小结全文相对详细地介绍了基于Redis实现延时任务的分片和监控的具体实施过程,核心代码仅供参考,还有一些具体的细节例如Prometheus、Grafana的一些应用,这里限于篇幅不会详细地展开。说实话,基于实际场景做一次中间件和架构的选型并不是一件简单的事,而且往往初期的实施并不是最大的难点,更大的难题在后面的优化以及监控。附件Markdown原件:github.com/zjcscut/blo…Github Page:www.throwable.club/2019/09/01/…Coding Page:throwable.coding.me/2019/09/01/…

使用Redis实现延时任务(一)(上)

前提最近在生产环境刚好遇到了延时任务的场景,调研了一下目前主流的方案,分析了一下优劣并且敲定了最终的方案。这篇文章记录了调研的过程,以及初步方案的实现。候选方案对比下面是想到的几种实现延时任务的方案,总结了一下相应的优势和劣势。方案优势劣势选用场景JDK内置的延迟队列DelayQueue实现简单数据内存态,不可靠一致性相对低的场景调度框架和MySQL进行短间隔轮询实现简单,可靠性高存在明显的性能瓶颈数据量较少实时性相对低的场景RabbitMQ的DLX和TTL,一般称为死信队列方案异步交互可以削峰延时的时间长度不可控,如果数据需要持久化则性能会降低-调度框架和Redis进行短间隔轮询数据持久化,高性能实现难度大常见于支付结果回调方案时间轮实时性高实现难度大,内存消耗大实时性高的场景如果应用的数据量不高,实时性要求比较低,选用调度框架和MySQL进行短间隔轮询这个方案是最优的方案。但是笔者遇到的场景数据量相对比较大,实时性并不高,采用扫库的方案一定会对MySQL实例造成比较大的压力。记得很早之前,看过一个PPT叫《盒子科技聚合支付系统演进》,其中里面有一张图片给予笔者一点启发:里面刚好用到了调度框架和Redis进行短间隔轮询实现延时任务的方案,不过为了分摊应用的压力,图中的方案还做了分片处理。鉴于笔者当前业务紧迫,所以在第一期的方案暂时不考虑分片,只做了一个简化版的实现。由于PPT中没有任何的代码或者框架贴出,有些需要解决的技术点需要自行思考,下面会重现一次整个方案实现的详细过程。场景设计实际的生产场景是笔者负责的某个系统需要对接一个外部的资金方,每一笔资金下单后需要延时30分钟推送对应的附件。这里简化为一个订单信息数据延迟处理的场景,就是每一笔下单记录一条订单消息(暂时叫做OrderMessage),订单消息需要延迟5到15秒后进行异步处理。否决的候选方案实现思路下面介绍一下其它四个不选用的候选方案,结合一些伪代码和流程分析一下实现过程。JDK内置延迟队列DelayQueue是一个阻塞队列的实现,它的队列元素必须是Delayed的子类,这里做个简单的例子:public class DelayQueueMain { private static final Logger LOGGER = LoggerFactory.getLogger(DelayQueueMain.class); public static void main(String[] args) throws Exception { DelayQueue<OrderMessage> queue = new DelayQueue<>(); // 默认延迟5秒 OrderMessage message = new OrderMessage("ORDER_ID_10086"); queue.add(message); // 延迟6秒 message = new OrderMessage("ORDER_ID_10087", 6); queue.add(message); // 延迟10秒 message = new OrderMessage("ORDER_ID_10088", 10); queue.add(message); ExecutorService executorService = Executors.newSingleThreadExecutor(r -> { Thread thread = new Thread(r); thread.setName("DelayWorker"); thread.setDaemon(true); return thread; LOGGER.info("开始执行调度线程..."); executorService.execute(() -> { while (true) { try { OrderMessage task = queue.take(); LOGGER.info("延迟处理订单消息,{}", task.getDescription()); } catch (Exception e) { LOGGER.error(e.getMessage(), e); Thread.sleep(Integer.MAX_VALUE); private static class OrderMessage implements Delayed { private static final DateTimeFormatter F = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); * 默认延迟5000毫秒 private static final long DELAY_MS = 1000L * 5; * 订单ID private final String orderId; * 创建时间戳 private final long timestamp; * 过期时间 private final long expire; private final String description; public OrderMessage(String orderId, long expireSeconds) { this.orderId = orderId; this.timestamp = System.currentTimeMillis(); this.expire = this.timestamp + expireSeconds * 1000L; this.description = String.format("订单[%s]-创建时间为:%s,超时时间为:%s", orderId, LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneId.systemDefault()).format(F), LocalDateTime.ofInstant(Instant.ofEpochMilli(expire), ZoneId.systemDefault()).format(F)); public OrderMessage(String orderId) { this.orderId = orderId; this.timestamp = System.currentTimeMillis(); this.expire = this.timestamp + DELAY_MS; this.description = String.format("订单[%s]-创建时间为:%s,超时时间为:%s", orderId, LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneId.systemDefault()).format(F), LocalDateTime.ofInstant(Instant.ofEpochMilli(expire), ZoneId.systemDefault()).format(F)); public String getOrderId() { return orderId; public long getTimestamp() { return timestamp; public long getExpire() { return expire; public String getDescription() { return description; @Override public long getDelay(TimeUnit unit) { return unit.convert(this.expire - System.currentTimeMillis(), TimeUnit.MILLISECONDS); @Override public int compareTo(Delayed o) { return (int) (this.getDelay(TimeUnit.MILLISECONDS) - o.getDelay(TimeUnit.MILLISECONDS)); 复制代码注意一下,OrderMessage实现Delayed接口,关键是需要实现Delayed#getDelay()和Delayed#compareTo()。运行一下main()方法:10:16:08.240 [main] INFO club.throwable.delay.DelayQueueMain - 开始执行调度线程... 10:16:13.224 [DelayWorker] INFO club.throwable.delay.DelayQueueMain - 延迟处理订单消息,订单[ORDER_ID_10086]-创建时间为:2019-08-20 10:16:08,超时时间为:2019-08-20 10:16:13 10:16:14.237 [DelayWorker] INFO club.throwable.delay.DelayQueueMain - 延迟处理订单消息,订单[ORDER_ID_10087]-创建时间为:2019-08-20 10:16:08,超时时间为:2019-08-20 10:16:14 10:16:18.237 [DelayWorker] INFO club.throwable.delay.DelayQueueMain - 延迟处理订单消息,订单[ORDER_ID_10088]-创建时间为:2019-08-20 10:16:08,超时时间为:2019-08-20 10:16:18 复制代码调度框架 + MySQL使用调度框架对MySQL表进行短间隔轮询是实现难度比较低的方案,通常服务刚上线,表数据不多并且实时性不高的情况下应该首选这个方案。不过要注意以下几点:注意轮询间隔不能太短,否则会对MySQL实例产生影响。注意每次查询的数量,结果集数量太多有可能会导致调度阻塞和占用应用大量内存,从而影响时效性。注意要设计状态值和最大重试次数,这样才能尽量避免大量数据积压和重复查询的问题。最好通过时间列做索引,查询指定时间范围内的数据。引入Quartz、MySQL的Java驱动包和spring-boot-starter-jdbc(这里只是为了方便用相对轻量级的框架实现,生产中可以按场景按需选择其他更合理的框架):<dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.48</version> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> <version>2.1.7.RELEASE</version> <scope>test</scope> </dependency> <dependency> <groupId>org.quartz-scheduler</groupId> <artifactId>quartz</artifactId> <version>2.3.1</version> <scope>test</scope> </dependency> 复制代码假设表设计如下:CREATE DATABASE `delayTask` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci; USE `delayTask`; CREATE TABLE `t_order_message` id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, order_id VARCHAR(50) NOT NULL COMMENT '订单ID', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建日期时间', edit_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改日期时间', retry_times TINYINT NOT NULL DEFAULT 0 COMMENT '重试次数', order_status TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态', INDEX idx_order_id (order_id), INDEX idx_create_time (create_time) ) COMMENT '订单信息表'; # 写入两条测试数据 INSERT INTO t_order_message(order_id) VALUES ('10086'),('10087'); 复制代码编写代码:// 常量 public class OrderConstants { public static final int MAX_RETRY_TIMES = 5; public static final int PENDING = 0; public static final int SUCCESS = 1; public static final int FAIL = -1; public static final int LIMIT = 10; // 实体 @Builder @Data public class OrderMessage { private Long id; private String orderId; private LocalDateTime createTime; private LocalDateTime editTime; private Integer retryTimes; private Integer orderStatus; // DAO @RequiredArgsConstructor public class OrderMessageDao { private final JdbcTemplate jdbcTemplate; private static final ResultSetExtractor<List<OrderMessage>> M = r -> { List<OrderMessage> list = Lists.newArrayList(); while (r.next()) { list.add(OrderMessage.builder() .id(r.getLong("id")) .orderId(r.getString("order_id")) .createTime(r.getTimestamp("create_time").toLocalDateTime()) .editTime(r.getTimestamp("edit_time").toLocalDateTime()) .retryTimes(r.getInt("retry_times")) .orderStatus(r.getInt("order_status")) .build()); return list; public List<OrderMessage> selectPendingRecords(LocalDateTime start, LocalDateTime end, List<Integer> statusList, int maxRetryTimes, int limit) { StringJoiner joiner = new StringJoiner(","); statusList.forEach(s -> joiner.add(String.valueOf(s))); return jdbcTemplate.query("SELECT * FROM t_order_message WHERE create_time >= ? AND create_time <= ? " + "AND order_status IN (?) AND retry_times < ? LIMIT ?", p -> { p.setTimestamp(1, Timestamp.valueOf(start)); p.setTimestamp(2, Timestamp.valueOf(end)); p.setString(3, joiner.toString()); p.setInt(4, maxRetryTimes); p.setInt(5, limit); }, M); public int updateOrderStatus(Long id, int status) { return jdbcTemplate.update("UPDATE t_order_message SET order_status = ?,edit_time = ? WHERE id =?", p -> { p.setInt(1, status); p.setTimestamp(2, Timestamp.valueOf(LocalDateTime.now())); p.setLong(3, id); // Service @RequiredArgsConstructor public class OrderMessageService { private static final Logger LOGGER = LoggerFactory.getLogger(OrderMessageService.class); private final OrderMessageDao orderMessageDao; private static final List<Integer> STATUS = Lists.newArrayList(); static { STATUS.add(OrderConstants.PENDING); STATUS.add(OrderConstants.FAIL); public void executeDelayJob() { LOGGER.info("订单处理定时任务开始执行......"); LocalDateTime end = LocalDateTime.now(); // 一天前 LocalDateTime start = end.minusDays(1); List<OrderMessage> list = orderMessageDao.selectPendingRecords(start, end, STATUS, OrderConstants.MAX_RETRY_TIMES, OrderConstants.LIMIT); if (!list.isEmpty()) { for (OrderMessage m : list) { LOGGER.info("处理订单[{}],状态由{}更新为{}", m.getOrderId(), m.getOrderStatus(), OrderConstants.SUCCESS); // 这里其实可以优化为批量更新 orderMessageDao.updateOrderStatus(m.getId(), OrderConstants.SUCCESS); LOGGER.info("订单处理定时任务开始完毕......"); // Job @DisallowConcurrentExecution public class OrderMessageDelayJob implements Job { @Override public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException { OrderMessageService service = (OrderMessageService) jobExecutionContext.getMergedJobDataMap().get("orderMessageService"); service.executeDelayJob(); public static void main(String[] args) throws Exception { HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:mysql://localhost:3306/delayTask?useSSL=false&characterEncoding=utf8"); config.setDriverClassName(Driver.class.getName()); config.setUsername("root"); config.setPassword("root"); HikariDataSource dataSource = new HikariDataSource(config); OrderMessageDao orderMessageDao = new OrderMessageDao(new JdbcTemplate(dataSource)); OrderMessageService service = new OrderMessageService(orderMessageDao); // 内存模式的调度器 StdSchedulerFactory factory = new StdSchedulerFactory(); Scheduler scheduler = factory.getScheduler(); // 这里没有用到IOC容器,直接用Quartz数据集合传递服务引用 JobDataMap jobDataMap = new JobDataMap(); jobDataMap.put("orderMessageService", service); // 新建Job JobDetail job = JobBuilder.newJob(OrderMessageDelayJob.class) .withIdentity("orderMessageDelayJob", "delayJob") .usingJobData(jobDataMap) .build(); // 新建触发器,10秒执行一次 Trigger trigger = TriggerBuilder.newTrigger() .withIdentity("orderMessageDelayTrigger", "delayJob") .withSchedule(SimpleScheduleBuilder.simpleSchedule().withIntervalInSeconds(10).repeatForever()) .build(); scheduler.scheduleJob(job, trigger); // 启动调度器 scheduler.start(); Thread.sleep(Integer.MAX_VALUE); 复制代码这个例子里面用了create_time做轮询,实际上可以添加一个调度时间schedule_time列做轮询,这样子才能更容易定制空闲时和忙碌时候的调度策略。上面的示例的运行效果如下:11:58:27.202 [main] INFO org.quartz.core.QuartzScheduler - Scheduler meta-data: Quartz Scheduler (v2.3.1) 'DefaultQuartzScheduler' with instanceId 'NON_CLUSTERED' Scheduler class: 'org.quartz.core.QuartzScheduler' - running locally. NOT STARTED. Currently in standby mode. Number of jobs executed: 0 Using thread pool 'org.quartz.simpl.SimpleThreadPool' - with 10 threads. Using job-store 'org.quartz.simpl.RAMJobStore' - which does not support persistence. and is not clustered. 11:58:27.202 [main] INFO org.quartz.impl.StdSchedulerFactory - Quartz scheduler 'DefaultQuartzScheduler' initialized from default resource file in Quartz package: 'quartz.properties' 11:58:27.202 [main] INFO org.quartz.impl.StdSchedulerFactory - Quartz scheduler version: 2.3.1 11:58:27.209 [main] INFO org.quartz.core.QuartzScheduler - Scheduler DefaultQuartzScheduler_$_NON_CLUSTERED started. 11:58:27.212 [DefaultQuartzScheduler_QuartzSchedulerThread] DEBUG org.quartz.core.QuartzSchedulerThread - batch acquisition of 1 triggers 11:58:27.217 [DefaultQuartzScheduler_QuartzSchedulerThread] DEBUG org.quartz.simpl.PropertySettingJobFactory - Producing instance of Job 'delayJob.orderMessageDelayJob', class=club.throwable.jdbc.OrderMessageDelayJob 11:58:27.219 [HikariPool-1 connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Added connection com.mysql.jdbc.JDBC4Connection@10eb8c53 11:58:27.220 [DefaultQuartzScheduler_QuartzSchedulerThread] DEBUG org.quartz.core.QuartzSchedulerThread - batch acquisition of 0 triggers 11:58:27.221 [DefaultQuartzScheduler_Worker-1] DEBUG org.quartz.core.JobRunShell - Calling execute on job delayJob.orderMessageDelayJob 11:58:34.440 [DefaultQuartzScheduler_Worker-1] INFO club.throwable.jdbc.OrderMessageService - 订单处理定时任务开始执行...... 11:58:34.451 [HikariPool-1 connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Added connection com.mysql.jdbc.JDBC4Connection@3d27ece4 11:58:34.459 [HikariPool-1 connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Added connection com.mysql.jdbc.JDBC4Connection@64e808af 11:58:34.470 [HikariPool-1 connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Added connection com.mysql.jdbc.JDBC4Connection@79c8c2b7 11:58:34.477 [HikariPool-1 connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Added connection com.mysql.jdbc.JDBC4Connection@19a62369 11:58:34.485 [HikariPool-1 connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Added connection com.mysql.jdbc.JDBC4Connection@1673d017 11:58:34.485 [HikariPool-1 connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - After adding stats (total=10, active=0, idle=10, waiting=0) 11:58:34.559 [DefaultQuartzScheduler_Worker-1] DEBUG org.springframework.jdbc.core.JdbcTemplate - Executing prepared SQL query 11:58:34.565 [DefaultQuartzScheduler_Worker-1] DEBUG org.springframework.jdbc.core.JdbcTemplate - Executing prepared SQL statement [SELECT * FROM t_order_message WHERE create_time >= ? AND create_time <= ? AND order_status IN (?) AND retry_times < ? LIMIT ?] 11:58:34.645 [DefaultQuartzScheduler_Worker-1] DEBUG org.springframework.jdbc.datasource.DataSourceUtils - Fetching JDBC Connection from DataSource 11:58:35.210 [DefaultQuartzScheduler_Worker-1] DEBUG org.springframework.jdbc.core.JdbcTemplate - SQLWarning ignored: SQL state '22007', error code '1292', message [Truncated incorrect DOUBLE value: '0,-1'] 11:58:35.335 [DefaultQuartzScheduler_Worker-1] INFO club.throwable.jdbc.OrderMessageService - 处理订单[10086],状态由0更新为1 11:58:35.342 [DefaultQuartzScheduler_Worker-1] DEBUG org.springframework.jdbc.core.JdbcTemplate - Executing prepared SQL update 11:58:35.346 [DefaultQuartzScheduler_Worker-1] DEBUG org.springframework.jdbc.core.JdbcTemplate - Executing prepared SQL statement [UPDATE t_order_message SET order_status = ?,edit_time = ? WHERE id =?] 11:58:35.347 [DefaultQuartzScheduler_Worker-1] DEBUG org.springframework.jdbc.datasource.DataSourceUtils - Fetching JDBC Connection from DataSource 11:58:35.354 [DefaultQuartzScheduler_Worker-1] INFO club.throwable.jdbc.OrderMessageService - 处理订单[10087],状态由0更新为1 11:58:35.355 [DefaultQuartzScheduler_Worker-1] DEBUG org.springframework.jdbc.core.JdbcTemplate - Executing prepared SQL update 11:58:35.355 [DefaultQuartzScheduler_Worker-1] DEBUG org.springframework.jdbc.core.JdbcTemplate - Executing prepared SQL statement [UPDATE t_order_message SET order_status = ?,edit_time = ? WHERE id =?] 11:58:35.355 [DefaultQuartzScheduler_Worker-1] DEBUG org.springframework.jdbc.datasource.DataSourceUtils - Fetching JDBC Connection from DataSource 11:58:35.361 [DefaultQuartzScheduler_Worker-1] INFO club.throwable.jdbc.OrderMessageService - 订单处理定时任务开始完毕...... 11:58:35.363 [DefaultQuartzScheduler_QuartzSchedulerThread] DEBUG org.quartz.core.QuartzSchedulerThread - batch acquisition of 1 triggers 11:58:37.206 [DefaultQuartzScheduler_QuartzSchedulerThread] DEBUG org.quartz.simpl.PropertySettingJobFactory - Producing instance of Job 'delayJob.orderMessageDelayJob', class=club.throwable.jdbc.OrderMessageDelayJob 11:58:37.206 [DefaultQuartzScheduler_QuartzSchedulerThread] DEBUG org.quartz.core.QuartzSchedulerThread - batch acquisition of 0 triggers 复制代码RabbitMQ死信队列使用RabbitMQ死信队列依赖于RabbitMQ的两个特性:TTL和DLX。TTL:Time To Live,消息存活时间,包括两个维度:队列消息存活时间和消息本身的存活时间。DLX:Dead Letter Exchange,死信交换器。画个图描述一下这两个特性:下面为了简单起见,TTL使用了针对队列的维度。引入RabbitMQ的Java驱动:<dependency> <groupId>com.rabbitmq</groupId> <artifactId>amqp-client</artifactId> <version>5.7.3</version> <scope>test</scope> </dependency> 复制代码代码如下:public class DlxMain { private static final DateTimeFormatter F = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); private static final Logger LOGGER = LoggerFactory.getLogger(DlxMain.class); public static void main(String[] args) throws Exception { ConnectionFactory factory = new ConnectionFactory(); Connection connection = factory.newConnection(); Channel producerChannel = connection.createChannel(); Channel consumerChannel = connection.createChannel(); // dlx交换器名称为dlx.exchange,类型是direct,绑定键为dlx.key,队列名为dlx.queue producerChannel.exchangeDeclare("dlx.exchange", "direct"); producerChannel.queueDeclare("dlx.queue", false, false, false, null); producerChannel.queueBind("dlx.queue", "dlx.exchange", "dlx.key"); Map<String, Object> queueArgs = new HashMap<>(); // 设置队列消息过期时间,5秒 queueArgs.put("x-message-ttl", 5000); // 指定DLX相关参数 queueArgs.put("x-dead-letter-exchange", "dlx.exchange"); queueArgs.put("x-dead-letter-routing-key", "dlx.key"); // 声明业务队列 producerChannel.queueDeclare("business.queue", false, false, false, queueArgs); ExecutorService executorService = Executors.newSingleThreadExecutor(r -> { Thread thread = new Thread(r); thread.setDaemon(true); thread.setName("DlxConsumer"); return thread; // 启动消费者 executorService.execute(() -> { try { consumerChannel.basicConsume("dlx.queue", true, new DlxConsumer(consumerChannel)); } catch (IOException e) { LOGGER.error(e.getMessage(), e); OrderMessage message = new OrderMessage("10086"); producerChannel.basicPublish("", "business.queue", MessageProperties.TEXT_PLAIN, message.getDescription().getBytes(StandardCharsets.UTF_8)); LOGGER.info("发送消息成功,订单ID:{}", message.getOrderId()); message = new OrderMessage("10087"); producerChannel.basicPublish("", "business.queue", MessageProperties.TEXT_PLAIN, message.getDescription().getBytes(StandardCharsets.UTF_8)); LOGGER.info("发送消息成功,订单ID:{}", message.getOrderId()); message = new OrderMessage("10088"); producerChannel.basicPublish("", "business.queue", MessageProperties.TEXT_PLAIN, message.getDescription().getBytes(StandardCharsets.UTF_8)); LOGGER.info("发送消息成功,订单ID:{}", message.getOrderId()); Thread.sleep(Integer.MAX_VALUE); private static class DlxConsumer extends DefaultConsumer { DlxConsumer(Channel channel) { super(channel); @Override public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException { LOGGER.info("处理消息成功:{}", new String(body, StandardCharsets.UTF_8)); private static class OrderMessage { private final String orderId; private final long timestamp; private final String description; OrderMessage(String orderId) { this.orderId = orderId; this.timestamp = System.currentTimeMillis(); this.description = String.format("订单[%s],订单创建时间为:%s", orderId, LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneId.systemDefault()).format(F)); public String getOrderId() { return orderId; public long getTimestamp() { return timestamp; public String getDescription() { return description; 复制代码运行main()方法结果如下:16:35:58.638 [main] INFO club.throwable.dlx.DlxMain - 发送消息成功,订单ID:10086 16:35:58.641 [main] INFO club.throwable.dlx.DlxMain - 发送消息成功,订单ID:10087 16:35:58.641 [main] INFO club.throwable.dlx.DlxMain - 发送消息成功,订单ID:10088 16:36:03.646 [pool-1-thread-4] INFO club.throwable.dlx.DlxMain - 处理消息成功:订单[10086],订单创建时间为:2019-08-20 16:35:58 16:36:03.670 [pool-1-thread-5] INFO club.throwable.dlx.DlxMain - 处理消息成功:订单[10087],订单创建时间为:2019-08-20 16:35:58 16:36:03.670 [pool-1-thread-6] INFO club.throwable.dlx.DlxMain - 处理消息成功:订单[10088],订单创建时间为:2019-08-20 16:35:58 复制代码时间轮时间轮TimingWheel是一种高效、低延迟的调度数据结构,底层采用数组实现存储任务列表的环形队列,示意图如下:这里暂时不对时间轮和其实现作分析,只简单举例说明怎么使用时间轮实现延时任务。这里使用Netty提供的HashedWheelTimer,引入依赖:<dependency> <groupId>io.netty</groupId> <artifactId>netty-common</artifactId> <version>4.1.39.Final</version> </dependency> 复制代码代码如下:public class HashedWheelTimerMain { private static final DateTimeFormatter F = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"); public static void main(String[] args) throws Exception { AtomicInteger counter = new AtomicInteger(); ThreadFactory factory = r -> { Thread thread = new Thread(r); thread.setDaemon(true); thread.setName("HashedWheelTimerWorker-" + counter.getAndIncrement()); return thread; // tickDuration - 每tick一次的时间间隔, 每tick一次就会到达下一个槽位 // unit - tickDuration的时间单位 // ticksPerWhee - 时间轮中的槽位数 Timer timer = new HashedWheelTimer(factory, 1, TimeUnit.SECONDS, 60); TimerTask timerTask = new DefaultTimerTask("10086"); timer.newTimeout(timerTask, 5, TimeUnit.SECONDS); timerTask = new DefaultTimerTask("10087"); timer.newTimeout(timerTask, 10, TimeUnit.SECONDS); timerTask = new DefaultTimerTask("10088"); timer.newTimeout(timerTask, 15, TimeUnit.SECONDS); Thread.sleep(Integer.MAX_VALUE); private static class DefaultTimerTask implements TimerTask { private final String orderId; private final long timestamp; public DefaultTimerTask(String orderId) { this.orderId = orderId; this.timestamp = System.currentTimeMillis(); @Override public void run(Timeout timeout) throws Exception { System.out.println(String.format("任务执行时间:%s,订单创建时间:%s,订单ID:%s", LocalDateTime.now().format(F), LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneId.systemDefault()).format(F), orderId)); 复制代码运行结果:任务执行时间:2019-08-20 17:19:49.310,订单创建时间:2019-08-20 17:19:43.294,订单ID:10086 任务执行时间:2019-08-20 17:19:54.297,订单创建时间:2019-08-20 17:19:43.301,订单ID:10087 任务执行时间:2019-08-20 17:19:59.297,订单创建时间:2019-08-20 17:19:43.301,订单ID:10088 复制代码一般来说,任务执行的时候应该使用另外的业务线程池,以免阻塞时间轮本身的运动。

使用Redis的HSCAN命令遇到的一个问题

前提笔者最近在做一个项目时候使用Redis存放客户端展示的订单列表,列表需要进行分页。由于笔者先前对Redis的各种数据类型的使用场景并不是十分熟悉,于是先入为主地看到Hash类型:USER_ID:1 ORDER_ID:ORDER_XX: {"amount": "100","orderId":"ORDER_XX"} ORDER_ID:ORDER_YY: {"amount": "200","orderId":"ORDER_YY"} 复制代码感觉Hash类型完全满足需求实现的场景。然后想当然地考虑使用HSCAN命令进行分页,引发了后面遇到的问题。SCAN和HSCAN命令SCAN命令如下:SCAN cursor [MATCH pattern] [COUNT count] [TYPE type] // 返回值如下: // 1. cursor,数值类型,下一轮的起始游标值,0代表遍历结束 // 2. 遍历的结果集合,列表 复制代码SCAN命令在Redis2.8.0版本中新增,时间复杂度计算如下:每一轮遍历的时间复杂度为O(1),所有元素遍历完毕直到游标cursor返回0的时间复杂度为O(N),其中N为集合内元素的数量。SCAN是针对整个Database内的所有KEY进行渐进式的遍历,它不会阻塞Redis,也就是使用SCAN命令遍历KEY的性能会优于KEY *命令。对于Hash类型有一个衍生的命令HSCAN专门用于遍历Hash类型及其相关属性(Field)的字段:HSCAN key cursor [MATCH pattern] [COUNT count] // 返回值如下: // 1. cursor,数值类型,下一轮的起始游标值,0代表遍历结束 // 2. 遍历的结果集合,是一个映射 复制代码笔者当时没有详细查阅Redis的官方文档,想当然地认为Hash类型的分页简单如下(假设每页数据只有1条):// 第一页 HSCAN USER_ID:1 0 COUNT 1 <= 这里认为返回的游标值为1 // 第二页 HSCAN USER_ID:1 1 COUNT 1 <= 这里认为返回的游标值为0,结束迭代 复制代码实际上,执行的结果如下:HSCAN USER_ID:1 0 COUNT 1 // 结果 ORDER_ID:ORDER_XX {"amount": "100","orderId":"ORDER_XX"} ORDER_ID:ORDER_YY {"amount": "200","orderId":"ORDER_YY"} 复制代码也就是在第一轮遍历的时候,KEY对应的所有Field-Value已经全量返回。笔者尝试增加哈希集合KEY = USER_ID:1里面的元素,但是数据量相对较大的时候,依然没有达到预期的分页效果;另一个方面,尝试修改命令中的COUNT值,发现无论如何修改COUNT值都不会对遍历的结果产生任何影响(也就是还是在第一轮迭代返回全部结果)。百思不得其解的情况下,只能仔细翻阅官方文档寻找解决方案。在SCAN命令的COUNT属性描述中找到了原因:简单翻译理解一下:SCAN命令以及其衍生命令并不保证每一轮迭代返回的元素数量,但是可以使用COUNT属性凭经验调整SCAN命令的行为。COUNT指定每次调用应该完成遍历的元素的数量,以便于遍历集合,本质只是一个提示值。COUNT默认值为10。当遍历的目标Set、Hash、Sorted Set或者Key空间足够大可以使用一个哈希表表示并且不使用MATCH属性的前提下,Redis服务端会返回COUNT或者比COUNT大的遍历元素结果集合。当遍历只包含Integer值的Set集合(也称为intsets),或者ziplists类型编码的Hash或者Sorted Set集合(说明这些集合里面的元素占用的空间足够小),那么SCAN命令会返回集合中的所有元素,直接忽略COUNT属性。注意第3点,这个就是在Hash集合中使用HSCAN命令COUNT属性失效的根本原因。Redis配置中有两个和Hash类型ziplist编码的相关配置值:hash-max-ziplist-entries 512 hash-max-ziplist-value 64 复制代码在如下两个条件之一满足的时候,Hash集合的编码会由ziplist会转成dict:当Hash集合中的数据项(即Field-Value对)的数目超过512的时候。当Hash集合中插入的任意一个Field-Value对中的Value长度超过64。当Hash集合的编码会由ziplist会转成dict,Redis为Hash类型的内存空间占用优化相当于失败了,降级为相对消耗更多内存的字典类型编码,这个时候,HSCAN命令COUNT属性才会起效。案例验证简单验证一下上一节得出的结论,写入一个测试数据如下:// 70个X HSET USER_ID:2 ORDER_ID:ORDER_XXX XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX // 70个Y HSET USER_ID:2 ORDER_ID:ORDER_YYY YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY 复制代码接着开始测试一下HSCAN命令:// 查看编码 object encoding USER_ID:2 // 编码结果 hashtable // 第一轮迭代 HSCAN USER_ID:2 0 COUNT 1 // 第一轮迭代返回结果 ORDER_ID:ORDER_YYY YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY // 第二轮迭代 HSCAN USER_ID:2 2 COUNT 1 ORDER_ID:ORDER_XXX XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX 复制代码测试案例中故意让两个值的长度为70,大于64,也就是让Hash集合转变为dict(hashtable)类型,使得COUNT属性生效。但是,这种做法是放弃了Redis为Hash集合的内存优化。显然,HSCAN命令天然不是为了做数据分页而设计的,而是为了渐进式的迭代(也就是如果需要迭代的集合很大,也不会阻塞Redis服务)。所以笔者最后放弃了使用HSCAN命令,寻找更适合做数据分页查询的其他Redis命令。小结通过这简单的踩坑案例,笔者得到一些经验:切忌先入为主,使用中间件的时候要结合实际的场景。使用工具的之前要仔细阅读工具的使用手册。要通过一些案例验证自己的猜想或者推导的结果。Redis提供的API十分丰富,后面应该还会遇到更多的踩坑经验。附件Github Page:www.throwable.club/2019/08/12/…Coding Page:throwable.coding.me/2019/08/12/…Markdown文件:github.com/zjcscut/blo…

Java函数式编程之Optional

前提java.util.Optional是JDK8中引入的类,它是JDK从著名的Java工具包Guava中移植过来。本文编写的时候使用的是JDK11。Optional是一个包含了NULL值或者非NULL值的对象容器,它常用作明确表明没有结果(其实明确表明存在结果也可以用Optional表示)的方法返回类型,这样可以避免NULL值带来的可能的异常(一般是NullPointerException)。也就是说,一个方法的返回值类型是Optional,则应该避免返回NULL,而应该让返回值指向一个包含NULL对象的Optional实例。Optional的出现为NULL判断、过滤操作、映射操作等提供了函数式适配入口,它算是Java引入函数式编程的一个重要的里程碑。本文新增一个Asciidoc的预览模式,可以体验一下Spring官方文档的感觉:Github Page:www.throwable.club/adoc/201908…Coding Page:throwable.coding.me/adoc/201908…Optional各个方法源码分析和使用场景Optional的源码比较简单,归根于它是一个简单的对象容器。下面会结合源码分析它的所有构造、属性、方法和对应的使用场景。Optional属性和构造Optional的属性和构造如下:public final class Optional<T> { // 这个是通用的代表NULL值的Optional实例 private static final Optional<?> EMPTY = new Optional<>(); // 泛型类型的对象实例 private final T value; // 实例化Optional,注意是私有修饰符,value置为NULL private Optional() { this.value = null; // 直接返回内部的EMPTY实例 public static<T> Optional<T> empty() { @SuppressWarnings("unchecked") Optional<T> t = (Optional<T>) EMPTY; return t; // 通过value实例化Optional,如果value为NULL则抛出NPE private Optional(T value) { this.value = Objects.requireNonNull(value); // 通过value实例化Optional,如果value为NULL则抛出NPE,实际上就是使用Optional(T value) public static <T> Optional<T> of(T value) { return new Optional<>(value); // 如果value为NULL则返回EMPTY实例,否则调用Optional#of(value) public static <T> Optional<T> ofNullable(T value) { return value == null ? empty() : of(value); // 暂时省略其他代码 复制代码如果明确一个对象实例不为NULL的时候,应该使用Optional#of(),例如:Order o = selectByOrderId(orderId); assert null != o Optional op = Optional.of(o); 复制代码如果无法明确一个对象实例是否为NULL的时候,应该使用Optional#ofNullable(),例如:Optional op = Optional.ofNullable(selectByOrderId(orderId)); 复制代码明确表示一个持有NULL值的Optional实例可以使用Optional.empty()。get()方法// 如果value为空,则抛出NPE,否则直接返回value public T get() { if (value == null) { throw new NoSuchElementException("No value present"); return value; 复制代码get()方法一般是需要明确value不为NULL的时候使用,它做了先验value的存在性。例如:Order o = selectByOrderId(orderId); assert null != o Optional op = Optional.of(o); Order value = op.get(); 复制代码isPresent()方法// 判断value是否存在,不为NULL则返回true,如果为NULL则返回false public boolean isPresent() { return value != null; 复制代码举个例子:Order o = selectByOrderId(orderId); boolean existed = Optional.ofNullable(o).isPresent(); 复制代码isEmpty()方法isEmpty()是JDK11引入的方法,是isPresent()的反向判断:// 判断value是否存在,为NULL则返回true,为非NULL则返回false public boolean isEmpty() { return value == null; 复制代码ifPresent()方法ifPresent()方法的作用是:如果value不为NULL,则使用value调用消费者函数式接口的消费方法Consumer#accept():public void ifPresent(Consumer<? super T> action) { if (value != null) { action.accept(value); 复制代码例如:Optional.ofNullable(selectByOrderId(orderId)).ifPresent(o-> LOGGER.info("订单ID:{}",o.getOrderId()); 复制代码ifPresentOrElse()方法ifPresentOrElse()方法是JDK9新增的方法,它是ifPresent()方法的加强版,如果value不为NULL,则使用value调用消费者函数式接口的消费方法Consumer#accept(),如果value为NULL则执行Runnable#run():public void ifPresentOrElse(Consumer<? super T> action, Runnable emptyAction) { if (value != null) { action.accept(value); } else { emptyAction.run(); 复制代码例如:String orderId = "xxxx"; Optional.ofNullable(selectByOrderId(orderId)).ifPresentOrElse(o-> LOGGER.info("订单ID:{}",o.getOrderId()), ()-> LOGGER.info("订单{}不存在",o.getOrderId())); 复制代码filter()方法public Optional<T> filter(Predicate<? super T> predicate) { // 判断predicate不能为NULL Objects.requireNonNull(predicate); // value为NULL,说明是空实例,则直接返回自身 if (!isPresent()) { return this; } else { // value不为NULL,则通过predicate判断,命中返回自身,不命中则返回空实例empty return predicate.test(value) ? this : empty(); 复制代码这个方法的功能是简单的过滤功能,容器持有对象value非NULL会做一次判断,决定返回自身实例还是empty()。例如:Optional.ofNullable(selectByOrderId(orderId)).filter(o -> o.getStatus() == 1).ifPresent(o-> LOGGER.info("订单{}的状态为1",o.getOrderId)); 复制代码map()方法map()是简单的值映射操作:public <U> Optional<U> map(Function<? super T, ? extends U> mapper) { // 判断mapper不能为NULL Objects.requireNonNull(mapper); // value为NULL,说明是空实例,则直接返回empty() if (!isPresent()) { return empty(); } else { // value不为NULL,通过mapper转换类型,重新封装为可空的Optional实例 return Optional.ofNullable(mapper.apply(value)); 复制代码API注释里面的一个例子:List<URI> uris = ...; // 找到URI列表中未处理的URI对应的路径 Optional<Path> p = uris.stream().filter(uri -> !isProcessedYet(uri)).findFirst().map(Paths::get); 复制代码flatMap()方法flatMap()方法也是一个映射操作,不过映射的Optional类型返回值直接由外部决定,不需要通过值重新封装为Optional实例:public <U> Optional<U> flatMap(Function<? super T, ? extends Optional<? extends U>> mapper) { // mapper存在性判断 Objects.requireNonNull(mapper); // value为NULL,说明是空实例,则直接返回empty() if (!isPresent()) { return empty(); } else { // value不为NULL,通过mapper转换,直接返回mapper的返回值,做一次空判断 @SuppressWarnings("unchecked") Optional<U> r = (Optional<U>) mapper.apply(value); return Objects.requireNonNull(r); 复制代码例如:class OptionalOrderFactory{ static Optional<Order> create(String id){ //省略... String orderId = "xxx"; Optional<Order> op = Optional.of(orderId).flatMap(id -> OptionalOrderFactory.create(id)); 复制代码or()方法public Optional<T> or(Supplier<? extends Optional<? extends T>> supplier) { // supplier存在性判断 Objects.requireNonNull(supplier); // value不为NULL,则直接返回自身 if (isPresent()) { return this; } else { // value为NULL,则返回supplier提供的Optional实例,做一次空判断 @SuppressWarnings("unchecked") Optional<T> r = (Optional<T>) supplier.get(); return Objects.requireNonNull(r); 复制代码例如:Order a = null; Order b = select(); // 拿到的就是b订单实例包装的Optional Optional<Order> op = Optional.ofNullable(a).or(b); 复制代码stream()方法// 对value做NULL判断,转换为Stream类型 public Stream<T> stream() { if (!isPresent()) { return Stream.empty(); } else { return Stream.of(value); 复制代码orElse()方法// 值不为NULL则直接返回value,否则返回other public T orElse(T other) { return value != null ? value : other; 复制代码orElse()就是常见的提供默认值兜底的方法,例如:String v1 = null; String v2 = "default"; // 拿到的就是v2对应的"default"值 String value = Optional.ofNullable(v1).orElse(v2); 复制代码orElseGet()方法// 值不为NULL则直接返回value,否则返回Supplier#get() public T orElseGet(Supplier<? extends T> supplier) { return value != null ? value : supplier.get(); 复制代码orElseGet()只是orElse()方法的升级版,例如:String v1 = null; Supplier<String> v2 = () -> "default"; // 拿到的就是v2对应的"default"值 String value = Optional.ofNullable(v1).orElseGet(v2); 复制代码orElseThrow()方法// 如果值为NULL,则抛出NoSuchElementException,否则直接返回value public T orElseThrow() { if (value == null) { throw new NoSuchElementException("No value present"); return value; // 如果值不为NULL,则直接返回value,否则返回Supplier#get()提供的异常实例 public <X extends Throwable> T orElseThrow(Supplier<? extends X> exceptionSupplier) throws X { if (value != null) { return value; } else { throw exceptionSupplier.get(); 复制代码例如:Optional.ofNullable(orderInfoVo.getAmount()).orElseThrow(()-> new IllegalArgumentException(String.format("%s订单的amount不能为NULL",orderInfoVo.getOrderId()))); 复制代码equals()和hashCode()方法public boolean equals(Object obj) { if (this == obj) { return true; if (!(obj instanceof Optional)) { return false; Optional<?> other = (Optional<?>) obj; return Objects.equals(value, other.value); public int hashCode() { return Objects.hashCode(value); 复制代码这两个方法都是比较value,说明了Optional实例如果使用于HashMap的KEY,只要value相同,对于HashMap就是同一个KEY。如:Map<Optional,Boolean> map = new HashMap<>(); Optional<String> op1 = Optional.of("throwable"); map.put(op1, true); Optional<String> op2 = Optional.of("throwable"); map.put(op2, false); // 输出false System.out.println(map.get(op1)); 复制代码Optional实战下面展示一下Optional的一些常见的使用场景。空判断空判断主要是用于不知道当前对象是否为NULL的时候,需要设置对象的属性。不使用Optional时候的代码如下:if(null != order){ order.setAmount(orderInfoVo.getAmount()); 复制代码使用Optional时候的代码如下:Optional.ofNullable(order).ifPresent(o -> o.setAmount(orderInfoVo.getAmount())); // 如果判断空的对象是OrderInfoVo如下 Order o = select(); OrderInfoVo vo = ... Optional.ofNullable(vo).ifPresent(v -> o.setAmount(v.getAmount())); 复制代码使用Optional实现空判断的好处是只有一个属性设值的时候可以压缩代码为一行,这样做的话,代码会相对简洁。断言在维护一些老旧的系统的时候,很多情况下外部的传参没有做空判断,因此需要写一些断言代码如:if (null == orderInfoVo.getAmount()){ throw new IllegalArgumentException(String.format("%s订单的amount不能为NULL",orderInfoVo.getOrderId())); if (StringUtils.isBlank(orderInfoVo.getAddress()){ throw new IllegalArgumentException(String.format("%s订单的address不能为空",orderInfoVo.getOrderId())); 复制代码使用Optional后的断言代码如下:Optional.ofNullable(orderInfoVo.getAmount()).orElseThrow(()-> new IllegalArgumentException(String.format("%s订单的amount不能为NULL",orderInfoVo.getOrderId()))); Optional.ofNullable(orderInfoVo.getAddress()).orElseThrow(()-> new IllegalArgumentException(String.format("%s订单的address不能为空",orderInfoVo.getOrderId()))); 复制代码综合仿真案例下面是一个仿真案例,模拟的步骤如下:给出客户ID列表查询客户列表。基于存在的客户列表中的客户ID查询订单列表。基于订单列表转换为订单DTO视图列表。@Data static class Customer { private Long id; @Data static class Order { private Long id; private String orderId; private Long customerId; @Data static class OrderDto { private String orderId; // 模拟客户查询 private static List<Customer> selectCustomers(List<Long> ids) { return null; // 模拟订单查询 private static List<Order> selectOrders(List<Long> customerIds) { return null; // main方法 public static void main(String[] args) throws Exception { List<Long> ids = new ArrayList<>(); List<OrderDto> view = Optional.ofNullable(selectCustomers(ids)) .filter(cs -> !cs.isEmpty()) .map(cs -> selectOrders(cs.stream().map(Customer::getId).collect(Collectors.toList()))) .map(orders -> { List<OrderDto> dtoList = new ArrayList<>(); orders.forEach(o -> { OrderDto dto = new OrderDto(); dto.setOrderId(o.getOrderId()); dtoList.add(dto); return dtoList; }).orElse(Collections.emptyList()); 复制代码小结Optional本质是一个对象容器,它的特征如下:Optional作为一个容器承载对象,提供方法适配部分函数式接口,结合部分函数式接口提供方法实现NULL判断、过滤操作、安全取值、映射操作等等。Optional一般使用场景是用于方法返回值的包装,当然也可以作为临时变量从而享受函数式接口的便捷功能。Optional只是一个简化操作的工具,可以解决多层嵌套代码的节点空判断问题(例如简化箭头型代码)。Optional并非银弹。这里提到箭头型代码,下面尝试用常规方法和Optional分别解决:// 假设VO有多个层级,每个层级都不知道父节点是否为NULL,如下 // - OrderInfoVo // - UserInfoVo // - AddressInfoVo // - address(属性) // 假设我要为address属性赋值,那么就会产生箭头型代码。 // 常规方法 String address = "xxx"; OrderInfoVo o = ...; if(null != o){ UserInfoVo uiv = o.getUserInfoVo(); if (null != uiv){ AddressInfoVo aiv = uiv.getAddressInfoVo(); if (null != aiv){ aiv.setAddress(address); // 使用Optional String address = "xxx"; OrderInfoVo o = null; Optional.ofNullable(o) .map(OrderInfoVo::getUserInfoVo) .map(UserInfoVo::getAddressInfoVo) .ifPresent(a -> a.setAddress(address)); 复制代码使用Optional解决箭头型代码,通过映射操作map()能减少大量的if和NULL判断分支,使得代码更加简洁。有些开发者提议把DAO方法的返回值类型定义为Optional,笔者对此持中立态度,原因是:Optional是JDK1.8引入,低版本的JDK并不能使用,不是所有的系统都能平滑迁移到JDK1.8+。并不是所有人都热衷于函数式编程,因为它带来了便捷的同时转变了代码的阅读逻辑(有些人甚至会认为降低了代码的可读性)。附件Github Page:www.throwable.club/2019/08/07/…Coding Page:throwable.coding.me/2019/08/07/…Markdown或Asciidoc文件:github.com/zjcscut/blo…(本文完 c-2-d e-a-20190805 by throwable)

Spring Cloud Gateway-ServerWebExchange核心方法与请求或者响应内容的修改(下)

修改响应体修改响应体的需求也是比较常见的,具体的做法和修改请求体差不多。例如我们想要实现下面的功能:第三方服务请求经过网关,原始报文是密文,我们需要在网关实现密文解密,然后把解密后的明文路由到下游服务,下游服务处理成功响应明文,需要在网关把明文加密成密文再返回到第三方服务。现在简化整个流程,用AES加密算法,统一密码为字符串"throwable",假设请求报文和响应报文明文如下:// 请求密文 "serialNumber": "请求流水号", "payload" : "加密后的请求消息载荷" // 请求明文(仅仅作为提示) "serialNumber": "请求流水号", "payload" : "{\"name:\":\"doge\"}" // 响应密文 "code": 200, "message":"ok", "payload" : "加密后的响应消息载荷" // 响应明文(仅仅作为提示) "code": 200, "message":"ok", "payload" : "{\"name:\":\"doge\",\"age\":26}" 复制代码为了方便一些加解密或者编码解码的实现,需要引入Apache的commons-codec类库:<dependency> <groupId>commons-codec</groupId> <artifactId>commons-codec</artifactId> <version>1.12</version> </dependency> 复制代码这里定义一个全局过滤器专门处理加解密,实际上最好结合真实的场景决定是否适合全局过滤器,这里只是一个示例:// AES加解密工具类 public enum AesUtils { // 单例 private static final String PASSWORD = "throwable"; private static final String KEY_ALGORITHM = "AES"; private static final String SECURE_RANDOM_ALGORITHM = "SHA1PRNG"; private static final String DEFAULT_CIPHER_ALGORITHM = "AES/ECB/PKCS5Padding"; public String encrypt(String content) { try { Cipher cipher = Cipher.getInstance(DEFAULT_CIPHER_ALGORITHM); cipher.init(Cipher.ENCRYPT_MODE, provideSecretKey()); return Hex.encodeHexString(cipher.doFinal(content.getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw new IllegalArgumentException(e); public byte[] decrypt(String content) { try { Cipher cipher = Cipher.getInstance(DEFAULT_CIPHER_ALGORITHM); cipher.init(Cipher.DECRYPT_MODE, provideSecretKey()); return cipher.doFinal(Hex.decodeHex(content)); } catch (Exception e) { throw new IllegalArgumentException(e); private SecretKey provideSecretKey() { try { KeyGenerator keyGen = KeyGenerator.getInstance(KEY_ALGORITHM); SecureRandom secureRandom = SecureRandom.getInstance(SECURE_RANDOM_ALGORITHM); secureRandom.setSeed(PASSWORD.getBytes(StandardCharsets.UTF_8)); keyGen.init(128, secureRandom); return new SecretKeySpec(keyGen.generateKey().getEncoded(), KEY_ALGORITHM); } catch (Exception e) { throw new IllegalArgumentException(e); // EncryptionGlobalFilter @Slf4j @Component public class EncryptionGlobalFilter implements GlobalFilter, Ordered { @Autowired private ObjectMapper objectMapper; @Override public int getOrder() { return -2; @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); ServerHttpResponse response = exchange.getResponse(); DataBufferFactory bufferFactory = exchange.getResponse().bufferFactory(); ServerHttpRequestDecorator requestDecorator = processRequest(request, bufferFactory); ServerHttpResponseDecorator responseDecorator = processResponse(response, bufferFactory); return chain.filter(exchange.mutate().request(requestDecorator).response(responseDecorator).build()); private ServerHttpRequestDecorator processRequest(ServerHttpRequest request, DataBufferFactory bufferFactory) { Flux<DataBuffer> body = request.getBody(); DataBufferHolder holder = new DataBufferHolder(); body.subscribe(dataBuffer -> { int len = dataBuffer.readableByteCount(); holder.length = len; byte[] bytes = new byte[len]; dataBuffer.read(bytes); DataBufferUtils.release(dataBuffer); String text = new String(bytes, StandardCharsets.UTF_8); JsonNode jsonNode = readNode(text); JsonNode payload = jsonNode.get("payload"); String payloadText = payload.asText(); byte[] content = AesUtils.X.decrypt(payloadText); String requestBody = new String(content, StandardCharsets.UTF_8); log.info("修改请求体payload,修改前:{},修改后:{}", payloadText, requestBody); rewritePayloadNode(requestBody, jsonNode); DataBuffer data = bufferFactory.allocateBuffer(); data.write(jsonNode.toString().getBytes(StandardCharsets.UTF_8)); holder.dataBuffer = data; HttpHeaders headers = new HttpHeaders(); headers.putAll(request.getHeaders()); headers.remove(HttpHeaders.CONTENT_LENGTH); return new ServerHttpRequestDecorator(request) { @Override public HttpHeaders getHeaders() { int contentLength = holder.length; if (contentLength > 0) { headers.setContentLength(contentLength); } else { headers.set(HttpHeaders.TRANSFER_ENCODING, "chunked"); return headers; @Override public Flux<DataBuffer> getBody() { return Flux.just(holder.dataBuffer); private ServerHttpResponseDecorator processResponse(ServerHttpResponse response, DataBufferFactory bufferFactory) { return new ServerHttpResponseDecorator(response) { @SuppressWarnings("unchecked") @Override public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) { if (body instanceof Flux) { Flux<? extends DataBuffer> flux = (Flux<? extends DataBuffer>) body; return super.writeWith(flux.map(buffer -> { CharBuffer charBuffer = StandardCharsets.UTF_8.decode(buffer.asByteBuffer()); DataBufferUtils.release(buffer); JsonNode jsonNode = readNode(charBuffer.toString()); JsonNode payload = jsonNode.get("payload"); String text = payload.toString(); String content = AesUtils.X.encrypt(text); log.info("修改响应体payload,修改前:{},修改后:{}", text, content); setPayloadTextNode(content, jsonNode); return bufferFactory.wrap(jsonNode.toString().getBytes(StandardCharsets.UTF_8)); return super.writeWith(body); private void rewritePayloadNode(String text, JsonNode root) { try { JsonNode node = objectMapper.readTree(text); ObjectNode objectNode = (ObjectNode) root; objectNode.set("payload", node); } catch (Exception e) { throw new IllegalStateException(e); private void setPayloadTextNode(String text, JsonNode root) { try { ObjectNode objectNode = (ObjectNode) root; objectNode.set("payload", new TextNode(text)); } catch (Exception e) { throw new IllegalStateException(e); private JsonNode readNode(String in) { try { return objectMapper.readTree(in); } catch (Exception e) { throw new IllegalStateException(e); private class DataBufferHolder { DataBuffer dataBuffer; int length; 复制代码先准备一份密文:Map<String, Object> json = new HashMap<>(8); json.put("serialNumber", "请求流水号"); String content = "{\"name\": \"doge\"}"; json.put("payload", AesUtils.X.encrypt(content)); System.out.println(new ObjectMapper().writeValueAsString(json)); // 输出 {"serialNumber":"请求流水号","payload":"144e3dc734743f5709f1adf857bca473da683246fd612f86ac70edeb5f2d2729"} 复制代码模拟请求:POST /order/json HTTP/1.1 Host: localhost:9090 accessToken: 10086 Content-Type: application/json User-Agent: PostmanRuntime/7.13.0 Accept: */* Cache-Control: no-cache Postman-Token: bda07fc3-ea1a-478c-b4d7-754fe6f37200,634734d9-feed-4fc9-ba20-7618bd986e1c Host: localhost:9090 cookie: customCookieName=customCookieValue accept-encoding: gzip, deflate content-length: 104 Connection: keep-alive cache-control: no-cache "serialNumber": "请求流水号", "payload": "FE49xzR0P1cJ8a34V7ykc9poMkb9YS+GrHDt618tJyk=" // 响应结果 "serialNumber": "请求流水号", "payload": "oo/K1igg2t/S8EExkBVGWOfI1gAh5pBpZ0wyjNPW6e8=" # <--- 解密后:{"name":"doge","age":26} 复制代码遇到的问题:必须实现Ordered接口,返回一个小于-1的order值,这是因为NettyWriteResponseFilter的order值为-1,我们需要覆盖返回响应体的逻辑,自定义的GlobalFilter必须比NettyWriteResponseFilter优先执行。网关每次重启之后,第一个请求总是无法从原始的ServerHttpRequest读取到有效的Body,准确来说出现的现象是NettyRoutingFilter调用ServerHttpRequest#getBody()的时候获取到一个空的对象,导致空指针;奇怪的是从第二个请求开始就能正常调用。笔者把Spring Cloud Gateway的版本降低到Finchley.SR3,Spring Boot的版本降低到2.0.8.RELEASE,问题不再出现,初步确定是Spring Cloud Gateway版本升级导致的兼容性问题或者是BUG。最重要的是用到了ServerHttpResponse装饰器ServerHttpResponseDecorator,主要覆盖写入响应体数据缓冲区的部分,至于怎么处理其他逻辑需要自行考虑,这里只是做一个简单的示范。一般的代码逻辑如下:ServerHttpResponse response = exchange.getResponse(); ServerHttpResponseDecorator responseDecorator = new ServerHttpResponseDecorator(response) { @Override public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) { if (body instanceof Flux) { Flux<? extends DataBuffer> flux = (Flux<? extends DataBuffer>) body; return super.writeWith(flux.map(buffer -> { // buffer就是原始的响应数据的缓冲区 // 下面处理完毕之后返回新的响应数据的缓冲区即可 return bufferFactory.wrap(...); return super.writeWith(body); return chain.filter(exchange.mutate().response(responseDecorator).build()); 复制代码请求体或者响应体报文过大的问题有热心的同学告诉笔者,如果请求报文过大或者响应报文过大的时候,前面两节的修改请求和响应报文的方法会出现问题,这里尝试重现一下遇到的具体问题。先把请求报文尝试加长:Map<String, Object> json = new HashMap<>(8); json.put("serialNumber", "请求流水号"); StringBuilder builder = new StringBuilder(); for (int i = 0; i < 1000; i++) { builder.append("doge"); String content = String.format("{\"name\": \"%s\"}", builder.toString()); json.put("payload", AesUtils.X.encrypt(content)); System.out.println(new ObjectMapper().writeValueAsString(json)); // 请求的JSON报文如下: "serialNumber": "请求流水号", "payload": "0Dcf2plFpESprKjkdqNHM8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/zyJ4ipyLGvo5LX87d9oDAs=" 复制代码用上面的请求报文发起请求,确实存在问题:主要问题是:请求体包数据装成的Flux<DataBuffer>实例被订阅之后,读取到的字节数组的长度被截断了,提供的原始请求报文里面字符串长度要大于1000,转换成byte数组绝对要大于1000,但是上面的示例中只读取到长度为673的byte数组。读取到的字节数组被截断后,则使用Jackson进行反序列化的时候提示没有读取到字符串的EOF标识,导致反序列化失败。既然遇到了问题,就想办法解决。首先第一步定位一下是什么原因,直觉告诉笔者:要开启一下DEBUG日志进行观察,如果还没有头绪可能要跟踪一下源码。开启DEBUG日志级别之后做一次请求,发现了一些可疑的日志信息:2019-05-19 11:16:15.660 [reactor-http-nio-2] DEBUG reactor.ipc.netty.http.server.HttpServer - [id: 0xa9b527e5, L:/0:0:0:0:0:0:0:1:9090 - R:/0:0:0:0:0:0:0:1:58012] READ COMPLETE 2019-05-19 11:16:15.660 [reactor-http-nio-2] DEBUG reactor.ipc.netty.http.server.HttpServer - [id: 0xa9b527e5, L:/0:0:0:0:0:0:0:1:9090 ! R:/0:0:0:0:0:0:0:1:58012] INACTIVE 2019-05-19 11:16:15.660 [reactor-http-nio-3] DEBUG reactor.ipc.netty.http.server.HttpServer - [id: 0x5554e091, L:/0:0:0:0:0:0:0:1:9090 - R:/0:0:0:0:0:0:0:1:58013] READ: 1024B +-------------------------------------------------+ | 0 1 2 3 4 5 6 7 8 9 a b c d e f | +--------+-------------------------------------------------+----------------+ |00000000| 50 4f 53 54 20 2f 6f 72 64 65 72 2f 6a 73 6f 6e |POST /order/json| |00000010| 20 48 54 54 50 2f 31 2e 31 0d 0a 61 63 63 65 73 | HTTP/1.1..acces| |00000020| 73 54 6f 6b 65 6e 3a 20 31 30 30 38 36 0d 0a 43 |sToken: 10086..C| |00000030| 6f 6e 74 65 6e 74 2d 54 79 70 65 3a 20 61 70 70 |ontent-Type: app| |00000040| 6c 69 63 61 74 69 6f 6e 2f 6a 73 6f 6e 0d 0a 55 |lication/json..U| |00000050| 73 65 72 2d 41 67 65 6e 74 3a 20 50 6f 73 74 6d |ser-Agent: Postm| |00000060| 61 6e 52 75 6e 74 69 6d 65 2f 37 2e 31 33 2e 30 |anRuntime/7.13.0| |00000070| 0d 0a 41 63 63 65 70 74 3a 20 2a 2f 2a 0d 0a 43 |..Accept: */*..C| |00000080| 61 63 68 65 2d 43 6f 6e 74 72 6f 6c 3a 20 6e 6f |ache-Control: no| |00000090| 2d 63 61 63 68 65 0d 0a 50 6f 73 74 6d 61 6e 2d |-cache..Postman-| |000000a0| 54 6f 6b 65 6e 3a 20 31 31 32 30 38 64 35 39 2d |Token: 11208d59-| |000000b0| 65 61 34 61 2d 34 62 39 63 2d 61 30 33 39 2d 30 |ea4a-4b9c-a039-0| |000000c0| 30 65 36 64 38 61 30 65 33 65 66 0d 0a 48 6f 73 |0e6d8a0e3ef..Hos| |000000d0| 74 3a 20 6c 6f 63 61 6c 68 6f 73 74 3a 39 30 39 |t: localhost:909| |000000e0| 30 0d 0a 63 6f 6f 6b 69 65 3a 20 63 75 73 74 6f |0..cookie: custo| |000000f0| 6d 43 6f 6f 6b 69 65 4e 61 6d 65 3d 63 75 73 74 |mCookieName=cust| |00000100| 6f 6d 43 6f 6f 6b 69 65 56 61 6c 75 65 0d 0a 61 |omCookieValue..a| |00000110| 63 63 65 70 74 2d 65 6e 63 6f 64 69 6e 67 3a 20 |ccept-encoding: | |00000120| 67 7a 69 70 2c 20 64 65 66 6c 61 74 65 0d 0a 63 |gzip, deflate..c| |00000130| 6f 6e 74 65 6e 74 2d 6c 65 6e 67 74 68 3a 20 35 |ontent-length: 5| |00000140| 34 31 36 0d 0a 43 6f 6e 6e 65 63 74 69 6f 6e 3a |416..Connection:| |00000150| 20 6b 65 65 70 2d 61 6c 69 76 65 0d 0a 0d 0a 7b | keep-alive....{| |00000160| 0a 20 20 20 20 22 73 65 72 69 61 6c 4e 75 6d 62 |. "serialNumb| |00000170| 65 72 22 3a 20 22 e8 af b7 e6 b1 82 e6 b5 81 e6 |er": "..........| |00000180| b0 b4 e5 8f b7 22 2c 0a 20 20 20 20 22 70 61 79 |.....",. "pay| |00000190| 6c 6f 61 64 22 3a 20 22 30 44 63 66 32 70 6c 46 |load": "0Dcf2plF| |000001a0| 70 45 53 70 72 4b 6a 6b 64 71 4e 48 4d 38 6a 6a |pESprKjkdqNHM8jj| |000001b0| 49 41 72 6b 64 37 58 57 35 4c 6c 32 2f 71 61 42 |IArkd7XW5Ll2/qaB| |000001c0| 71 76 2f 49 34 79 41 4b 35 48 65 31 31 75 53 35 |qv/I4yAK5He11uS5| |000001d0| 64 76 36 6d 67 61 72 2f 79 4f 4d 67 43 75 52 33 |dv6mgar/yOMgCuR3| |000001e0| 74 64 62 6b 75 58 62 2b 70 6f 47 71 2f 38 6a 6a |tdbkuXb+poGq/8jj| |000001f0| 49 41 72 6b 64 37 58 57 35 4c 6c 32 2f 71 61 42 |IArkd7XW5Ll2/qaB| |00000200| 71 76 2f 49 34 79 41 4b 35 48 65 31 31 75 53 35 |qv/I4yAK5He11uS5| |00000210| 64 76 36 6d 67 61 72 2f 79 4f 4d 67 43 75 52 33 |dv6mgar/yOMgCuR3| |00000220| 74 64 62 6b 75 58 62 2b 70 6f 47 71 2f 38 6a 6a |tdbkuXb+poGq/8jj| |00000230| 49 41 72 6b 64 37 58 57 35 4c 6c 32 2f 71 61 42 |IArkd7XW5Ll2/qaB| |00000240| 71 76 2f 49 34 79 41 4b 35 48 65 31 31 75 53 35 |qv/I4yAK5He11uS5| |00000250| 64 76 36 6d 67 61 72 2f 79 4f 4d 67 43 75 52 33 |dv6mgar/yOMgCuR3| |00000260| 74 64 62 6b 75 58 62 2b 70 6f 47 71 2f 38 6a 6a |tdbkuXb+poGq/8jj| |00000270| 49 41 72 6b 64 37 58 57 35 4c 6c 32 2f 71 61 42 |IArkd7XW5Ll2/qaB| |00000280| 71 76 2f 49 34 79 41 4b 35 48 65 31 31 75 53 35 |qv/I4yAK5He11uS5| |00000290| 64 76 36 6d 67 61 72 2f 79 4f 4d 67 43 75 52 33 |dv6mgar/yOMgCuR3| |000002a0| 74 64 62 6b 75 58 62 2b 70 6f 47 71 2f 38 6a 6a |tdbkuXb+poGq/8jj| |000002b0| 49 41 72 6b 64 37 58 57 35 4c 6c 32 2f 71 61 42 |IArkd7XW5Ll2/qaB| |000002c0| 71 76 2f 49 34 79 41 4b 35 48 65 31 31 75 53 35 |qv/I4yAK5He11uS5| |000002d0| 64 76 36 6d 67 61 72 2f 79 4f 4d 67 43 75 52 33 |dv6mgar/yOMgCuR3| |000002e0| 74 64 62 6b 75 58 62 2b 70 6f 47 71 2f 38 6a 6a |tdbkuXb+poGq/8jj| |000002f0| 49 41 72 6b 64 37 58 57 35 4c 6c 32 2f 71 61 42 |IArkd7XW5Ll2/qaB| |00000300| 71 76 2f 49 34 79 41 4b 35 48 65 31 31 75 53 35 |qv/I4yAK5He11uS5| |00000310| 64 76 36 6d 67 61 72 2f 79 4f 4d 67 43 75 52 33 |dv6mgar/yOMgCuR3| |00000320| 74 64 62 6b 75 58 62 2b 70 6f 47 71 2f 38 6a 6a |tdbkuXb+poGq/8jj| |00000330| 49 41 72 6b 64 37 58 57 35 4c 6c 32 2f 71 61 42 |IArkd7XW5Ll2/qaB| |00000340| 71 76 2f 49 34 79 41 4b 35 48 65 31 31 75 53 35 |qv/I4yAK5He11uS5| |00000350| 64 76 36 6d 67 61 72 2f 79 4f 4d 67 43 75 52 33 |dv6mgar/yOMgCuR3| |00000360| 74 64 62 6b 75 58 62 2b 70 6f 47 71 2f 38 6a 6a |tdbkuXb+poGq/8jj| |00000370| 49 41 72 6b 64 37 58 57 35 4c 6c 32 2f 71 61 42 |IArkd7XW5Ll2/qaB| |00000380| 71 76 2f 49 34 79 41 4b 35 48 65 31 31 75 53 35 |qv/I4yAK5He11uS5| |00000390| 64 76 36 6d 67 61 72 2f 79 4f 4d 67 43 75 52 33 |dv6mgar/yOMgCuR3| |000003a0| 74 64 62 6b 75 58 62 2b 70 6f 47 71 2f 38 6a 6a |tdbkuXb+poGq/8jj| |000003b0| 49 41 72 6b 64 37 58 57 35 4c 6c 32 2f 71 61 42 |IArkd7XW5Ll2/qaB| |000003c0| 71 76 2f 49 34 79 41 4b 35 48 65 31 31 75 53 35 |qv/I4yAK5He11uS5| |000003d0| 64 76 36 6d 67 61 72 2f 79 4f 4d 67 43 75 52 33 |dv6mgar/yOMgCuR3| |000003e0| 74 64 62 6b 75 58 62 2b 70 6f 47 71 2f 38 6a 6a |tdbkuXb+poGq/8jj| |000003f0| 49 41 72 6b 64 37 58 57 35 4c 6c 32 2f 71 61 42 |IArkd7XW5Ll2/qaB| +--------+-------------------------------------------------+----------------+ 2019-05-19 11:16:15.662 [reactor-http-nio-2] DEBUG reactor.ipc.netty.http.server.HttpServer - [id: 0xa9b527e5, L:/0:0:0:0:0:0:0:1:9090 ! R:/0:0:0:0:0:0:0:1:58012] UNREGISTERED 2019-05-19 11:16:15.665 [reactor-http-nio-3] DEBUG reactor.ipc.netty.http.server.HttpServerOperations - [id: 0x5554e091, L:/0:0:0:0:0:0:0:1:9090 - R:/0:0:0:0:0:0:0:1:58013] Increasing pending responses, now 1 2019-05-19 11:16:15.671 [reactor-http-nio-3] DEBUG reactor.ipc.netty.http.server.HttpServer - [id: 0x5554e091, L:/0:0:0:0:0:0:0:1:9090 - R:/0:0:0:0:0:0:0:1:58013] READ COMPLETE 复制代码注意一下关键字READ: 1024B,这里应该是底层的Reactor-Netty读取的最大数据报的长度限制,打印出来的数据报刚好也是1024B的大小,这个应该就是导致请求体被截断的根本原因;这个问题不单单会出现在请求体的获取,也会出现在响应体的写入。既然这个是共性的问题,那么项目Github上肯定有对应的Issue,找到一个互动比较长的gateway request size limit 1024B because netty default limit 1024,how to solve it? #581,从回答来看,官方建议使用ModifyRequestBodyGatewayFilterFactory和ModifyResponseBodyGatewayFilterFactory完成对应的功能。这里可以尝试借鉴一下ModifyRequestBodyGatewayFilterFactory的实现方式修改之前的代码,因为代码的逻辑比较长和复杂,解密请求体的过滤器拆分到新的类RequestEncryptionGlobalFilter,加密响应体的过滤器拆分到ResponseDecryptionGlobalFilter:RequestEncryptionGlobalFilter的代码如下:@Slf4j @Component public class RequestEncryptionGlobalFilter implements GlobalFilter, Ordered { @Autowired private ObjectMapper objectMapper; private final List<HttpMessageReader<?>> messageReaders = HandlerStrategies.withDefaults().messageReaders(); @Override public int getOrder() { return -2; @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { return processRequest(exchange, chain); private Mono<Void> processRequest(ServerWebExchange exchange, GatewayFilterChain chain) { ServerRequest serverRequest = new DefaultServerRequest(exchange, messageReaders); DataBufferFactory bufferFactory = exchange.getResponse().bufferFactory(); Mono<String> rawBody = serverRequest.bodyToMono(String.class).map(s -> s); BodyInserter<Mono<String>, ReactiveHttpOutputMessage> bodyInserter = BodyInserters.fromPublisher(rawBody, String.class); HttpHeaders tempHeaders = new HttpHeaders(); tempHeaders.putAll(exchange.getRequest().getHeaders()); tempHeaders.remove(HttpHeaders.CONTENT_LENGTH); CachedBodyOutputMessage outputMessage = new CachedBodyOutputMessage(exchange, tempHeaders); return bodyInserter.insert(outputMessage, new BodyInserterContext()).then(Mono.defer(() -> { Flux<DataBuffer> body = outputMessage.getBody(); DataBufferHolder holder = new DataBufferHolder(); body.subscribe(dataBuffer -> { int len = dataBuffer.readableByteCount(); holder.length = len; byte[] bytes = new byte[len]; dataBuffer.read(bytes); DataBufferUtils.release(dataBuffer); String text = new String(bytes, StandardCharsets.UTF_8); JsonNode jsonNode = readNode(text); JsonNode payload = jsonNode.get("payload"); String payloadText = payload.asText(); byte[] content = AesUtils.X.decrypt(payloadText); String requestBody = new String(content, StandardCharsets.UTF_8); log.info("修改请求体payload,修改前:{},修改后:{}", payloadText, requestBody); rewritePayloadNode(requestBody, jsonNode); DataBuffer data = bufferFactory.allocateBuffer(); data.write(jsonNode.toString().getBytes(StandardCharsets.UTF_8)); holder.dataBuffer = data; ServerHttpRequestDecorator requestDecorator = new ServerHttpRequestDecorator(exchange.getRequest()) { @Override public HttpHeaders getHeaders() { long contentLength = tempHeaders.getContentLength(); HttpHeaders httpHeaders = new HttpHeaders(); httpHeaders.putAll(super.getHeaders()); if (contentLength > 0) { httpHeaders.setContentLength(contentLength); } else { httpHeaders.set(HttpHeaders.TRANSFER_ENCODING, "chunked"); return httpHeaders; @Override public Flux<DataBuffer> getBody() { return Flux.just(holder.dataBuffer); return chain.filter(exchange.mutate().request(requestDecorator).build()); private void rewritePayloadNode(String text, JsonNode root) { try { JsonNode node = objectMapper.readTree(text); ObjectNode objectNode = (ObjectNode) root; objectNode.set("payload", node); } catch (Exception e) { throw new IllegalStateException(e); private void setPayloadTextNode(String text, JsonNode root) { try { ObjectNode objectNode = (ObjectNode) root; objectNode.set("payload", new TextNode(text)); } catch (Exception e) { throw new IllegalStateException(e); private JsonNode readNode(String in) { try { return objectMapper.readTree(in); } catch (Exception e) { throw new IllegalStateException(e); private class DataBufferHolder { DataBuffer dataBuffer; int length; 复制代码ResponseDecryptionGlobalFilter的代码如下:@Slf4j @Component public class ResponseDecryptionGlobalFilter implements GlobalFilter, Ordered { @Autowired private ObjectMapper objectMapper; @Override public int getOrder() { return NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER - 1; @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { return processResponse(exchange, chain); private Mono<Void> processResponse(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpResponseDecorator responseDecorator = new ServerHttpResponseDecorator(exchange.getResponse()) { @Override public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) { String originalResponseContentType = exchange.getAttribute(ORIGINAL_RESPONSE_CONTENT_TYPE_ATTR); HttpHeaders httpHeaders = new HttpHeaders(); httpHeaders.add(HttpHeaders.CONTENT_TYPE, originalResponseContentType); ResponseAdapter responseAdapter = new ResponseAdapter(body, httpHeaders); DefaultClientResponse clientResponse = new DefaultClientResponse(responseAdapter, ExchangeStrategies.withDefaults()); Mono<String> rawBody = clientResponse.bodyToMono(String.class).map(s -> s); BodyInserter<Mono<String>, ReactiveHttpOutputMessage> bodyInserter = BodyInserters.fromPublisher(rawBody, String.class); CachedBodyOutputMessage outputMessage = new CachedBodyOutputMessage(exchange, exchange.getResponse().getHeaders()); return bodyInserter.insert(outputMessage, new BodyInserterContext()) .then(Mono.defer(() -> { Flux<DataBuffer> messageBody = outputMessage.getBody(); Flux<DataBuffer> flux = messageBody.map(buffer -> { CharBuffer charBuffer = StandardCharsets.UTF_8.decode(buffer.asByteBuffer()); DataBufferUtils.release(buffer); JsonNode jsonNode = readNode(charBuffer.toString()); JsonNode payload = jsonNode.get("payload"); String text = payload.toString(); String content = AesUtils.X.encrypt(text); log.info("修改响应体payload,修改前:{},修改后:{}", text, content); setPayloadTextNode(content, jsonNode); return getDelegate().bufferFactory().wrap(jsonNode.toString().getBytes(StandardCharsets.UTF_8)); HttpHeaders headers = getDelegate().getHeaders(); if (!headers.containsKey(HttpHeaders.TRANSFER_ENCODING)) { flux = flux.doOnNext(data -> headers.setContentLength(data.readableByteCount())); return getDelegate().writeWith(flux); return chain.filter(exchange.mutate().response(responseDecorator).build()); private void setPayloadTextNode(String text, JsonNode root) { try { ObjectNode objectNode = (ObjectNode) root; objectNode.set("payload", new TextNode(text)); } catch (Exception e) { throw new IllegalStateException(e); private JsonNode readNode(String in) { try { return objectMapper.readTree(in); } catch (Exception e) { throw new IllegalStateException(e); private class ResponseAdapter implements ClientHttpResponse { private final Flux<DataBuffer> flux; private final HttpHeaders headers; @SuppressWarnings("unchecked") private ResponseAdapter(Publisher<? extends DataBuffer> body, HttpHeaders headers) { this.headers = headers; if (body instanceof Flux) { flux = (Flux) body; } else { flux = ((Mono) body).flux(); @Override public Flux<DataBuffer> getBody() { return flux; @Override public HttpHeaders getHeaders() { return headers; @Override public HttpStatus getStatusCode() { return null; @Override public int getRawStatusCode() { return 0; @Override public MultiValueMap<String, ResponseCookie> getCookies() { return null; 复制代码模拟请求:POST /order/json HTTP/1.1 Host: localhost:9090 accessToken: 10086 Content-Type: application/json User-Agent: PostmanRuntime/7.13.0 Accept: */* Cache-Control: no-cache Postman-Token: 3a830202-f3d1-450e-839f-ae8f3b88bced,b229feb1-7c8b-4d25-a039-09345f3fe8f0 Host: localhost:9090 cookie: customCookieName=customCookieValue accept-encoding: gzip, deflate content-length: 5416 Connection: keep-alive cache-control: no-cache "serialNumber": "请求流水号", "payload": "0Dcf2plFpESprKjkdqNHM8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/8jjIArkd7XW5Ll2/qaBqv/I4yAK5He11uS5dv6mgar/yOMgCuR3tdbkuXb+poGq/zyJ4ipyLGvo5LX87d9oDAs=" // 响应 {"serialNumber":"请求流水号","userId":null,"payload":"7S2VqLu4J6LdW0As50JgZ0eFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+DkeFFoe2FbWytaYdpr2HPg5HhRaHthW1srWmHaa9hz4OR4UWh7YVtbK1ph2mvYc+Dm8rTVHylECORYnLNgnfWx0ENJ9a6E+abYhyFJ9zSIda"} 复制代码彻底解决了之前的请求或者响应报文截断的问题,笔者发现了很多博文都在(照搬)更改读取DataBuffer实例时候的代码逻辑,其实那段逻辑是不相关的,可以尝试用BufferedReader基于行读取然后用StringBuilder承载,或者像本文那样直接读取为byte数组等等,因为根本的原因是底层的Reactor-Netty的数据块读取大小限制导致获取到的DataBuffer实例里面的数据是不完整的,解决方案就是参照Spring Cloud Gateway本身提供的基础类库进行改造(暂时没发现有入口可以调整Reactor-Netty的配置),难度也不大。小结刚好遇到一个需求需要做网关的加解密包括请求体和响应体的修改,这里顺便把Spring Cloud Gateway一些涉及到这方面的一些内容梳理了一遍,顺便把坑踩了并且填完。下一步尝试按照目前官方提供的可用组件修改一下实现自定义的逻辑,包括Hystrix、基于Eureka和Ribbon的负载均衡、限流等等。

Spring Cloud Gateway-ServerWebExchange核心方法与请求或者响应内容的修改(上)

前提本文编写的时候使用的Spring Cloud Gateway版本为当时最新的版本Greenwich.SR1。我们在使用Spring Cloud Gateway的时候,注意到过滤器(包括GatewayFilter、GlobalFilter和过滤器链GatewayFilterChain),都依赖到ServerWebExchange:public interface GlobalFilter { Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain); public interface GatewayFilter extends ShortcutConfigurable { Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain); public interface GatewayFilterChain { Mono<Void> filter(ServerWebExchange exchange); 复制代码这里的设计和Servlet中的Filter是相似的,当前过滤器可以决定是否执行下一个过滤器的逻辑,由GatewayFilterChain#filter()是否被调用来决定。而ServerWebExchange就相当于当前请求和响应的上下文。ServerWebExchange实例不单存储了Request和Response对象,还提供了一些扩展方法,如果想实现改造请求参数或者响应参数,就必须深入了解ServerWebExchange。理解ServerWebExchange先看ServerWebExchange的注释:Contract for an HTTP request-response interaction. Provides access to the HTTP request and response and also exposes additional server-side processing related properties and features such as request attributes.翻译一下大概是:ServerWebExchange是一个HTTP请求-响应交互的契约。提供对HTTP请求和响应的访问,并公开额外的服务器端处理相关属性和特性,如请求属性。其实,ServerWebExchange命名为服务网络交换器,存放着重要的请求-响应属性、请求实例和响应实例等等,有点像Context的角色。ServerWebExchange接口ServerWebExchange接口的所有方法:public interface ServerWebExchange { // 日志前缀属性的KEY,值为org.springframework.web.server.ServerWebExchange.LOG_ID // 可以理解为 attributes.set("org.springframework.web.server.ServerWebExchange.LOG_ID","日志前缀的具体值"); // 作用是打印日志的时候会拼接这个KEY对饮的前缀值,默认值为"" String LOG_ID_ATTRIBUTE = ServerWebExchange.class.getName() + ".LOG_ID"; String getLogPrefix(); // 获取ServerHttpRequest对象 ServerHttpRequest getRequest(); // 获取ServerHttpResponse对象 ServerHttpResponse getResponse(); // 返回当前exchange的请求属性,返回结果是一个可变的Map Map<String, Object> getAttributes(); // 根据KEY获取请求属性 @Nullable default <T> T getAttribute(String name) { return (T) getAttributes().get(name); // 根据KEY获取请求属性,做了非空判断 @SuppressWarnings("unchecked") default <T> T getRequiredAttribute(String name) { T value = getAttribute(name); Assert.notNull(value, () -> "Required attribute '" + name + "' is missing"); return value; // 根据KEY获取请求属性,需要提供默认值 @SuppressWarnings("unchecked") default <T> T getAttributeOrDefault(String name, T defaultValue) { return (T) getAttributes().getOrDefault(name, defaultValue); // 返回当前请求的网络会话 Mono<WebSession> getSession(); // 返回当前请求的认证用户,如果存在的话 <T extends Principal> Mono<T> getPrincipal(); // 返回请求的表单数据或者一个空的Map,只有Content-Type为application/x-www-form-urlencoded的时候这个方法才会返回一个非空的Map -- 这个一般是表单数据提交用到 Mono<MultiValueMap<String, String>> getFormData(); // 返回multipart请求的part数据或者一个空的Map,只有Content-Type为multipart/form-data的时候这个方法才会返回一个非空的Map -- 这个一般是文件上传用到 Mono<MultiValueMap<String, Part>> getMultipartData(); // 返回Spring的上下文 @Nullable ApplicationContext getApplicationContext(); // 这几个方法和lastModified属性相关 boolean isNotModified(); boolean checkNotModified(Instant lastModified); boolean checkNotModified(String etag); boolean checkNotModified(@Nullable String etag, Instant lastModified); // URL转换 String transformUrl(String url); // URL转换映射 void addUrlTransformer(Function<String, String> transformer); // 注意这个方法,方法名是:改变,这个是修改ServerWebExchange属性的方法,返回的是一个Builder实例,Builder是ServerWebExchange的内部类 default Builder mutate() { return new DefaultServerWebExchangeBuilder(this); interface Builder { // 覆盖ServerHttpRequest Builder request(Consumer<ServerHttpRequest.Builder> requestBuilderConsumer); Builder request(ServerHttpRequest request); // 覆盖ServerHttpResponse Builder response(ServerHttpResponse response); // 覆盖当前请求的认证用户 Builder principal(Mono<Principal> principalMono); // 构建新的ServerWebExchange实例 ServerWebExchange build(); 复制代码注意到ServerWebExchange#mutate()方法,ServerWebExchange实例可以理解为不可变实例,如果我们想要修改它,需要通过mutate()方法生成一个新的实例,例如这样:public class CustomGlobalFilter implements GlobalFilter { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); // 这里可以修改ServerHttpRequest实例 ServerHttpRequest newRequest = ... ServerHttpResponse response = exchange.getResponse(); // 这里可以修改ServerHttpResponse实例 ServerHttpResponse newResponse = ... // 构建新的ServerWebExchange实例 ServerWebExchange newExchange = exchange.mutate().request(newRequest).response(newResponse).build(); return chain.filter(newExchange); 复制代码ServerHttpRequest接口ServerHttpRequest实例是用于承载请求相关的属性和请求体,Spring Cloud Gateway中底层使用Netty处理网络请求,通过追溯源码,可以从ReactorHttpHandlerAdapter中得知ServerWebExchange实例中持有的ServerHttpRequest实例的具体实现是ReactorServerHttpRequest。之所以列出这些实例之间的关系,是因为这样比较容易理清一些隐含的问题,例如:ReactorServerHttpRequest的父类AbstractServerHttpRequest中初始化内部属性headers的时候把请求的HTTP头部封装为只读的实例:public AbstractServerHttpRequest(URI uri, @Nullable String contextPath, HttpHeaders headers) { this.uri = uri; this.path = RequestPath.parse(uri, contextPath); this.headers = HttpHeaders.readOnlyHttpHeaders(headers); // HttpHeaders类中的readOnlyHttpHeaders方法,其中ReadOnlyHttpHeaders屏蔽了所有修改请求头的方法,直接抛出UnsupportedOperationException public static HttpHeaders readOnlyHttpHeaders(HttpHeaders headers) { Assert.notNull(headers, "HttpHeaders must not be null"); if (headers instanceof ReadOnlyHttpHeaders) { return headers; else { return new ReadOnlyHttpHeaders(headers); 复制代码所以不能直接从ServerHttpRequest实例中直接获取请求头HttpHeaders实例并且进行修改。ServerHttpRequest接口如下:public interface HttpMessage { // 获取请求头,目前的实现中返回的是ReadOnlyHttpHeaders实例,只读 HttpHeaders getHeaders(); public interface ReactiveHttpInputMessage extends HttpMessage { // 返回请求体的Flux封装 Flux<DataBuffer> getBody(); public interface HttpRequest extends HttpMessage { // 返回HTTP请求方法,解析为HttpMethod实例 @Nullable default HttpMethod getMethod() { return HttpMethod.resolve(getMethodValue()); // 返回HTTP请求方法,字符串 String getMethodValue(); // 请求的URI URI getURI(); public interface ServerHttpRequest extends HttpRequest, ReactiveHttpInputMessage { // 连接的唯一标识或者用于日志处理标识 String getId(); // 获取请求路径,封装为RequestPath对象 RequestPath getPath(); // 返回查询参数,是只读的MultiValueMap实例 MultiValueMap<String, String> getQueryParams(); // 返回Cookie集合,是只读的MultiValueMap实例 MultiValueMap<String, HttpCookie> getCookies(); // 远程服务器地址信息 @Nullable default InetSocketAddress getRemoteAddress() { return null; // SSL会话实现的相关信息 @Nullable default SslInfo getSslInfo() { return null; // 修改请求的方法,返回一个建造器实例Builder,Builder是内部类 default ServerHttpRequest.Builder mutate() { return new DefaultServerHttpRequestBuilder(this); interface Builder { // 覆盖请求方法 Builder method(HttpMethod httpMethod); // 覆盖请求的URI、请求路径或者上下文,这三者相互有制约关系,具体可以参考API注释 Builder uri(URI uri); Builder path(String path); Builder contextPath(String contextPath); // 覆盖请求头 Builder header(String key, String value); Builder headers(Consumer<HttpHeaders> headersConsumer); // 覆盖SslInfo Builder sslInfo(SslInfo sslInfo); // 构建一个新的ServerHttpRequest实例 ServerHttpRequest build(); 复制代码如果要修改ServerHttpRequest实例,那么需要这样做:ServerHttpRequest request = exchange.getRequest(); ServerHttpRequest newRequest = request.mutate().headers("key","value").path("/myPath").build(); 复制代码这里最值得注意的是:ServerHttpRequest或者说HttpMessage接口提供的获取请求头方法HttpHeaders getHeaders();返回结果是一个只读的实例,具体是ReadOnlyHttpHeaders类型,这里提多一次,笔者写这篇博文时候使用的Spring Cloud Gateway版本为Greenwich.SR1。ServerHttpResponse接口ServerHttpResponse实例是用于承载响应相关的属性和响应体,Spring Cloud Gateway中底层使用Netty处理网络请求,通过追溯源码,可以从ReactorHttpHandlerAdapter中得知ServerWebExchange实例中持有的ServerHttpResponse实例的具体实现是ReactorServerHttpResponse。之所以列出这些实例之间的关系,是因为这样比较容易理清一些隐含的问题,例如:// ReactorServerHttpResponse的父类 public AbstractServerHttpResponse(DataBufferFactory dataBufferFactory, HttpHeaders headers) { Assert.notNull(dataBufferFactory, "DataBufferFactory must not be null"); Assert.notNull(headers, "HttpHeaders must not be null"); this.dataBufferFactory = dataBufferFactory; this.headers = headers; this.cookies = new LinkedMultiValueMap<>(); public ReactorServerHttpResponse(HttpServerResponse response, DataBufferFactory bufferFactory) { super(bufferFactory, new HttpHeaders(new NettyHeadersAdapter(response.responseHeaders()))); Assert.notNull(response, "HttpServerResponse must not be null"); this.response = response; 复制代码可知ReactorServerHttpResponse构造函数初始化实例的时候,存放响应Header的是HttpHeaders实例,也就是响应Header是可以直接修改的。ServerHttpResponse接口如下:public interface HttpMessage { // 获取响应Header,目前的实现中返回的是HttpHeaders实例,可以直接修改 HttpHeaders getHeaders(); public interface ReactiveHttpOutputMessage extends HttpMessage { // 获取DataBufferFactory实例,用于包装或者生成数据缓冲区DataBuffer实例(创建响应体) DataBufferFactory bufferFactory(); // 注册一个动作,在HttpOutputMessage提交之前此动作会进行回调 void beforeCommit(Supplier<? extends Mono<Void>> action); // 判断HttpOutputMessage是否已经提交 boolean isCommitted(); // 写入消息体到HTTP协议层 Mono<Void> writeWith(Publisher<? extends DataBuffer> body); // 写入消息体到HTTP协议层并且刷新缓冲区 Mono<Void> writeAndFlushWith(Publisher<? extends Publisher<? extends DataBuffer>> body); // 指明消息处理已经结束,一般在消息处理结束自动调用此方法,多次调用不会产生副作用 Mono<Void> setComplete(); public interface ServerHttpResponse extends ReactiveHttpOutputMessage { // 设置响应状态码 boolean setStatusCode(@Nullable HttpStatus status); // 获取响应状态码 @Nullable HttpStatus getStatusCode(); // 获取响应Cookie,封装为MultiValueMap实例,可以修改 MultiValueMap<String, ResponseCookie> getCookies(); // 添加响应Cookie void addCookie(ResponseCookie cookie); 复制代码这里可以看到除了响应体比较难修改之外,其他的属性都是可变的。ServerWebExchangeUtils和上下文属性ServerWebExchangeUtils里面存放了很多静态公有的字符串KEY值(这些字符串KEY的实际值是org.springframework.cloud.gateway.support.ServerWebExchangeUtils. + 下面任意的静态公有KEY),这些字符串KEY值一般是用于ServerWebExchange的属性(Attribute,见上文的ServerWebExchange#getAttributes()方法)的KEY,这些属性值都是有特殊的含义,在使用过滤器的时候如果时机适当可以直接取出来使用,下面逐个分析。PRESERVE_HOST_HEADER_ATTRIBUTE:是否保存Host属性,值是布尔值类型,写入位置是PreserveHostHeaderGatewayFilterFactory,使用的位置是NettyRoutingFilter,作用是如果设置为true,HTTP请求头中的Host属性会写到底层Reactor-Netty的请求Header属性中。CLIENT_RESPONSE_ATTR:保存底层Reactor-Netty的响应对象,类型是reactor.netty.http.client.HttpClientResponse。CLIENT_RESPONSE_CONN_ATTR:保存底层Reactor-Netty的连接对象,类型是reactor.netty.Connection。URI_TEMPLATE_VARIABLES_ATTRIBUTE:PathRoutePredicateFactory解析路径参数完成之后,把解析完成后的占位符KEY-路径Path映射存放在ServerWebExchange的属性中,KEY就是URI_TEMPLATE_VARIABLES_ATTRIBUTE。CLIENT_RESPONSE_HEADER_NAMES:保存底层Reactor-Netty的响应Header的名称集合。GATEWAY_ROUTE_ATTR:用于存放RoutePredicateHandlerMapping中匹配出来的具体的路由(org.springframework.cloud.gateway.route.Route)实例,通过这个路由实例可以得知当前请求会路由到下游哪个服务。GATEWAY_REQUEST_URL_ATTR:java.net.URI类型的实例,这个实例代表直接请求或者负载均衡处理之后需要请求到下游服务的真实URI。GATEWAY_ORIGINAL_REQUEST_URL_ATTR:java.net.URI类型的实例,需要重写请求URI的时候,保存原始的请求URI。GATEWAY_HANDLER_MAPPER_ATTR:保存当前使用的HandlerMapping具体实例的类型简称(一般是字符串"RoutePredicateHandlerMapping")。GATEWAY_SCHEME_PREFIX_ATTR:确定目标路由URI中如果存在schemeSpecificPart属性,则保存该URI的scheme在此属性中,路由URI会被重新构造,见RouteToRequestUrlFilter。GATEWAY_PREDICATE_ROUTE_ATTR:用于存放RoutePredicateHandlerMapping中匹配出来的具体的路由(org.springframework.cloud.gateway.route.Route)实例的ID。WEIGHT_ATTR:实验性功能(此版本还不建议在正式版本使用)存放分组权重相关属性,见WeightCalculatorWebFilter。ORIGINAL_RESPONSE_CONTENT_TYPE_ATTR:存放响应Header中的ContentType的值。HYSTRIX_EXECUTION_EXCEPTION_ATTR:Throwable的实例,存放的是Hystrix执行异常时候的异常实例,见HystrixGatewayFilterFactory。GATEWAY_ALREADY_ROUTED_ATTR:布尔值,用于判断是否已经进行了路由,见NettyRoutingFilter。GATEWAY_ALREADY_PREFIXED_ATTR:布尔值,用于判断请求路径是否被添加了前置部分,见PrefixPathGatewayFilterFactory。ServerWebExchangeUtils提供的上下文属性用于Spring Cloud Gateway的ServerWebExchange组件处理请求和响应的时候,内部一些重要实例或者标识属性的安全传输和使用,使用它们可能存在一定的风险,因为没有人可以确定在版本升级之后,原有的属性KEY或者VALUE是否会发生改变,如果评估过风险或者规避了风险之后,可以安心使用。例如我们在做请求和响应日志(类似Nginx的Access Log)的时候,可以依赖到GATEWAY_ROUTE_ATTR,因为我们要打印路由的目标信息。举个简单例子:@Slf4j @Component public class AccessLogFilter implements GlobalFilter { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); String path = request.getPath().pathWithinApplication().value(); HttpMethod method = request.getMethod(); // 获取路由的目标URI URI targetUri = exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR); InetSocketAddress remoteAddress = request.getRemoteAddress(); return chain.filter(exchange.mutate().build()).then(Mono.fromRunnable(() -> { ServerHttpResponse response = exchange.getResponse(); HttpStatus statusCode = response.getStatusCode(); log.info("请求路径:{},客户端远程IP地址:{},请求方法:{},目标URI:{},响应码:{}", path, remoteAddress, method, targetUri, statusCode); 复制代码修改请求体修改请求体是一个比较常见的需求。例如我们使用Spring Cloud Gateway实现网关的时候,要实现一个功能:把存放在请求头中的JWT解析后,提取里面的用户ID,然后写入到请求体中。我们简化这个场景,假设我们把userId明文存放在请求头中的accessToken中,请求体是一个JSON结构:{ "serialNumber": "请求流水号", "payload" : { // ... 这里是有效载荷,存放具体的数据 复制代码我们需要提取accessToken,也就是userId插入到请求体JSON中如下:{ "userId": "用户ID", "serialNumber": "请求流水号", "payload" : { // ... 这里是有效载荷,存放具体的数据 复制代码这里为了简化设计,用全局过滤器GlobalFilter实现,实际需要结合具体场景考虑:@Slf4j @Component public class ModifyRequestBodyGlobalFilter implements GlobalFilter { private final DataBufferFactory dataBufferFactory = new NettyDataBufferFactory(ByteBufAllocator.DEFAULT); @Autowired private ObjectMapper objectMapper; @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); String accessToken = request.getHeaders().getFirst("accessToken"); if (!StringUtils.hasLength(accessToken)) { throw new IllegalArgumentException("accessToken"); // 新建一个ServerHttpRequest装饰器,覆盖需要装饰的方法 ServerHttpRequestDecorator decorator = new ServerHttpRequestDecorator(request) { @Override public Flux<DataBuffer> getBody() { Flux<DataBuffer> body = super.getBody(); InputStreamHolder holder = new InputStreamHolder(); body.subscribe(buffer -> holder.inputStream = buffer.asInputStream()); if (null != holder.inputStream) { try { // 解析JSON的节点 JsonNode jsonNode = objectMapper.readTree(holder.inputStream); Assert.isTrue(jsonNode instanceof ObjectNode, "JSON格式异常"); ObjectNode objectNode = (ObjectNode) jsonNode; // JSON节点最外层写入新的属性 objectNode.put("userId", accessToken); DataBuffer dataBuffer = dataBufferFactory.allocateBuffer(); String json = objectNode.toString(); log.info("最终的JSON数据为:{}", json); dataBuffer.write(json.getBytes(StandardCharsets.UTF_8)); return Flux.just(dataBuffer); } catch (Exception e) { throw new IllegalStateException(e); } else { return super.getBody(); // 使用修改后的ServerHttpRequestDecorator重新生成一个新的ServerWebExchange return chain.filter(exchange.mutate().request(decorator).build()); private class InputStreamHolder { InputStream inputStream; 复制代码测试一下:// HTTP POST /order/json HTTP/1.1 Host: localhost:9090 Content-Type: application/json accessToken: 10086 Accept: */* Cache-Control: no-cache Host: localhost:9090 accept-encoding: gzip, deflate content-length: 94 Connection: keep-alive cache-control: no-cache "serialNumber": "请求流水号", "payload": { "name": "doge" // 日志输出 最终的JSON数据为:{"serialNumber":"请求流水号","payload":{"name":"doge"},"userId":"10086"} 复制代码最重要的是用到了ServerHttpRequest装饰器ServerHttpRequestDecorator,主要覆盖对应获取请求体数据缓冲区的方法即可,至于怎么处理其他逻辑需要自行考虑,这里只是做一个简单的示范。一般的代码逻辑如下:ServerHttpRequest request = exchange.getRequest(); ServerHttpRequestDecorator requestDecorator = new ServerHttpRequestDecorator(request) { @Override public Flux<DataBuffer> getBody() { // 拿到承载原始请求体的Flux Flux<DataBuffer> body = super.getBody(); // 这里通过自定义方式生成新的承载请求体的Flux Flux<DataBuffer> newBody = ... return newBody; return chain.filter(exchange.mutate().request(requestDecorator).build());

Spring Cloud Gateway-自定义异常处理

前提我们平时在用SpringMVC的时候,只要是经过DispatcherServlet处理的请求,可以通过@ControllerAdvice和@ExceptionHandler自定义不同类型异常的处理逻辑,具体可以参考ResponseEntityExceptionHandler和DefaultHandlerExceptionResolver,底层原理很简单,就是发生异常的时候搜索容器中已经存在的异常处理器并且匹配对应的异常类型,匹配成功之后使用该指定的异常处理器返回结果进行Response的渲染,如果找不到默认的异常处理器则用默认的进行兜底(个人认为,Spring在很多功能设计的时候都有这种“有则使用自定义,无则使用默认提供”这种思想十分优雅)。SpringMVC中提供的自定义异常体系在Spring-WebFlux中并不适用,其实原因很简单,两者底层的运行容器并不相同。WebExceptionHandler是Spring-WebFlux的异常处理器顶层接口,因此追溯到子类可以追踪到DefaultErrorWebExceptionHandler是Spring Cloud Gateway的全局异常处理器,配置类是ErrorWebFluxAutoConfiguration。为什么要自定义异常处理先画一个假想但是贴近实际架构图,定位一下网关的作用:网关在整个架构中的作用是:路由服务端应用的请求到后端应用。(聚合)后端应用的响应转发到服务端应用。假设网关服务总是正常的前提下:对于第1点来说,假设后端应用不能平滑无损上线,会有一定的几率出现网关路由请求到一些后端的“僵尸节点(请求路由过去的时候,应用更好在重启或者刚好停止)”,这个时候会路由会失败抛出异常,一般情况是Connection Refuse。对于第2点来说,假设后端应用没有正确处理异常,那么应该会把异常信息经过网关转发回到服务端应用,这种情况理论上不会出现异常。其实还有第3点隐藏的问题,网关如果不单单承担路由的功能,还包含了鉴权、限流等功能,如果这些功能开发的时候对异常捕获没有做完善的处理甚至是逻辑本身存在BUG,有可能导致异常没有被正常捕获处理,走了默认的异常处理器DefaultErrorWebExceptionHandler,默认的异常处理器的处理逻辑可能并不符合我们预期的结果。如何自定义异常处理我们可以先看默认的异常处理器的配置类ErrorWebFluxAutoConfiguration:@Configuration @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) @ConditionalOnClass(WebFluxConfigurer.class) @AutoConfigureBefore(WebFluxAutoConfiguration.class) @EnableConfigurationProperties({ ServerProperties.class, ResourceProperties.class }) public class ErrorWebFluxAutoConfiguration { private final ServerProperties serverProperties; private final ApplicationContext applicationContext; private final ResourceProperties resourceProperties; private final List<ViewResolver> viewResolvers; private final ServerCodecConfigurer serverCodecConfigurer; public ErrorWebFluxAutoConfiguration(ServerProperties serverProperties, ResourceProperties resourceProperties, ObjectProvider<ViewResolver> viewResolversProvider, ServerCodecConfigurer serverCodecConfigurer, ApplicationContext applicationContext) { this.serverProperties = serverProperties; this.applicationContext = applicationContext; this.resourceProperties = resourceProperties; this.viewResolvers = viewResolversProvider.orderedStream() .collect(Collectors.toList()); this.serverCodecConfigurer = serverCodecConfigurer; @Bean @ConditionalOnMissingBean(value = ErrorWebExceptionHandler.class, search = SearchStrategy.CURRENT) @Order(-1) public ErrorWebExceptionHandler errorWebExceptionHandler( ErrorAttributes errorAttributes) { DefaultErrorWebExceptionHandler exceptionHandler = new DefaultErrorWebExceptionHandler( errorAttributes, this.resourceProperties, this.serverProperties.getError(), this.applicationContext); exceptionHandler.setViewResolvers(this.viewResolvers); exceptionHandler.setMessageWriters(this.serverCodecConfigurer.getWriters()); exceptionHandler.setMessageReaders(this.serverCodecConfigurer.getReaders()); return exceptionHandler; @Bean @ConditionalOnMissingBean(value = ErrorAttributes.class, search = SearchStrategy.CURRENT) public DefaultErrorAttributes errorAttributes() { return new DefaultErrorAttributes( this.serverProperties.getError().isIncludeException()); 复制代码注意到两个Bean实例ErrorWebExceptionHandler和DefaultErrorAttributes都使用了@ConditionalOnMissingBean注解,也就是我们可以通过自定义实现去覆盖它们。先自定义一个CustomErrorWebFluxAutoConfiguration(除了ErrorWebExceptionHandler的自定义实现,其他直接拷贝ErrorWebFluxAutoConfiguration):@Configuration @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) @ConditionalOnClass(WebFluxConfigurer.class) @AutoConfigureBefore(WebFluxAutoConfiguration.class) @EnableConfigurationProperties({ServerProperties.class, ResourceProperties.class}) public class CustomErrorWebFluxAutoConfiguration { private final ServerProperties serverProperties; private final ApplicationContext applicationContext; private final ResourceProperties resourceProperties; private final List<ViewResolver> viewResolvers; private final ServerCodecConfigurer serverCodecConfigurer; public CustomErrorWebFluxAutoConfiguration(ServerProperties serverProperties, ResourceProperties resourceProperties, ObjectProvider<ViewResolver> viewResolversProvider, ServerCodecConfigurer serverCodecConfigurer, ApplicationContext applicationContext) { this.serverProperties = serverProperties; this.applicationContext = applicationContext; this.resourceProperties = resourceProperties; this.viewResolvers = viewResolversProvider.orderedStream() .collect(Collectors.toList()); this.serverCodecConfigurer = serverCodecConfigurer; @Bean @ConditionalOnMissingBean(value = ErrorWebExceptionHandler.class, search = SearchStrategy.CURRENT) @Order(-1) public ErrorWebExceptionHandler errorWebExceptionHandler(ErrorAttributes errorAttributes) { // TODO 这里完成自定义ErrorWebExceptionHandler实现逻辑 return null; @Bean @ConditionalOnMissingBean(value = ErrorAttributes.class, search = SearchStrategy.CURRENT) public DefaultErrorAttributes errorAttributes() { return new DefaultErrorAttributes(this.serverProperties.getError().isIncludeException()); 复制代码ErrorWebExceptionHandler的实现,可以直接参考DefaultErrorWebExceptionHandler,甚至直接继承DefaultErrorWebExceptionHandler,覆盖对应的方法即可。这里直接把异常信息封装成下面格式的Response返回,最后需要渲染成JSON格式:{ "code": 200, "message": "描述信息", "path" : "请求路径", "method": "请求方法" 复制代码我们需要分析一下DefaultErrorWebExceptionHandler中的一些源码:// 封装异常属性 protected Map<String, Object> getErrorAttributes(ServerRequest request, boolean includeStackTrace) { return this.errorAttributes.getErrorAttributes(request, includeStackTrace); // 渲染异常Response protected Mono<ServerResponse> renderErrorResponse(ServerRequest request) { boolean includeStackTrace = isIncludeStackTrace(request, MediaType.ALL); Map<String, Object> error = getErrorAttributes(request, includeStackTrace); return ServerResponse.status(getHttpStatus(error)) .contentType(MediaType.APPLICATION_JSON_UTF8) .body(BodyInserters.fromObject(error)); // 返回路由方法基于ServerResponse的对象 @Override protected RouterFunction<ServerResponse> getRoutingFunction(ErrorAttributes errorAttributes) { return route(acceptsTextHtml(), this::renderErrorView).andRoute(all(), this::renderErrorResponse); // HTTP响应状态码的封装,原来是基于异常属性的status属性进行解析的 protected HttpStatus getHttpStatus(Map<String, Object> errorAttributes) { int statusCode = (int) errorAttributes.get("status"); return HttpStatus.valueOf(statusCode); 复制代码确定三点:最后封装到响应体的对象来源于DefaultErrorWebExceptionHandler#getErrorAttributes(),并且结果是一个Map<String, Object>实例转换成的字节序列。原来的RouterFunction实现只支持HTML格式返回,我们需要修改为JSON格式返回(或者说支持所有格式返回)。DefaultErrorWebExceptionHandler#getHttpStatus()是响应状态码的封装,原来的逻辑是基于异常属性getErrorAttributes()的status属性进行解析的。自定义的JsonErrorWebExceptionHandler如下:public class JsonErrorWebExceptionHandler extends DefaultErrorWebExceptionHandler { public JsonErrorWebExceptionHandler(ErrorAttributes errorAttributes, ResourceProperties resourceProperties, ErrorProperties errorProperties, ApplicationContext applicationContext) { super(errorAttributes, resourceProperties, errorProperties, applicationContext); @Override protected Map<String, Object> getErrorAttributes(ServerRequest request, boolean includeStackTrace) { // 这里其实可以根据异常类型进行定制化逻辑 Throwable error = super.getError(request); Map<String, Object> errorAttributes = new HashMap<>(8); errorAttributes.put("message", error.getMessage()); errorAttributes.put("code", HttpStatus.INTERNAL_SERVER_ERROR.value()); errorAttributes.put("method", request.methodName()); errorAttributes.put("path", request.path()); return errorAttributes; @Override protected RouterFunction<ServerResponse> getRoutingFunction(ErrorAttributes errorAttributes) { return RouterFunctions.route(RequestPredicates.all(), this::renderErrorResponse); @Override protected HttpStatus getHttpStatus(Map<String, Object> errorAttributes) { // 这里其实可以根据errorAttributes里面的属性定制HTTP响应码 return HttpStatus.INTERNAL_SERVER_ERROR; 复制代码配置类CustomErrorWebFluxAutoConfiguration添加JsonErrorWebExceptionHandler:@Bean @ConditionalOnMissingBean(value = ErrorWebExceptionHandler.class, search = SearchStrategy.CURRENT) @Order(-1) public ErrorWebExceptionHandler errorWebExceptionHandler(ErrorAttributes errorAttributes) { JsonErrorWebExceptionHandler exceptionHandler = new JsonErrorWebExceptionHandler( errorAttributes, resourceProperties, this.serverProperties.getError(), applicationContext); exceptionHandler.setViewResolvers(this.viewResolvers); exceptionHandler.setMessageWriters(this.serverCodecConfigurer.getWriters()); exceptionHandler.setMessageReaders(this.serverCodecConfigurer.getReaders()); return exceptionHandler; 复制代码很简单,这里把异常的HTTP响应状态码统一为HttpStatus.INTERNAL_SERVER_ERROR(500),改造的东西并不多,只要了解原来异常处理的上下文逻辑即可。测试测试场景一:只启动网关,下游服务不启动的情况下直接调用下游服务:curl http://localhost:9090/order/host // 响应结果 {"path":"/order/host","code":500,"message":"Connection refused: no further information: localhost/127.0.0.1:9091","method":"GET"} 复制代码测试场景二:下游服务正常启动和调用,网关自身抛出异常。在网关应用自定义一个全局过滤器并且故意抛出异常:@Component public class ErrorGlobalFilter implements GlobalFilter { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { int i = 1/0; return chain.filter(exchange); 复制代码curl http://localhost:9090/order/host // 响应结果 {"path":"/order/host","code":500,"message":"/ by zero","method":"GET"} 复制代码响应结果和定制的逻辑一致,并且后台的日志也打印了对应的异常堆栈。小结笔者一直认为,做异常分类和按照分类处理是工程里面十分重要的一环。笔者在所在公司负责的系统中,坚持实现异常分类捕获,主要是需要区分可以重试补偿以及无法重试需要及时预警的异常,这样子才能针对可恢复异常定制自愈逻辑,对不能恢复的异常及时预警和人为介入。所以,Spring Cloud Gateway这个技术栈也必须调研其自定义异常的处理逻辑。

Spring Cloud Gateway入坑记

前提最近在做老系统的重构,重构完成后新系统中需要引入一个网关服务,作为新系统和老系统接口的适配和代理。之前,很多网关应用使用的是Spring-Cloud-Netfilx基于Zuul1.x版本实现的那套方案,但是鉴于Zuul1.x已经停止迭代,它使用的是比较传统的阻塞(B)IO + 多线程的实现方案,其实性能不太好。后来Spring团队干脆自己重新研发了一套网关组件,这个就是本次要调研的Spring-Cloud-Gateway。简介Spring Cloud Gateway依赖于Spring Boot 2.0, Spring WebFlux,和Project Reactor。许多熟悉的同步类库(例如Spring-Data和Spring-Security)和同步编程模式在Spring Cloud Gateway中并不适用,所以最好先阅读一下上面提到的三个框架的文档。Spring Cloud Gateway依赖于Spring Boot和Spring WebFlux提供的基于Netty的运行时环境,它并非构建为一个WAR包或者运行在传统的Servlet容器中。专有名词路由(Route):路由是网关的基本组件。它由ID,目标URI,谓词(Predicate)集合和过滤器集合定义。如果谓词聚合判断为真,则匹配路由。谓词(Predicate):使用的是Java8中基于函数式编程引入的java.util.Predicate。使用谓词(聚合)判断的时候,输入的参数是ServerWebExchange类型,它允许开发者匹配来自HTTP请求的任意参数,例如HTTP请求头、HTTP请求参数等等。过滤器(Filter):使用的是指定的GatewayFilter工厂所创建出来的GatewayFilter实例,可以在发送请求到下游之前或者之后修改请求(参数)或者响应(参数)。其实Filter还包括了GlobalFilter,不过在官方文档中没有提到。工作原理客户端向Spring Cloud Gateway发出请求,如果Gateway Handler Mapping模块处理当前请求如果匹配到一个目标路由配置,该请求就会转发到Gateway Web Handler模块。Gateway Web Handler模块在发送请求的时候,会把该请求通过一个匹配于该请求的过滤器链。上图中过滤器被虚线分隔的原因是:过滤器的处理逻辑可以在代理请求发送之前或者之后执行。所有pre类型的过滤器执行之后,代理请求才会创建(和发送),当代理请求创建(和发送)完成之后,所有的post类型的过滤器才会执行。见上图,外部请求进来后如果落入过滤器链,那么虚线左边的就是pre类型的过滤器,请求先经过pre类型的过滤器,再发送到目标被代理的服务。目标被代理的服务响应请求,响应会再次经过滤器链,也就是走虚线右侧的过滤器链,这些过滤器就是post类型的过滤器。注意,如果在路由配置中没有明确指定对应的路由端口,那么会使用如下的默认端口:HTTP协议,使用80端口。HTTPS协议,使用443端口。引入依赖建议直接通过Train版本(其实笔者考究过,Train版本的代号其实是伦敦地铁站的命名,像当前的Spring Cloud最新版本是Greenwich.SR1,Greenwich可以在伦敦地铁站的地图查到这个站点,对应的SpringBoot版本是2.1.x)引入Spring-Cloud-Gateway,因为这样可以跟上最新稳定版本的Spring-Cloud版本,另外由于Spring-Cloud-Gateway基于Netty的运行时环境启动,不需要引入带Servlet容器的spring-boot-starter-web。父POM引入下面的配置:<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>Greenwich.SR1</version> <type>pom</type> <scope>import</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>2.1.4.RELEASE</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> 复制代码子模块或者需要引入Spring-Cloud-Gateway的模块POM引入下面的配置:<dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-gateway</artifactId> </dependency> </dependencies> 复制代码创建一个启动类即可:@SpringBootApplication public class RouteServerApplication { public static void main(String[] args) { SpringApplication.run(RouteServerApplication.class, args); 复制代码网关配置网关配置最终需要转化为一个RouteDefinition的集合,配置的定义接口如下:public interface RouteDefinitionLocator { Flux<RouteDefinition> getRouteDefinitions(); 复制代码通过YAML文件配置或者流式编程式配置(其实文档中还有配合Eureka的DiscoveryClient进行配置,这里暂时不研究),最终都是为了创建一个RouteDefinition的集合。Yaml配置配置实现是PropertiesRouteDefinitionLocator,关联着配置类GatewayProperties:spring: cloud: gateway: routes: - id: datetime_after_route # <------ 这里是路由配置的ID uri: http://www.throwable.club # <------ 这里是路由最终目标Server的URI(Host) predicates: # <------ 谓词集合配置,多个是用and逻辑连接 - Path=/blog # <------- Key(name)=Expression,键是谓词规则工厂的ID,值一般是匹配规则的正则表示 复制代码编程式流式配置编程式和流式编程配置需要依赖RouteLocatorBuilder,目标是构造一个RouteLocator实例:@Bean public RouteLocator customRouteLocator(RouteLocatorBuilder builder) { return builder.routes() .route(r -> r.path("/blog") .uri("http://www.throwable.club") .build(); 复制代码路由谓词工厂Spring Cloud Gateway将路由(Route)作为Spring-WebFlux的HandlerMapping组件基础设施的一部分,也就是HandlerMapping进行匹配的时候,会把配置好的路由规则也纳入匹配机制之中。Spring Cloud Gateway自身包含了很多内建的路由谓词工厂。这些谓词分别匹配一个HTTP请求的不同属性。多个路由谓词工厂可以用and的逻辑组合在一起。目前Spring Cloud Gateway提供的内置的路由谓词工厂如下:指定日期时间规则路由谓词按照配置的日期时间指定的路由谓词有三种可选规则:匹配请求在指定日期时间之前。匹配请求在指定日期时间之后。匹配请求在指定日期时间之间。值得注意的是,配置的日期时间必须满足ZonedDateTime的格式://年月日和时分秒用'T'分隔,接着-07:00是和UTC相差的时间,最后的[America/Denver]是所在的时间地区 2017-01-20T17:42:47.789-07:00[America/Denver] 复制代码例如网关的应用是2019-05-01T00:00:00+08:00[Asia/Shanghai]上线的,上线之后的请求都路由奥www.throwable.club,那么配置如下:server port: 9090 spring: cloud: gateway: routes: - id: datetime_after_route uri: http://www.throwable.club predicates: - After=2019-05-01T00:00:00+08:00[Asia/Shanghai] 复制代码此时,只要请求网关http://localhost:9090,请求就会转发到http://www.throwable.club。如果想要只允许2019-05-01T00:00:00+08:00[Asia/Shanghai]之前的请求,那么只需要改为:server port: 9091 spring: cloud: gateway: routes: - id: datetime_before_route uri: http://www.throwable.club predicates: - Before=2019-05-01T00:00:00+08:00[Asia/Shanghai] 复制代码如果只允许两个日期时间段之间的时间进行请求,那么只需要改为:server port: 9090 spring: cloud: gateway: routes: - id: datetime_between_route uri: http://www.throwable.club predicates: - Between=2019-05-01T00:00:00+08:00[Asia/Shanghai],2019-05-02T00:00:00+08:00[Asia/Shanghai] 复制代码那么只有2019年5月1日0时到5月2日0时的请求才能正常路由。Cookie路由谓词CookieRoutePredicateFactory需要提供两个参数,分别是Cookie的name和一个正则表达式(value)。只有在请求中的Cookie对应的name和value和Cookie路由谓词中配置的值匹配的时候,才能匹配命中进行路由。server port: 9090 spring: cloud: gateway: routes: - id: cookie_route uri: http://www.throwable.club predicates: - Cookie=doge,throwable 复制代码请求需要携带一个Cookie,name为doge,value需要匹配正则表达式"throwable"才能路由到http://www.throwable.club。这里尝试本地搭建一个订单Order服务,基于SpringBoot2.1.4搭建,启动在9091端口:// 入口类 @RestController @RequestMapping(path = "/order") @SpringBootApplication public class OrderServiceApplication { public static void main(String[] args) { SpringApplication.run(OrderServiceApplication.class, args); @GetMapping(value = "/cookie") public ResponseEntity<String> cookie(@CookieValue(name = "doge") String doge) { return ResponseEntity.ok(doge); 复制代码订单服务application.yaml配置:spring: application: name: order-service server: port: 9091 复制代码网关路由配置:spring: application: name: route-server cloud: gateway: routes: - id: cookie_route uri: http://localhost:9091 predicates: - Cookie=doge,throwable 复制代码curl http://localhost:9090/order/cookie --cookie "doge=throwable" //响应结果 throwable 复制代码Header路由谓词HeaderRoutePredicateFactory需要提供两个参数,分别是Header的name和一个正则表达式(value)。只有在请求中的Header对应的name和value和Header路由谓词中配置的值匹配的时候,才能匹配命中进行路由。订单服务中新增一个/header端点:@RestController @RequestMapping(path = "/order") @SpringBootApplication public class OrderServiceApplication { public static void main(String[] args) { SpringApplication.run(OrderServiceApplication.class, args); @GetMapping(value = "/header") public ResponseEntity<String> header(@RequestHeader(name = "accessToken") String accessToken) { return ResponseEntity.ok(accessToken); 复制代码网关的路由配置如下:spring: cloud: gateway: routes: - id: header_route uri: http://localhost:9091 predicates: - Header=accessToken,Doge 复制代码curl -H "accessToken:Doge" http://localhost:9090/order/header //响应结果 复制代码Host路由谓词HostRoutePredicateFactory只需要指定一个主机名列表,列表中的每个元素支持Ant命名样式,使用.作为分隔符,多个元素之间使用,区分。Host路由谓词实际上针对的是HTTP请求头中的Host属性。订单服务中新增一个/header端点:@RestController @RequestMapping(path = "/order") @SpringBootApplication public class OrderServiceApplication { public static void main(String[] args) { SpringApplication.run(OrderServiceApplication.class, args); @GetMapping(value = "/host") public ResponseEntity<String> host(@RequestHeader(name = "Host") String host) { return ResponseEntity.ok(host); 复制代码网关的路由配置如下:spring: cloud: gateway: routes: - id: host_route uri: http://localhost:9091 predicates: - Host=localhost:9090 复制代码curl http://localhost:9090/order/host //响应结果 localhost:9091 # <--------- 这里要注意一下,路由到订单服务的时候,Host会被修改为localhost:9091 复制代码其实可以定制更多样化的Host匹配模式,甚至可以支持URI模板变量。- Host=www.throwable.**,**.throwable.** - Host={sub}.throwable.club 复制代码请求方法路由谓词MethodRoutePredicateFactory只需要一个参数:要匹配的HTTP请求方法。网关的路由配置如下:spring: cloud: gateway: routes: - id: method_route uri: http://localhost:9091 predicates: - Method=GET 复制代码这样配置,所有的进入到网关的GET方法的请求都会路由到http://localhost:9091。订单服务中新增一个/get端点:@GetMapping(value = "/get") public ResponseEntity<String> get() { return ResponseEntity.ok("get"); 复制代码curl http://localhost:9090/order/get //响应结果 复制代码请求路径路由谓词PathRoutePredicateFactory需要PathMatcher模式路径列表和一个可选的标志位参数matchOptionalTrailingSeparator。这个是最常用的一个路由谓词。spring: cloud: gateway: routes: - id: path_route uri: http://localhost:9091 predicates: - Path=/order/path 复制代码@GetMapping(value = "/path") public ResponseEntity<String> path() { return ResponseEntity.ok("path"); 复制代码curl http://localhost:9090/order/path //响应结果 复制代码此外,可以通过{segment}占位符配置路径如/foo/1或/foo/bar或/bar/baz,如果通过这种形式配置,在匹配命中进行路由的时候,会提取路径中对应的内容并且将键值对放在ServerWebExchange.getAttributes()集合中,KEY为ServerWebExchangeUtils.URI_TEMPLATE_VARIABLES_ATTRIBUTE,这些提取出来的属性可以供GatewayFilter Factories使用。请求查询参数路由谓词QueryRoutePredicateFactory需要一个必须的请求查询参数(param的name)以及一个可选的正则表达式(regexp)。spring: cloud: gateway: routes: - id: query_route uri: http://localhost:9091 predicates: - Query=doge,throwabl. 复制代码这里配置的param就是doge,正则表达式是throwabl.。@GetMapping(value = "/query") public ResponseEntity<String> query(@RequestParam("name") String doge) { return ResponseEntity.ok(doge); 复制代码curl http://localhost:9090/order/query?doge=throwable //响应结果 throwable 复制代码远程IP地址路由谓词RemoteAddrRoutePredicateFactory匹配规则采用CIDR符号(IPv4或IPv6)字符串的列表(最小值为1),例如192.168.0.1/16(其中192.168.0.1是远程IP地址并且16是子网掩码)。spring: cloud: gateway: routes: - id: remoteaddr_route uri: http://localhost:9091 predicates: - RemoteAddr=127.0.0.1 复制代码@GetMapping(value = "/remote") public ResponseEntity<String> remote() { return ResponseEntity.ok("remote"); 复制代码curl http://localhost:9090/order/remote //响应结果 remote 复制代码关于远程IP路由这一个路由谓词其实还有很多扩展手段,这里暂时不展开。多个路由谓词组合因为路由配置中的predicates属性其实是一个列表,可以直接添加多个路由规则:spring: cloud: gateway: routes: - id: remoteaddr_route uri: http://localhost:9091 predicates: - RemoteAddr=xxxx - Path=/yyyy - Query=zzzz,aaaa 复制代码这些规则是用and逻辑组合的,例如上面的例子相当于:request = ... if(request.getRemoteAddr == 'xxxx' && request.getPath match '/yyyy' && request.getQuery('zzzz') match 'aaaa') { return true; return false; 复制代码GatewayFilter工厂路由过滤器GatewayFilter允许修改进来的HTTP请求内容或者返回的HTTP响应内容。路由过滤器的作用域是一个具体的路由配置。Spring Cloud Gateway提供了丰富的内建的GatewayFilter工厂,可以按需选用。因为GatewayFilter工厂类实在太多,笔者这里举个简单的例子。如果我们想对某些请求附加特殊的HTTP请求头,可以选用AddRequestHeaderX-Request-Foo:Bar,application.yml如下:spring: cloud: gateway: routes: - id: add_request_header_route uri: https://example.org filters: - AddRequestHeader=X-Request-Foo,Bar 复制代码那么所有的从网关入口的HTTP请求都会添加一个特殊的HTTP请求头:X-Request-Foo:Bar。目前GatewayFilter工厂的内建实现如下:ID类名类型功能StripPrefixStripPrefixGatewayFilterFactorypre移除请求URL路径的第一部分,例如原始请求路径是/order/query,处理后是/querySetStatusSetStatusGatewayFilterFactorypost设置请求响应的状态码,会从org.springframework.http.HttpStatus中解析SetResponseHeaderSetResponseHeaderGatewayFilterFactorypost设置(添加)请求响应的响应头SetRequestHeaderSetRequestHeaderGatewayFilterFactorypre设置(添加)请求头SetPathSetPathGatewayFilterFactorypre设置(覆盖)请求路径SecureHeaderSecureHeadersGatewayFilterFactorypre设置安全相关的请求头,见SecureHeadersPropertiesSaveSessionSaveSessionGatewayFilterFactorypre保存WebSessionRewriteResponseHeaderRewriteResponseHeaderGatewayFilterFactorypost重新响应头RewritePathRewritePathGatewayFilterFactorypre重写请求路径RetryRetryGatewayFilterFactorypre基于条件对请求进行重试RequestSizeRequestSizeGatewayFilterFactorypre限制请求的大小,单位是byte,超过设定值返回413 Payload Too LargeRequestRateLimiterRequestRateLimiterGatewayFilterFactorypre限流RequestHeaderToRequestUriRequestHeaderToRequestUriGatewayFilterFactorypre通过请求头的值改变请求URLRemoveResponseHeaderRemoveResponseHeaderGatewayFilterFactorypost移除配置的响应头RemoveRequestHeaderRemoveRequestHeaderGatewayFilterFactorypre移除配置的请求头RedirectToRedirectToGatewayFilterFactorypre重定向,需要指定HTTP状态码和重定向URLPreserveHostHeaderPreserveHostHeaderGatewayFilterFactorypre设置请求携带的属性preserveHostHeader为truePrefixPathPrefixPathGatewayFilterFactorypre请求路径添加前置路径HystrixHystrixGatewayFilterFactorypre整合HystrixFallbackHeadersFallbackHeadersGatewayFilterFactorypreHystrix执行如果命中降级逻辑允许通过请求头携带异常明细信息AddResponseHeaderAddResponseHeaderGatewayFilterFactorypost添加响应头AddRequestParameterAddRequestParameterGatewayFilterFactorypre添加请求参数,仅仅限于URL的Query参数AddRequestHeaderAddRequestHeaderGatewayFilterFactorypre添加请求头GatewayFilter工厂使用的时候需要知道其ID以及配置方式,配置方式可以看对应工厂类的公有静态内部类XXXXConfig。GlobalFilter工厂GlobalFilter的功能其实和GatewayFilter是相同的,只是GlobalFilter的作用域是所有的路由配置,而不是绑定在指定的路由配置上。多个GlobalFilter可以通过@Order或者getOrder()方法指定每个GlobalFilter的执行顺序,order值越小,GlobalFilter执行的优先级越高。注意,由于过滤器有pre和post两种类型,pre类型过滤器如果order值越小,那么它就应该在pre过滤器链的顶层,post类型过滤器如果order值越小,那么它就应该在pre过滤器链的底层。示意图如下:例如要实现负载均衡的功能,application.yml配置如下:spring: cloud: gateway: routes: - id: myRoute uri: lb://myservice # <-------- lb特殊标记会使用LoadBalancerClient搜索目标服务进行负载均衡 predicates: - Path=/service/** 复制代码目前Spring Cloud Gateway提供的内建的GlobalFilter如下:类名功能ForwardRoutingFilter重定向LoadBalancerClientFilter负载均衡NettyRoutingFilterNetty的HTTP客户端的路由NettyWriteResponseFilterNetty响应进行写操作RouteToRequestUrlFilter基于路由配置更新URLWebsocketRoutingFilterWebsocket请求转发到下游内建的GlobalFilter大多数和ServerWebExchangeUtils的属性相关,这里就不深入展开。跨域配置网关可以通过配置来控制全局的CORS行为。全局的CORS配置对应的类是CorsConfiguration,这个配置是一个URL模式的映射。例如application.yaml文件如下:spring: cloud: gateway: globalcors: corsConfigurations: '[/**]': allowedOrigins: "https://docs.spring.io" allowedMethods: - GET 复制代码在上面的示例中,对于所有请求的路径,将允许来自docs.spring.io并且是GET方法的CORS请求。Actuator端点相关引入spring-boot-starter-actuator,需要做以下配置开启gateway监控端点:management.endpoint.gateway.enabled=true management.endpoints.web.exposure.include=gateway 复制代码目前支持的端点列表:ID请求路径HTTP方法描述globalfilters/actuator/gateway/globalfiltersGET展示路由配置中的GlobalFilter列表routefilters/actuator/gateway/routefiltersGET展示绑定到对应路由配置的GatewayFilter列表refresh/actuator/gateway/refreshPOST清空路由配置缓存routes/actuator/gateway/routesGET展示已经定义的路由配置列表routes/{id}/actuator/gateway/routes/{id}GET展示对应ID已经定义的路由配置routes/{id}/actuator/gateway/routes/{id}POST添加一个新的路由配置routes/{id}/actuator/gateway/routes/{id}DELETE删除指定ID的路由配置其中/actuator/gateway/routes/{id}添加一个新的路由配置请求参数的格式如下:{ "id": "first_route", "predicates": [{ "name": "Path", "args": {"doge":"/throwable"} "filters": [], "uri": "https://www.throwable.club", "order": 0 复制代码小结笔者虽然是一个底层的码畜,但是很久之前就向身边的朋友说:反应式编程结合同步非阻塞IO或者异步非阻塞IO是目前网络编程框架的主流方向,最好要跟上主流的步伐掌握这些框架的使用,有能力最好成为它们的贡献者。目前常见的反应式编程框架有:Reactor和RxJava2,其中Reactor在后端的JVM应用比较常见,RxJava2在安卓编写的APP客户端比较常见。Reactor-Netty,这个是基于Reactor和Netty封装的。Spring-WebFlux和Spring-Cloud-Gateway,其中Spring-Cloud-Gateway依赖Spring-WebFlux,而Spring-WebFlux底层依赖于Reactor-Netty。根据这个链式关系,最好系统学习一下Reactor和Netty。附录选用Spring-Cloud-Gateway不仅仅是为了使用新的技术,更重要的是它的性能有了不俗的提升,基准测试项目spring-cloud-gateway-bench的结果如下:代理组件(Proxy)平均交互延迟(Avg Latency)平均每秒处理的请求数(Avg Requests/Sec)Spring Cloud Gateway6.61ms32213.38Linkered7.62ms28050.76Zuul(1.x)12.56ms20800.13None(直接调用)2.09ms116841.15

通过micrometer实时监控线程池的各项指标

前提最近的一个项目中涉及到文件上传和下载,使用到JUC的线程池ThreadPoolExecutor,在生产环境中出现了某些时刻线程池满负载运作,由于使用了CallerRunsPolicy拒绝策略,导致满负载情况下,应用接口调用无法响应,处于假死状态。考虑到之前用micrometer + prometheus + grafana搭建过监控体系,于是考虑使用micrometer做一次主动的线程池度量数据采集,最终可以相对实时地展示在grafana的面板中。实践过程下面通过真正的实战过程做一个仿真的例子用于复盘。代码改造首先我们要整理一下ThreadPoolExecutor中提供的度量数据项和micrometer对应的Tag的映射关系:线程池名称,Tag:thread.pool.name,这个很重要,用于区分各个线程池的数据,如果使用IOC容器管理,可以使用BeanName代替。int getCorePoolSize():核心线程数,Tag:thread.pool.core.size。int getLargestPoolSize():历史峰值线程数,Tag:thread.pool.largest.size。int getMaximumPoolSize():最大线程数(线程池线程容量),Tag:thread.pool.max.size。int getActiveCount():当前活跃线程数,Tag:thread.pool.active.size。int getPoolSize():当前线程池中运行的线程总数(包括核心线程和非核心线程),Tag:thread.pool.thread.count。当前任务队列中积压任务的总数,Tag:thread.pool.queue.size,这个需要动态计算得出。接着编写具体的代码,实现的功能如下:1、建立一个ThreadPoolExecutor实例,核心线程和最大线程数为10,任务队列长度为10,拒绝策略为AbortPolicy。2、提供两个方法,分别使用线程池实例模拟短时间耗时的任务和长时间耗时的任务。3、提供一个方法用于清空线程池实例中的任务队列。4、提供一个单线程的调度线程池用于定时收集ThreadPoolExecutor实例中上面列出的度量项,保存到micrometer内存态的收集器中。由于这些统计的值都会跟随时间发生波动性变更,可以考虑选用Gauge类型的Meter进行记录。// ThreadPoolMonitor import io.micrometer.core.instrument.Metrics; import io.micrometer.core.instrument.Tag; import org.springframework.beans.factory.InitializingBean; import org.springframework.stereotype.Service; import java.util.Collections; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; * @author throwable * @version v1.0 * @description * @since 2019/4/7 21:02 @Service public class ThreadPoolMonitor implements InitializingBean { private static final String EXECUTOR_NAME = "ThreadPoolMonitorSample"; private static final Iterable<Tag> TAG = Collections.singletonList(Tag.of("thread.pool.name", EXECUTOR_NAME)); private final ScheduledExecutorService scheduledExecutor = Executors.newSingleThreadScheduledExecutor(); private final ThreadPoolExecutor executor = new ThreadPoolExecutor(10, 10, 0, TimeUnit.SECONDS, new ArrayBlockingQueue<>(10), new ThreadFactory() { private final AtomicInteger counter = new AtomicInteger(); @Override public Thread newThread(Runnable r) { Thread thread = new Thread(r); thread.setDaemon(true); thread.setName("thread-pool-" + counter.getAndIncrement()); return thread; }, new ThreadPoolExecutor.AbortPolicy()); private Runnable monitor = () -> { //这里需要捕获异常,尽管实际上不会产生异常,但是必须预防异常导致调度线程池线程失效的问题 try { Metrics.gauge("thread.pool.core.size", TAG, executor, ThreadPoolExecutor::getCorePoolSize); Metrics.gauge("thread.pool.largest.size", TAG, executor, ThreadPoolExecutor::getLargestPoolSize); Metrics.gauge("thread.pool.max.size", TAG, executor, ThreadPoolExecutor::getMaximumPoolSize); Metrics.gauge("thread.pool.active.size", TAG, executor, ThreadPoolExecutor::getActiveCount); Metrics.gauge("thread.pool.thread.count", TAG, executor, ThreadPoolExecutor::getPoolSize); // 注意如果阻塞队列使用无界队列这里不能直接取size Metrics.gauge("thread.pool.queue.size", TAG, executor, e -> e.getQueue().size()); } catch (Exception e) { //ignore @Override public void afterPropertiesSet() throws Exception { // 每5秒执行一次 scheduledExecutor.scheduleWithFixedDelay(monitor, 0, 5, TimeUnit.SECONDS); public void shortTimeWork() { executor.execute(() -> { try { // 5秒 Thread.sleep(5000); } catch (InterruptedException e) { //ignore public void longTimeWork() { executor.execute(() -> { try { // 500秒 Thread.sleep(5000 * 100); } catch (InterruptedException e) { //ignore public void clearTaskQueue() { executor.getQueue().clear(); //ThreadPoolMonitorController import club.throwable.smp.service.ThreadPoolMonitor; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; * @author throwable * @version v1.0 * @description * @since 2019/4/7 21:20 @RequiredArgsConstructor @RestController public class ThreadPoolMonitorController { private final ThreadPoolMonitor threadPoolMonitor; @GetMapping(value = "/shortTimeWork") public ResponseEntity<String> shortTimeWork() { threadPoolMonitor.shortTimeWork(); return ResponseEntity.ok("success"); @GetMapping(value = "/longTimeWork") public ResponseEntity<String> longTimeWork() { threadPoolMonitor.longTimeWork(); return ResponseEntity.ok("success"); @GetMapping(value = "/clearTaskQueue") public ResponseEntity<String> clearTaskQueue() { threadPoolMonitor.clearTaskQueue(); return ResponseEntity.ok("success"); 复制代码配置如下:server: port: 9091 management: server: port: 9091 endpoints: exposure: include: '*' base-path: /management 复制代码prometheus的调度Job也可以适当调高频率,这里默认是15秒拉取一次/prometheus端点,也就是会每次提交3个收集周期的数据。项目启动之后,可以尝试调用/management/prometheus查看端点提交的数据:因为ThreadPoolMonitorSample是我们自定义命名的Tag,看到相关字样说明数据收集是正常的。如果prometheus的Job没有配置错误,在本地的spring-boot项目起来后,可以查下prometheus的后台:OK,完美,可以进行下一步。grafana面板配置确保JVM应用和prometheus的调度Job是正常的情况下,接下来重要的一步就是配置grafana面板。如果暂时不想认真学习一下prometheus的PSQL的话,可以从prometheus后台的/graph面板直接搜索对应的样本表达式拷贝进去grafana配置中就行,当然最好还是去看下prometheus的文档系统学习一下怎么编写PSQL。基本配置:可视化配置,把右边的标签勾选,宽度尽量调大点:查询配置,这个是最重要的,最终图表就是靠查询配置展示的:查询配置具体如下:A:thread_pool_active_size,Legend:{{instance}}-{{thread_pool_name}}线程池活跃线程数。B:thread_pool_largest_size,Legend:{{instance}}-{{thread_pool_name}}线程池历史峰值线程数。C:thread_pool_max_size,Legend:{{instance}}-{{thread_pool_name}}线程池容量。D:thread_pool_core_size,Legend:{{instance}}-{{thread_pool_name}}线程池核心线程数。E:thread_pool_thread_count,Legend:{{instance}}-{{thread_pool_name}}线程池运行中的线程数。F:thread_pool_queue_size,Legend:{{instance}}-{{thread_pool_name}}线程池积压任务数。最终效果多调用几次例子中提供的几个接口,就能得到一个监控线程池呈现的图表:小结针对线程池ThreadPoolExecutor的各项数据进行监控,有利于及时发现使用线程池的接口的异常,如果想要快速恢复,最有效的途径是:清空线程池中任务队列中积压的任务。具体的做法是:可以把ThreadPoolExecutor委托到IOC容器管理,并且把ThreadPoolExecutor的任务队列清空的方法暴露成一个REST端点即可。像HTTP客户端的连接池如Apache-Http-Client或者OkHttp等的监控,可以用类似的方式实现,数据收集的时候可能由于加锁等原因会有少量的性能损耗,不过这些都是可以忽略的,如果真的怕有性能影响,可以尝试用反射API直接获取ThreadPoolExecutor实例内部的属性值,这样就可以避免加锁的性能损耗。

SpringBoot2.x入门:引入web模块
这篇文章是《SpringBoot2.x入门》专辑的「第3篇」文章,使用的SpringBoot版本为2.3.1.RELEASE,JDK版本为1.8。 主要介绍SpringBoot的web模块引入,会相对详细地分析不同的Servlet容器(如Tomcat、Jetty等)的切换,以及该模块提供的SpringMVC相关功能的使用。