news 2026/10/13 1:37:34

Spring AOP拦截器链与proceed()递归调用原理深度解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring AOP拦截器链与proceed()递归调用原理深度解析

1. 从一个诡异的日志重复打印说起

前阵子帮同事排查一个线上问题,现象很怪:同一个Service方法被调用一次,日志却打印了两遍,而且两遍日志的耗时统计还不一样。第一反应是方法被调了两次,但加断点后发现业务逻辑只执行了一次。最后定位到问题出在自定义的AOP拦截器上——拦截器里手动调用了proceed()两次,导致目标方法被重复执行。这个坑让我意识到,很多人用Spring AOP写拦截器时,对MethodInvocation的调用链路其实是一知半解的。

MethodInvocation是Spring AOP里最核心的接口之一,它承载了"一次方法调用"的完整上下文。你写的@Around通知、MethodInterceptor拦截器,最终都是围绕它来工作的。但大部分人对它的理解停留在"调proceed()就是执行原方法"这个层面,至于proceed()背后发生了什么、拦截器链是怎么串起来的、为什么调两次会出问题,往往说不清楚。

这篇内容就是想把MethodInvocation的拦截器调用原理彻底讲透。我会从拦截器链的组装讲起,拆解proceed()方法的执行逻辑,分析递归调用是怎么实现的,再结合几个实际踩过的坑,说明哪些写法会出问题、为什么出问题。适合已经用过Spring AOP但想深入理解其运行机制的后端开发,也适合正在排查AOP相关诡异问题的同学。读完之后,你应该能自己画出一次AOP调用的完整链路图,并且在写拦截器时清楚知道每一行代码在干什么。

2. 拦截器链是怎么被组装出来的

2.1 从Advisor到MethodInterceptor的转换

要理解MethodInvocation,得先搞清楚它拿到的拦截器链是从哪来的。Spring AOP的核心是Advisor,一个Advisor可以简单理解为"切点+通知"的组合。但Advisor本身不是可执行的拦截逻辑,它需要被转换成MethodInterceptor才能真正参与调用。

这个转换工作由AdvisorAdapter完成。Spring内置了几种适配器,比如MethodBeforeAdviceAdapter负责把前置通知适配成MethodInterceptor,AfterReturningAdviceAdapter负责后置通知。每个适配器都实现了supportsAdvice()方法来判断自己能不能处理某个Advice,然后通过getInterceptor()返回一个包装后的拦截器。

这里有个容易被忽略的细节:@Aspect注解的切面类,最终也会被转换成Advisor。AspectJExpressionPointcut负责解析切点表达式,AspectJMethodBeforeAdvice、AspectJAroundAdvice等负责包装通知方法。所以无论你用XML配置还是注解,最终都会走到同一套适配逻辑上。

// 简化后的适配逻辑示意 public MethodInterceptor getInterceptor(Advisor advisor) { Advice advice = advisor.getAdvice(); if (advice instanceof MethodInterceptor) { return (MethodInterceptor) advice; } for (AdvisorAdapter adapter : this.adapters) { if (adapter.supportsAdvice(advice)) { return adapter.getInterceptor(advisor); } } throw new IllegalStateException("No adapter for advice: " + advice); }

这段逻辑说明一个关键点:如果你自己实现了MethodInterceptor接口,它会被直接使用,不会再经过适配器包装。这也是为什么自定义拦截器性能上会稍微好一点——少了一层包装。

2.2 拦截器链的排序规则

拿到所有拦截器之后,Spring会把它们串成一个链。这个链的排序直接决定了通知的执行顺序,而排序规则是很多人搞混的地方。

对于@Around、@Before、@After这些通知,它们的执行顺序遵循一个原则:优先级高的拦截器在外层,优先级低的在内层。所谓"外层",就是先执行、后返回。@Around通知因为要包裹整个调用,所以通常排在最外层;@Before在目标方法前执行,排在中间;@After在目标方法后执行,排在内层。

但这里有个反直觉的地方:@After虽然叫"后置",但它在拦截器链里的位置其实比较靠内。因为拦截器链是递归执行的,内层拦截器的proceed()返回后,外层拦截器才能继续执行后续逻辑。所以@After的实际执行时机是在目标方法之后、外层@Around的后半段之前。

排序的具体实现靠的是Ordered接口和@Order注解。如果没指定顺序,默认值是Ordered.LOWEST_PRECEDENCE,也就是优先级最低。多个同类型通知之间的顺序,在没有显式指定的情况下是不确定的,这一点在写多个切面时要特别注意。

2.3 链的构建时机与缓存

拦截器链不是每次方法调用时临时构建的,而是在创建代理对象时就构建好并缓存的。AdvisedSupport里的methodCache就是干这个的,key是Method对象,value是构建好的拦截器链。

这个缓存机制带来一个实际影响:运行期动态改变切面配置,对已经创建的代理对象不生效。比如你在运行时往Advised里添加一个新的Advisor,已经缓存的拦截器链不会自动刷新。需要手动调用advisedSupport.getMethodCache().clear()或者重新创建代理。这个坑在写动态AOP功能时很容易踩到。

另外,缓存是按Method维度做的,同一个类里不同方法的拦截器链是分开缓存的。如果切点表达式只匹配部分方法,那只有匹配的方法才会有对应的链,不匹配的方法走的是空链或者直接调用。

3. proceed()方法背后的递归调用机制

3.1 ReflectiveMethodInvocation的结构

ReflectiveMethodInvocation是MethodInvocation最常用的实现类,它持有了这次调用需要的所有信息:目标对象、目标方法、参数、拦截器链、当前执行到的拦截器索引。

public class ReflectiveMethodInvocation implements ProxyMethodInvocation { protected final Object proxy; protected final Object target; protected final Method method; protected Object[] arguments; protected final List<?> interceptorsAndDynamicMethodMatchers; private int currentInterceptorIndex = -1; // ... }

currentInterceptorIndex是理解整个机制的关键。它初始值是-1,每次调用proceed()时先自增,然后取出对应位置的拦截器。如果自增后的索引等于拦截器链的长度,说明所有拦截器都执行完了,该调用目标方法了。

这个设计很巧妙:同一个MethodInvocation对象在整条链上传递,索引状态记录在对象内部。所以拦截器链的执行不是靠方法栈的层层嵌套来传递状态的,而是靠这个共享的索引变量。

3.2 proceed()的完整执行流程

proceed()方法的逻辑可以拆成几个清晰的步骤:

public Object proceed() throws Throwable { // 1. 所有拦截器都执行完了,调用目标方法 if (this.currentInterceptorIndex == this.interceptorsAndDynamicMethodMatchers.size() - 1) { return invokeJoinpoint(); } // 2. 取出下一个拦截器 Object interceptorOrInterceptionAdvice = this.interceptorsAndDynamicMethodMatchers.get(++this.currentInterceptorIndex); // 3. 处理动态方法匹配器(运行期切点) if (interceptorOrInterceptionAdvice instanceof InterceptorAndDynamicMethodMatcher) { InterceptorAndDynamicMethodMatcher dm = (InterceptorAndDynamicMethodMatcher) interceptorOrInterceptionAdvice; if (dm.methodMatcher.matches(this.method, this.target.getClass(), this.arguments)) { return dm.interceptor.invoke(this); } else { // 不匹配,跳过这个拦截器,继续下一个 return proceed(); } } else { // 4. 普通拦截器,直接调用 return ((MethodInterceptor) interceptorOrInterceptionAdvice).invoke(this); } }

注意第3步里的return proceed(),这是一个递归调用。当动态匹配器判断不匹配时,会跳过当前拦截器继续往下走。这种递归写法保证了即使有拦截器被跳过,索引也能正确推进。

第4步是核心:拦截器的invoke(this)方法接收的是当前这个MethodInvocation对象。拦截器内部如果要继续往下执行,就得调用invocation.proceed()。这就形成了递归——每个拦截器在invoke里调proceed(),proceed()又去调下一个拦截器的invoke。

3.3 递归调用栈的实际形态

用一个具体的例子来说明。假设有三个拦截器A、B、C,目标方法叫target()。执行时的调用栈是这样的:

A.invoke(invocation) -> invocation.proceed() // index变成0,取出A -> A.invoke(invocation) -> invocation.proceed() // index变成1,取出B -> B.invoke(invocation) -> invocation.proceed() // index变成2,取出C -> C.invoke(invocation) -> invocation.proceed() // index变成3,等于size-1 -> invokeJoinpoint() // 调用target() <- target()返回 <- C.invoke返回 <- B.invoke返回 <- A.invoke返回

这个栈形结构解释了为什么@Around能包裹整个调用:因为A的invoke方法里,proceed()调用之前的代码在"进入"阶段执行,proceed()之后的代码在"返回"阶段执行。而B、C的invoke都在A的proceed()调用栈里面,所以A自然就包裹了B和C。

3.4 为什么调两次proceed()会出问题

回到开头那个日志重复打印的问题。如果某个拦截器的invoke方法里调了两次proceed(),会发生什么?

第一次调proceed(),索引从当前值自增,执行下一个拦截器,最终调用目标方法,然后返回。第二次调proceed(),索引再次自增,但此时索引已经越过了链的末尾,会直接调用invokeJoinpoint(),也就是目标方法被再执行一次。

更麻烦的是,如果链中间有多个拦截器,第二次proceed()会从当前索引继续往后走,可能跳过一些拦截器,也可能重复执行某些拦截器。具体行为取决于当前索引的位置。这就是为什么日志会打印两遍,而且两次的耗时统计不一样——第二次执行时,部分拦截器被跳过了。

注意:在@Around通知里,proceed()必须且只能调用一次。如果确实需要重试逻辑,应该在proceed()外层包一个循环,而不是在循环里反复调proceed()。因为MethodInvocation的索引状态是共享的,反复调proceed()不会重置状态。

4. 动态切点与运行期匹配的代价

4.1 静态切点与动态切点的区别

Spring AOP的切点分两种:静态切点和动态切点。静态切点在代理创建时就能确定是否匹配,比如execution(* com.example.service.*.*(..))这种基于方法签名的切点。动态切点需要在运行期根据方法参数来判断,比如args(param)这种带参数条件的切点。

静态切点的匹配结果会被缓存,同一个方法多次调用时不需要重复匹配。动态切点则每次调用都要重新判断,因为参数可能不同。这个区别直接影响了proceed()的执行路径。

在ReflectiveMethodInvocation的拦截器链里,动态切点对应的拦截器会被包装成InterceptorAndDynamicMethodMatcher。proceed()方法里那段instanceof判断就是专门处理这种情况的。如果匹配,正常调用拦截器;如果不匹配,跳过并递归调用proceed()。

4.2 动态匹配的性能影响

动态切点的性能开销主要来自两方面:一是每次调用都要执行methodMatcher.matches(),二是递归调用带来的额外栈帧。

matches()方法的开销取决于切点表达式的复杂度。简单的args()匹配可能只是检查参数类型,开销很小。但如果切点里用了自定义的MethodMatcher,每次都要执行自定义逻辑,开销就可能很大。我见过一个项目在切点里做了数据库查询来判断是否匹配,结果每次方法调用都多了一次数据库往返,性能直接崩了。

递归调用的开销相对小一些,但在高频调用的方法上也会累积。如果链里有多个动态切点,而且大部分都不匹配,那每次调用都要经历多次递归跳过,栈深度会增加。

4.3 动态切点的优化思路

如果确实需要动态切点,有几个优化方向:

  • 尽量用静态切点缩小范围:先用execution()把范围限定到少数几个方法,再用args()做动态判断。这样动态匹配只在少数方法上发生。
  • 缓存匹配结果:如果参数组合有限,可以在自定义MethodMatcher里缓存匹配结果。但要注意缓存的key设计,避免内存泄漏。
  • 避免在切点里做重操作:切点匹配应该是轻量的,数据库查询、远程调用这类操作绝对不应该放在matches()里。
// 不推荐的写法:切点里做重操作 public boolean matches(Method method, Class<?> targetClass, Object... args) { // 每次调用都查数据库,性能灾难 return userService.isSpecialUser(args[0]); } // 推荐的写法:静态切点缩小范围 + 轻量动态判断 @Pointcut("execution(* com.example.service.OrderService.createOrder(..)) && args(userId, ..)") public void createOrderPointcut(Long userId) {} @Around("createOrderPointcut(userId)") public Object aroundCreateOrder(ProceedingJoinPoint pjp, Long userId) { // 动态判断放在通知里,而不是切点里 if (isSpecialUser(userId)) { // 特殊处理 } return pjp.proceed(); }

把动态判断从切点移到通知里,好处是切点匹配变成静态的,拦截器链的构建和缓存更高效。动态判断的逻辑在通知里执行,可以更灵活地控制,也更容易做缓存。

5. 拦截器里那些容易踩的坑

5.1 修改参数后目标方法拿不到新值

在@Around通知里,通过ProceedingJoinPoint.getArgs()拿到的是参数的引用。如果参数是对象,修改对象内部属性会生效;但如果要替换整个参数对象,直接改数组元素是不生效的。

@Around("execution(* com.example.service.*.*(..))") public Object around(ProceedingJoinPoint pjp) throws Throwable { Object[] args = pjp.getArgs(); // 这样改没用,目标方法拿到的还是原参数 args[0] = "new value"; return pjp.proceed(); }

正确做法是调用proceed(Object[] args)重载方法,传入新的参数数组:

@Around("execution(* com.example.service.*.*(..))") public Object around(ProceedingJoinPoint pjp) throws Throwable { Object[] args = pjp.getArgs(); args[0] = "new value"; // 传入新参数数组 return pjp.proceed(args); }

这个坑的根源在于ReflectiveMethodInvocation里的arguments字段。proceed()无参版本用的是this.arguments,而proceed(Object[])会先替换this.arguments再执行。所以直接改getArgs()返回的数组,改的是同一个数组对象,但proceed()无参版本不会重新读取,导致修改丢失。

5.2 拦截器里的异常处理陷阱

在拦截器里catch异常然后不抛出,会导致上层调用者以为方法执行成功了。这个坑在事务场景下特别危险:如果拦截器吞掉了异常,事务管理器收不到异常信号,就不会回滚。

@Around("execution(* com.example.service.*.*(..))") public Object around(ProceedingJoinPoint pjp) { try { return pjp.proceed(); } catch (Exception e) { log.error("方法执行异常", e); // 吞掉异常,事务不会回滚 return null; } }

如果确实需要捕获异常做处理,要么处理完重新抛出,要么明确知道自己在做什么。对于事务方法,异常必须传播到事务拦截器才能触发回滚。

另一个相关的问题是异常类型转换。如果把受检异常包装成运行时异常抛出,要注意事务回滚规则。默认情况下,Spring事务只对运行时异常回滚,受检异常不回滚。如果包装后的异常类型不对,事务同样不会回滚。

5.3 拦截器顺序导致的空指针

多个切面同时作用于一个方法时,执行顺序很重要。如果切面A依赖切面B设置的数据,但A的执行顺序在B之前,就会拿到null。

比如一个权限切面和一个日志切面,日志切面要记录当前用户信息,而用户信息是权限切面设置的。如果日志切面先执行,拿到的用户信息就是null。

解决方法是显式指定顺序:

@Aspect @Order(1) // 权限切面先执行 public class AuthAspect { } @Aspect @Order(2) // 日志切面后执行 public class LogAspect { }

@Order的值越小,优先级越高,在拦截器链里越靠外。靠外的拦截器先执行"进入"阶段的逻辑,所以@Order(1)的切面会先设置用户信息,@Order(2)的切面才能拿到。

但要注意,@Order只对同类型的通知有效。如果一个是@Around,一个是@Before,它们的相对顺序还受到通知类型的影响。@Around通常在最外层,@Before在中间,@After在内层。所以即使@Before的@Order值更小,它也可能在@Around的"进入"阶段之后才执行。

5.4 自调用导致AOP失效

这个坑太经典了,但还是要提一下。同一个类里的方法A调用方法B,如果B上有AOP通知,通知不会生效。因为自调用走的是this引用,而不是代理对象。

@Service public class OrderService { public void methodA() { // 自调用,B上的AOP不生效 methodB(); } @Transactional public void methodB() { // 事务不会生效 } }

解决办法有几种:注入自身代理、用AopContext.currentProxy()、或者把方法B拆到另一个Bean里。注入自身代理是最推荐的方式,但要注意循环依赖问题。Spring Boot 2.6之后默认禁止循环依赖,需要额外配置才能开启。

@Service public class OrderService { @Autowired @Lazy // 延迟注入,避免循环依赖 private OrderService self; public void methodA() { // 通过代理调用,AOP生效 self.methodB(); } }

@Lazy注解的作用是注入一个代理对象,真正调用时才去容器里拿Bean,从而打破循环依赖。这个方案在大多数场景下都能工作,但要注意@Lazy注入的代理对象在第一次调用时会有轻微的性能开销。

6. 从源码角度验证调用链路

6.1 用调试断点跟踪proceed()

光看源码容易漏掉细节,最好的验证方式是打断点跟一遍。在ReflectiveMethodInvocation.proceed()方法入口打上断点,然后调用一个被AOP拦截的方法,观察每次进入断点时currentInterceptorIndex的值和拦截器链的内容。

第一次进入断点,currentInterceptorIndex是-1,自增后变成0,取出第一个拦截器。如果第一个拦截器是@Around通知,它的invoke方法会调用proceed(),再次进入断点。此时索引变成1,取出第二个拦截器。以此类推,直到索引等于链长度减一,调用invokeJoinpoint()。

在调试过程中可以观察interceptorsAndDynamicMethodMatchers列表的内容,看看每个元素是什么类型。如果是InterceptorAndDynamicMethodMatcher,说明是动态切点;如果是普通的MethodInterceptor,说明是静态切点。这个观察能帮你确认切点的实际匹配情况。

6.2 自定义拦截器验证执行顺序

写一个简单的自定义拦截器,在invoke方法里打印进入和退出的日志,然后配置多个拦截器,观察日志顺序。

public class LogInterceptor implements MethodInterceptor { private final String name; public LogInterceptor(String name) { this.name = name; } @Override public Object invoke(MethodInvocation invocation) throws Throwable { System.out.println(name + " 进入"); try { return invocation.proceed(); } finally { System.out.println(name + " 退出"); } } }

配置三个拦截器A、B、C,预期输出是:

A 进入 B 进入 C 进入 C 退出 B 退出 A 退出

这个输出验证了拦截器链的栈形结构。如果输出顺序不对,说明拦截器的排序有问题,需要检查Ordered接口的实现或者@Order注解的值。

6.3 观察动态切点的跳过行为

配置一个动态切点,让它在某些参数下不匹配,然后观察proceed()的递归跳过行为。可以在proceed()方法里加日志,打印每次取出的拦截器类型和匹配结果。

// 在proceed()方法里临时加日志 System.out.println("当前索引: " + currentInterceptorIndex + ", 拦截器: " + interceptorOrInterceptionAdvice.getClass().getSimpleName());

当动态切点不匹配时,会看到日志里连续打印多个拦截器,但只有匹配的那个才真正执行了invoke。这个观察能帮你理解动态切点的实际开销——即使不匹配,也要走一遍proceed()的递归逻辑。

7. 几个实际场景下的选型建议

7.1 什么时候用MethodInterceptor,什么时候用@Aspect

MethodInterceptor是Spring AOP的原生接口,@Aspect是AspectJ风格的注解。两者的能力基本对等,但使用场景有区别。

MethodInterceptor更适合写框架级的通用拦截逻辑,比如事务、缓存、重试。因为它是接口,可以被其他框架直接引用和组合,不依赖注解解析。Spring自己的事务拦截器TransactionInterceptor就是MethodInterceptor的实现。

@Aspect更适合业务代码里的切面,因为注解写起来更直观,切点表达式也更灵活。特别是需要多个通知类型(@Before、@After、@Around)组合时,@Aspect的代码组织更清晰。

性能上两者差别不大,因为@Aspect最终也会被适配成MethodInterceptor。唯一的区别是@Aspect多了一层适配器的包装,但这个开销在方法调用的整体开销里可以忽略。

7.2 拦截器里该不该做耗时操作

拦截器是在目标方法的调用栈上执行的,拦截器里的耗时操作会直接加到方法的总耗时上。所以拦截器里应该只做轻量的、必要的逻辑。

常见的反模式是在拦截器里做远程调用、大量计算、同步IO。比如在权限拦截器里每次调用都去远程鉴权服务查一次权限,这个开销会直接反映到接口响应时间上。

如果确实需要做耗时操作,考虑几个方案:把结果缓存起来、改成异步执行、或者把逻辑移到业务方法内部按需执行。拦截器应该只做"判断"和"记录"这类轻量操作,重操作应该下沉到业务层或者用异步方式处理。

7.3 多个切面叠加时的顺序设计

多个切面叠加时,顺序设计要遵循"依赖关系决定顺序"的原则。被依赖的切面先执行,依赖方后执行。

典型的依赖关系有:日志切面依赖用户上下文切面、事务切面依赖数据源切面、缓存切面依赖序列化切面。设计时先画出切面之间的依赖图,然后按拓扑顺序分配@Order值。

如果切面之间没有依赖关系,那顺序可以随意,但建议保持稳定,避免因为顺序变化导致行为不一致。可以给每个切面分配固定的@Order值,并在注释里说明为什么是这个值。

提示:@Order的值不要用连续整数,留出间隔。比如用10、20、30而不是1、2、3。这样以后要在中间插入新切面时,不用重新调整所有切面的顺序。

8. 写在最后的一点经验

MethodInvocation的拦截器调用机制,核心就是那个currentInterceptorIndex和递归的proceed()。理解了这两点,大部分AOP的诡异问题都能解释清楚。

我自己踩过的最大的坑,是在拦截器里做参数校验时,校验失败直接返回了一个错误对象,没有抛异常。结果上层调用者以为方法执行成功了,拿着错误对象继续往下走,最后在很远的地方才报错,排查了半天才定位到拦截器。从那以后,我在拦截器里做校验失败时,一律抛异常,让调用链明确知道出错了。

另一个经验是,写拦截器时尽量保持"无状态"。拦截器实例是单例的,如果拦截器里存了实例变量,多个线程同时调用时会互相干扰。所有状态都应该放在MethodInvocation里或者用ThreadLocal传递。这个原则看起来简单,但在写复杂拦截器时很容易违反。

最后,如果你在排查AOP相关的问题,第一件事是确认代理是否生效。可以在目标方法里打印this.getClass().getName(),看看是不是代理类。如果是原始类名,说明代理没生效,后面的拦截器逻辑根本不会执行。这个检查能帮你快速排除掉一半的可能性。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/13 1:37:18

PyTorch官方指南翻译实践:从术语表到代码验证的完整复盘

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 1:36:06

带权并查集入门:从A Bug‘s Life理解关系维护与路径压缩

刷POJ的并查集题单&#xff0c;有一道题绕不开&#xff1a;A Bugs Life。我第一次做它的时候&#xff0c;用普通并查集硬怼了半天&#xff0c;WA了快十发才反应过来——这题根本不是“把虫子分成几堆”那么简单&#xff0c;它需要给并查集的每条边加上权值&#xff0c;去表示集…

作者头像 李华
网站建设 2026/10/13 1:35:45

C# WinForm医院药品管理系统源码解析:SQL Server数据库设计与事务处理

简介&#xff1a;这是一套面向高校计算机相关专业学生与初学者的医院药品管理系统完整源码&#xff0c;基于C#的WinForm框架与SQL Server数据库开发&#xff0c;适用于课程设计、毕业设计或二次开发参考。系统覆盖用户管理、药品信息维护、入库出库、药品申领与审核、库存预警及…

作者头像 李华
网站建设 2026/10/13 1:35:04

从zhilian1.zip到可运行项目:解压、环境搭建与验证指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/13 1:32:39

树莓派与Ubuntu上MAVROS安装全攻略:从环境配置到定点降落实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华