news 2026/10/10 3:34:02

JUnit 5自定义@ClassTemplate:基于@TestTemplate扩展机制实现多环境测试

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JUnit 5自定义@ClassTemplate:基于@TestTemplate扩展机制实现多环境测试

先说结论:JUnit 5 官方并没有内置@ClassTemplate这个注解。你在 IDE 里敲一个@ClassTemplate,编译器会直接标红。但很多项目确实在这么写——准确说,大家叫的“类模板”,底层其实是@TestTemplate+TestTemplateInvocationContextProvider这套扩展机制。这篇文章我会从零讲清楚这套机制,然后手写一个可用的@ClassTemplate自定义注解,带一个能直接跑起来的示例,再整理我实际踩过的坑。适合那些已经会用@ParameterizedTest,但对 JUnit 5 扩展体系还比较模糊的读者。

1. 先搞明白:@ClassTemplate 到底是不是 JUnit 5 的原生注解

1.1 官方答案:只有 @TestTemplate

JUnit 5(Jupiter)里,模板机制的官方入口是@TestTemplate。一个方法标了@TestTemplate,JUnit 就不会把它当成普通@Test来执行,而是把它当成一个“测试模板样板”。真正执行多少次、用什么参数和环境,全部由注册到该方法上的TestTemplateInvocationContextProvider决定。

官方的@TestTemplate只存在一个目标:方法。你翻junit-jupiter-api的源码,会看到:

@Target({ ElementType.ANNOTATION_TYPE, ElementType.METHOD }) @Retention(RetentionPolicy.RUNTIME) @API(status = STABLE, since = "5.0") @Testable public @interface TestTemplate { }

注意它的@Target里包含ANNOTATION_TYPE,这意味着官方设计时就允许你拿它做组合注解的“元注解”。所以@ClassTemplate不是官方 API,但它完全可以是一个合法的自定义组合注解。很多团队在内部框架里实现的“类模板”,本质就是把@TestTemplate和某个@ExtendWith包到一个新注解里,再让这个注解自动携带 provider。这样做的好处是:使用方只需要写一个注解,背后复杂的 provider 注册、参数解析、配置读取全部被封装起来。

1.2 真实诉求:大家都在说的“类模板”是什么

我最早听到“类模板”这个词,是在一个做接口兼容性测试的项目里。需求很简单:同一套接口断言逻辑,要在三个环境各跑一遍——本地、预发布、生产。三个环境的基础地址不同、认证 token 不同、部分返回结构有细微差异。如果每个环境复制一份测试方法,那代码维护成本巨大;如果只用@ParameterizedTest,参数可以传进去,但每个环境需要注入额外的扩展(比如环境变量、Mock 对象、临时目录)时,@ParameterizedTest就没那么顺手。

那时候团队里有人提议:“能不能给整个测试类加一个@ClassTemplate,让类下的每个方法都自动按环境模板跑?” 于是就开始在 JUnit 5 的扩展机制里找方案。最后落地的方式是:类上注册一个TestTemplateInvocationContextProvider,方法上使用@TestTemplate。每个测试方法会被 provider 生成的多个 invocation context 分别执行多次,每次执行都会得到一个独立的扩展上下文——参数解析器、生命周期回调、异常处理器都可以不同。

所以“类模板”的真正价值不是“给类加注解”,而是“让多个测试方法共享同一套 invocation context 生成逻辑”。搞清楚这个词指什么,比纠结注解叫什么都重要。

1.3 三条实现路线,我推荐组合注解

当年我实现“类模板”时,调研过三条路线。

第一条是最朴素的方式:测试类上用@ExtendWith(EnvironmentProvider.class)注册 provider,每个测试方法上写@TestTemplate。这个方式能跑通,但代码很啰嗦。类上要写一个 extension,方法上还要写一个注解,如果换 provider 还得两头改。

第二条是用@TestFactory生成DynamicTest。这种方式能批量生成动态测试,但@TestFactory的返回值是容器,不是普通测试方法;依赖 JUnit 的ParameterResolver、BeforeEachCallback这类扩展能力时,表现和声明式测试模板不一样,调试报告也不如@TestTemplate直观。

第三条就是自定义组合注解。自己定义一个@ClassTemplate,内部把@TestTemplate和@ExtendWith组合起来,让 provider 从注解参数中读取配置。使用方只写一个注解,provider 的注册被隐藏起来,配置还可以自定义。这条路线保留了@TestTemplate的全部扩展能力,代码又最干净。本文下面全部围绕第三条路线展开。

2. 核心机制拆解:TestTemplateInvocationContext 到底做了什么

2.1 一次模板调用 = 一次独立测试

理解@TestTemplate之前,先看一张心智模型。普通@Test方法:一个方法 = 一个测试用例。@ParameterizedTest方法:一个方法 + 参数集合 = N 个测试用例,但每个用例共享同一套测试方法体。@TestTemplate方法:一个方法 + provider 生成的多个 invocation context = N 个测试用例,每个 context 还可以携带独立的一组扩展。

换句话说,TestTemplateInvocationContext是“一次测试运行所需的全部环境信息”的载体。一个 context 里有两个关键能力:

  • getDisplayName(int invocationIndex):返回这次运行的展示名,会在测试报告里显示成类似[1] environment=production的格式。
  • getAdditionalExtensions():返回这次运行额外注册的 JUnit 扩展列表。比如一个ParameterResolver,负责解析被测方法的参数;一个BeforeEachCallback,负责在运行前准备环境。

每个 context 最终都会变成一个独立的TestTemplateInvocationContext测试用例,拥有自己的生命周期。也就是说,模板方法里如果写了@BeforeEach,每个 invocation 都会执行一次;如果某个 invocation 的ParameterResolver无法解析参数,只会影响当前 invocation,不会污染其他 invocation。

2.2 TestTemplateInvocationContextProvider 两个方法的分工

实现一个 provider,必须实现TestTemplateInvocationContextProvider接口。它有两个核心方法。

public interface TestTemplateInvocationContextProvider extends Extension { boolean supportsTestTemplate(ExtensionContext context); Stream<TestTemplateInvocationContext> provideTestTemplateInvocationContexts(ExtensionContext context); }

supportsTestTemplate决定“当前这个方法要不要用这个 provider”。返回false就直接跳过,返回true才会继续调用provideTestTemplateInvocationContexts。provideTestTemplateInvocationContexts负责生成上下文流。这里有一个很容易踩的坑:provide方法返回的是Stream,不是集合。如果你返回的Stream没有严格按照顺序消费,某些InvocationContext可能不会执行。所以生产环境里我一般直接用Stream.of(...)或List.stream(),避免并发导致上下文丢失。

另外要注意,supportsTestTemplate里可以直接通过context.getTestMethod()拿当前被测方法。所以 provider 完全可以根据方法上的注解、方法名、方法参数类型来决定是否启用。这也是后面@ClassTemplate读取注解配置的基础。

2.3 扩展注册的两种位置与生效范围

JUnit 5 的@ExtendWith可以放在类上,也可以放在方法上。如果放在类上,它对该类下所有@TestTemplate方法生效;如果放在方法上,只对该方法生效。常见的一个误解是:provider 必须注册在类上,否则“类模板”不成立。实际上,@ClassTemplate这种组合注解把@ExtendWith放到方法上,一样能实现多个方法共享同一套 provider 逻辑——因为 provider 做的不是“按类注册一次”,而是“每个测试方法注册一次,但内部读取同一个配置源”。如果你的配置源来自类上的注解,那就需要在 provider 里通过context.getTestClass()去读取父级上下文。这个细节后面会提到。

官方还提供了@RegisterExtension,它可以在字段上注册 extension。@RegisterExtension支持静态字段和实例字段,静态字段在类级别生效,实例字段在实例级别生效。但如果你在测试类里用@RegisterExtension注册了一个TestTemplateInvocationContextProvider,它不会自动绑定到@TestTemplate方法。因为@RegisterExtension的管理对象是 test instance,而模板的调用链是走方法级注解发现的。所以我建议:能注解就注解,少用编程式注册。

3. 实战:从零手写一个 @ClassTemplate 组合注解

3.1 定义注解:把 @TestTemplate 和 @ExtendWith 组合起来

下面开始写代码。场景是:我要做多环境接口兼容性测试,不同环境的基础信息不同。定义一个@ClassTemplate注解,里面放一个providers属性,用来指定哪个 provider 负责生成 invocation context。

import org.junit.jupiter.api.TestTemplate; import org.junit.jupiter.api.extension.ExtendWith; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @TestTemplate @ExtendWith(ClassTemplateExtension.class) public @interface ClassTemplate { Class<? extends TestTemplateInvocationContextProvider>[] providers() default {}; }

这里有三点设计考量。

第一,@Target(ElementType.METHOD)是强制的。因为@TestTemplate和@ExtendWith本身都只支持方法,组合注解的 target 也不能放宽,否则 JUnit 在扫描时会忽略无法应用在方法上的注解。

第二,@Retention(RetentionPolicy.RUNTIME)必须保留到运行时。JUnit 5 的扩展机制是在运行时通过反射读取注解的,如果 retention 是SOURCE或CLASS,provider 里就完全拿不到@ClassTemplate实例。

第三,providers使用Class<? extends TestTemplateInvocationContextProvider>[]类型。这样可以在注解里传多个 provider,而不需要硬编码 provider 类。注意不能传Class<?>,因为后面要用反射实例化并强转到 provider 接口,提前限定泛型能避免运行时强转异常。

3.2 实现 provider:读取注解上的 provider 配置

ClassTemplateExtension需要实现TestTemplateInvocationContextProvider,它的职责是:看到方法上有@ClassTemplate,就把注解里配置的多个 provider 都实例化,然后逐个调用它们的provideTestTemplateInvocationContexts,最后把所有流拼接起来。

import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.api.extension.TestTemplateInvocationContext; import org.junit.jupiter.api.extension.TestTemplateInvocationContextProvider; import java.util.Arrays; import java.util.stream.Stream; public class ClassTemplateExtension implements TestTemplateInvocationContextProvider { @Override public boolean supportsTestTemplate(ExtensionContext context) { return context.getTestMethod() .map(method -> method.isAnnotationPresent(ClassTemplate.class)) .orElse(false); } @Override public Stream<TestTemplateInvocationContext> provideTestTemplateInvocationContexts(ExtensionContext context) { ClassTemplate annotation = context.getRequiredTestMethod() .getAnnotation(ClassTemplate.class); if (annotation == null || annotation.providers().length == 0) { return Stream.empty(); } return Arrays.stream(annotation.providers()) .map(this::instantiate) .flatMap(provider -> provider.provideTestTemplateInvocationContexts(context)); } private TestTemplateInvocationContextProvider instantiate( Class<? extends TestTemplateInvocationContextProvider> providerClass) { try { return providerClass.getDeclaredConstructor().newInstance(); } catch (Exception e) { throw new IllegalStateException("无法实例化 provider: " + providerClass.getName(), e); } } }

这段代码里有个特别重要的细节:supportsTestTemplate里的判断直接影响了整个执行链路。如果这个方法返回false,后面所有逻辑都不会发生。我见过不少朋友把supportsTestTemplate写成return true,结果所有普通方法也被模板 provider 扫描,然后getAnnotation(ClassTemplate.class)返回null,方法执行 0 次,原测试根本不会跑。所以判断一定要精确到目标注解。

此外,这里的ClassTemplateExtension作为@ExtendWith指向的扩展类,必须有一个无参构造函数。JUnit 会为它创建实例,如果 provider 里有状态,就要保证每次创建是干净的。多个测试方法之间,JUnit 会复用同一个扩展实例吗?不会。同一个扩展类的实例在每个测试方法上会按需要被重新创建,所以尽量让 provider 无状态,或者把状态放在InvocationContext里。

3.3 实现 invocation context:displayName + 参数解析器

现在定义一个环境 provider。它的作用是根据不同环境,生成不同的TestTemplateInvocationContext。

import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.api.extension.TestTemplateInvocationContext; import org.junit.jupiter.api.extension.TestTemplateInvocationContextProvider; import java.util.stream.Stream; public class EnvironmentProvider implements TestTemplateInvocationContextProvider { @Override public boolean supportsTestTemplate(ExtensionContext context) { return true; } @Override public Stream<TestTemplateInvocationContext> provideTestTemplateInvocationContexts(ExtensionContext context) { return Stream.of( new EnvironmentContext("local", "http://localhost:8080", 200), new EnvironmentContext("staging", "http://staging.example.com", 200), new EnvironmentContext("production", "http://prod.example.com", 500) ); } }

EnvironmentContext实现TestTemplateInvocationContext接口:

import org.junit.jupiter.api.extension.Extension; import org.junit.jupiter.api.extension.ParameterContext; import org.junit.jupiter.api.extension.ParameterResolver; import org.junit.jupiter.api.extension.TestTemplateInvocationContext; import java.util.List; public class EnvironmentContext implements TestTemplateInvocationContext { private final String name; private final String baseUrl; private final int expectedCode; public EnvironmentContext(String name, String baseUrl, int expectedCode) { this.name = name; this.baseUrl = baseUrl; this.expectedCode = expectedCode; } @Override public String getDisplayName(int invocationIndex) { return "environment=" + name + ", baseUrl=" + baseUrl + ", expected=" + expectedCode; } @Override public List<Extension> getAdditionalExtensions() { return List.of(new EnvironmentParameterResolver(name, baseUrl, expectedCode)); } }

getDisplayName是每个 invocation 在测试报告里的标题。如果不实现,默认会显示方法名加编号,比如[1] test[1],很难看出当前跑的是哪个环境。我建议把关键参数尽量带进去。报告里一眼能看出失败的是哪个环境。

getAdditionalExtensions是最核心的。它返回的扩展会被 JUnit 注册到这次 invocation 上。我们这里返回了一个EnvironmentParameterResolver,作用是把EnvironmentInfo参数注入到测试方法。

public class EnvironmentInfo { public final String name; public final String baseUrl; public final int expectedCode; public EnvironmentInfo(String name, String baseUrl, int expectedCode) { this.name = name; this.baseUrl = baseUrl; this.expectedCode = expectedCode; } @Override public String toString() { return name + "(" + baseUrl + ", expected=" + expectedCode + ")"; } }

ParameterResolver 的实现:

import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.api.extension.ParameterContext; import org.junit.jupiter.api.extension.ParameterResolver; public class EnvironmentParameterResolver implements ParameterResolver { private final String name; private final String baseUrl; private final int expectedCode; public EnvironmentParameterResolver(String name, String baseUrl, int expectedCode) { this.name = name; this.baseUrl = baseUrl; this.expectedCode = expectedCode; } @Override public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { return parameterContext.getParameter().getType() == EnvironmentInfo.class; } @Override public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { return new EnvironmentInfo(name, baseUrl, expectedCode); } }

这里有个很容易踩的坑:ParameterResolver只是 JUnit 支持的扩展之一。如果你在getAdditionalExtensions()里还加了BeforeEachCallback、AfterEachCallback、TestExecutionExceptionHandler,所有这些扩展都会叠加到当前 invocation 上,并且只对当前 invocation 生效。所以模板机制很适合做环境隔离——每个环境不仅参数不同,连前后的初始化逻辑都可以不同。

3.4 动手跑一个示例:多环境兼容性测试

接下来写测试类。假设要测一个 HTTP 请求工具类,接收环境信息,返回状态码是否符合预期。

import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; public class HttpCheckerTest { @ClassTemplate(providers = EnvironmentProvider.class) void shouldCheckHttpStatus(EnvironmentInfo env) { int statusCode = HttpChecker.check(env.baseUrl); assertEquals(env.expectedCode, statusCode, "环境 " + env.name + " 的返回码不符合预期"); } @Test void normalTest() { assertTrue(true); } }

这里@ClassTemplate(providers = EnvironmentProvider.class)替代了原本需要的两行代码:@TestTemplate和@ExtendWith(EnvironmentProvider.class)。如果不用自定义注解,你需要写成:

@TestTemplate @ExtendWith(EnvironmentProvider.class) void shouldCheckHttpStatus(EnvironmentInfo env) { ... }

用@ClassTemplate之后,测试报告会显示三个被执行的用例:

  • shouldCheckHttpStatus[1] environment=local, baseUrl=http://localhost:8080, expected=200
  • shouldCheckHttpStatus[2] environment=staging, baseUrl=http://staging.example.com, expected=200
  • shouldCheckHttpStatus[3] environment=production, baseUrl=http://prod.example.com, expected=500

第四个普通测试方法normalTest不会受到任何影响。这就是组合注解的威力:既把模板逻辑封装干净,又不干扰同类的普通测试方法。

4. 踩过的坑与排查方法

4.1 常见报错汇总

表格是我做类似封装时遇到的问题,按出现频率排序。

现象根因解决办法
方法完全没有执行,报告里没有记录@TestTemplate没有被识别,或supportsTestTemplate返回了false检查注解 target 和 retention;在supportsTestTemplate里加日志
执行次数为 0providers()返回空数组,或 provider 的provideTestTemplateInvocationContexts返回空流给注解数组默认加至少一个 provider;打印 provider 类名
@ClassTemplate(providers = X.class)项目无法编译providers类型写成了Class<?>改成Class<? extends TestTemplateInvocationContextProvider>
provider 实例化报NoSuchMethodExceptionprovider 类没有无参构造器为 provider 增加无参构造器,或者用getDeclaredConstructor().newInstance()捕获异常
参数注入不进去,方法报ParameterResolutionExceptiongetAdditionalExtensions返回的 resolver 没有匹配参数类型检查supportsParameter是否精确匹配类型,注意基本类型和包装类型
displayName 没有生效某些报表工具/IDE 视图没有展示 invocation 标题在getDisplayName返回完整字符串,并在日志中输出验证

其中“方法完全没有执行”是最隐蔽的。因为 JUnit 在扫描测试方法时,如果一个方法只标了@ClassTemplate,而@ClassTemplate并没有把@TestTemplate作为元注解,那这个方法根本不会被当成测试方法,连报错都不会有。所以检查@ClassTemplate定义里是否包含@TestTemplate必须是第一步。

4.2 元注解失效的排查

组合注解的失效通常出在两个地方。一个是@Target不对。虽然 JUnit 允许@TestTemplate标注在ANNOTATION_TYPE上,但如果你自定义注解的@Target里没有METHOD,那它就没法标注到测试方法上,自然不会被扫描。另一个是@ExtendWith的组合问题。JUnit 5 支持把@ExtendWith作为元注解,但前提是组合注解必须能被 JUnit 的注解扫描机制读到。测试类如果被代理包装,或者注解的 retention 不够,就会丢失。

我习惯的做法是写一个最小验证用例:只有@ClassTemplate,没有 provider,方法里打印一行日志。如果能执行,说明元注解和扩展都生效了;如果不能执行,再一层层拆。最常用的排查方式是在ClassTemplateExtension.supportsTestTemplate中加System.out.println(context.getRequiredTestMethod()),看看 JUnit 到底把它识别成了什么。

4.3 什么时候不要用 @ClassTemplate

定制化程度高不代表什么都该用。我个人的经验是:

  • 如果只是传几组基础参数,优先用@ParameterizedTest+@MethodSource,它更简单,IDE 支持也最好。
  • 如果要在运行时动态生成测试用例,且需要严格控制测试数量,可以用@TestFactory。
  • 如果多个测试方法需要共享同一套“执行场景”,且场景里包含参数解析、生命周期回调、扩展注入,那么@TestTemplate和自定义@ClassTemplate才值得上。
  • 如果场景之间差异极小,只是 URL 或端口不同,直接用配置参数读取就好,没必要引入模板。

我还碰到过一个反面例子:同事把所有测试都改成@ClassTemplate,结果普通测试方法没了几条,排查了半天,最后发现是supportsTestTemplate返回了true,导致所有@Test方法也被误判。记住:模板是用来解决“同一套逻辑跑多种上下文”的,不是把所有测试都模板化。

5. 一点扩展:把 @ClassTemplate 玩出更多花样

5.1 多个 provider 叠加

我在注解上设计的是providers数组,这意味着可以同时传入多个 provider。比如既有环境 provider,又有权限角色 provider,最后测试方法接收两个参数:环境信息和用户角色。执行时,每个环境 × 每个角色都会组合成一个 invocation。如果环境 provider 生成 3 个 context,角色 provider 生成 2 个 context,那么最终会有 6 次执行。这个组合行为在ClassTemplateExtension中是通过flatMap完成的:

Arrays.stream(annotation.providers()) .map(this::instantiate) .flatMap(provider -> provider.provideTestTemplateInvocationContexts(context));

如果有多个 provider 返回不同类型的参数,测试方法可以用多个参数分别接收。每个 provider 生成的 context 会把自己扩展里的ParameterResolver带上,从而解析对应的参数。这个机制非常强大,但也要求 provider 必须无状态,否则不同 context 之间可能会互相影响。

5.2 与动态测试结合

如果你生成的测试场景数量和内容在运行期才能确定,比如从数据库读取待测试用例列表,可以结合@TestFactory。一个典型玩法是:@ClassTemplate用于“固定环境 + 固定断言”的模板;@TestFactory用于“根据数据动态创建”的场景。两者不是互斥关系,而是互补关系。实际项目中,我会把环境上下文用模板管理,数据驱动部分用@TestFactory或@MethodSource管理,避免所有东西都堆到一个注解里。

5.3 并行执行时的注意点

JUnit 5 可以开启并行执行。模板生成的每个 invocation 通常可以被并行调度,但要注意:如果InvocationContext里有共享的可变状态(比如同一个临时目录、同一个单例配置类),并行执行时就会出现数据竞争。我一开始用模板跑多环境测试时,把环境信息放到了一个静态ThreadLocal里,结果并行时环境老是串,排查了很久才发现是模板并行执行导致的。

正确的做法是:所有环境相关状态都通过ParameterResolver注入到方法参数,方法内部不直接访问静态状态。ParameterResolver每次解析返回的新对象,天然线程安全。如果确实需要共享资源,用@ResourceLock或@Execution(CONCURRENT)来控制。

还有个容易漏掉的点:getDisplayName不要依赖外部可变状态,因为 JUnit 可能在解析报告时多次调用它。你在getDisplayName里打印日志,会发现同一 invocation 会被打印多次,这是正常的。保持它纯函数式,返回字符串就足够。

我在项目里把这个@ClassTemplate封装成内部测试库的公共注解后,接口测试代码的重复量大约减少了四成,而且新增环境时只需要改 provider,不需要碰任何测试方法。不过我要提醒一句:自定义注解是把双刃剑,它隐藏了细节,但也会让新人不清楚背后的机制。所以团队里使用这种封装时,一定要写一份简短的设计文档,标明“它不是 JUnit 官方注解,它等价于什么”。把@ClassTemplate的元注解组合链写清楚,比让每个人都重新读一遍 JUnit 文档高效得多。最后再分享一个小技巧:如果你在调试时实在搞不清扩展有没有生效,可以在supportsTestTemplate里临时抛一个异常,看哪个测试方法被扫描到了。这个办法比用日志快得多,而且基本不会误判。

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

形式语言与自动机理论试题解析:从死记硬背到独立推导

简介&#xff1a;这份文档面向计算机专业学生与考研备考者&#xff0c;针对形式语言与自动机理论课程中的习题与考试难点&#xff0c;提供系统的试题答案解析。内容覆盖集合幂集计算、文法构造、DFA设计、语言识别、形式语言分类、语言推导及泵引理证明等核心知识点&#xff0c…

作者头像 李华
网站建设 2026/10/10 3:32:56

SpringBoot3+MyBatis-Plus快速搭建CRUD工程:版本升级与配置避坑实践

先说明一个现实问题&#xff1a;五分钟只是一个理想化目标&#xff0c;真正卡时间的地方往往不是创建项目&#xff0c;而是版本不对、依赖冲突、扫描路径漏配这三个深坑。我自己是从 SpringBoot2 升级上来的&#xff0c;当时被 javax 换 jakarta 的改动坑了一整个下午&…

作者头像 李华
网站建设 2026/10/10 3:32:50

FFmpeg+Nginx+SSM搭建RTSP转HLS网页直播系统实战

简介&#xff1a;这套以SSM架构整合Nginx与FFmpeg的流媒体方案&#xff0c;专门面向需要将RTSP实时流转为HLS并在网页上播放的开发者&#xff0c;尤其适合刚接触视频接入的小白和希望快速预览实时画面的朋友。压缩包共52个文件&#xff0c;约70.26MB&#xff0c;涵盖png界面图、…

作者头像 李华
网站建设 2026/10/10 3:32:47

栈:从数据结构到全栈开发的思维暗号

栈这东西&#xff0c;是我跟同行聊天时几乎绕不开的话题。数据结构课上说“Stack栈”是最基础的容器之一&#xff0c;算法竞赛里单调栈和表达式求值常年出镜&#xff0c;线上服务崩了抓调用栈&#xff08;backtrace&#xff09;定位问题&#xff0c;聊全栈开发时“技术栈”三个…

作者头像 李华
网站建设 2026/10/10 3:32:47

k3s轻量级Kubernetes实战:低配Ubuntu服务器部署微服务全指南

最近被问得最多的问题&#xff0c;不是“Kubernetes 怎么学”&#xff0c;而是“我就一台 2G 内存的 Ubuntu 服务器&#xff0c;能装 K8s 跑微服务吗”。直接上标准 K8s 集群当然费劲&#xff0c;但换用 k3s 这种轻量级 Kubernetes 发行版&#xff0c;完全可以在低配机器上把一…

作者头像 李华