面试题手册

梳理高频技术问题,帮助你按主题复习和查漏补缺。

服务端阅读 05月27日 23:23

如何实现 RPC 的异步调用?

答案RPC 异步调用的核心思路是:客户端发起调用后不阻塞等待响应,而是通过 Future、回调或响应式流等机制在结果就绪时获取。常见有三种模式:Future/Promise — 调用立即返回 Future 对象,调用方自行决定何时获取结果。Dubbo 原生支持 async=true,通过 RpcContext.getFuture() 拿到返回值。Callback 回调 — 传入回调接口,服务端响应到达时自动触发。适合事件驱动场景,但多层嵌套易产生回调地狱。响应式流(Reactive) — 基于 Reactor/RxJava,以 Mono/Observable 表示异步结果,支持背压和链式组合,适合流式处理。此外,Java 8 的 CompletableFuture 兼具 Future 和回调的优点,支持 thenCombine 组合多个异步结果,是目前最常用的异步编排工具。gRPC 则通过 StreamObserver 实现异步,并原生支持双向流通信。异步调用的优势不阻塞调用线程,单线程可同时处理多个请求,提高并发能力和吞吐量并行调用多个服务,用 CompletableFuture.allOf 或响应式 zip 组合结果,显著降低总延迟避免资源浪费,线程不必在 I/O 等待上空转关键挑战上下文传递:异步线程切换时 TraceId、用户信息等上下文易丢失,需用 TransmittableThreadLocal 显式传递超时控制:必须设超时并取消,否则请求可能无限挂起。future.get(timeout) + future.cancel(true) 是基本模式线程池管理:异步任务不能无限制创建线程,需配置有界线程池并监控队列积压追问Q: 同步调用和异步调用怎么选?调用方需要立即拿到结果才能继续(如下单扣库存),用同步;调用方不依赖结果或可以后续处理(如发通知、写日志),用异步。实际中大部分 RPC 调用用同步,并行调用多个服务时用异步组合。Q: CompletableFuture 和 Reactor 的区别是什么?CompletableFuture 处理单值异步结果,API 简单,适合多数业务场景;Reactor 基于响应式流规范,支持多值序列和背压,适合流式数据处理,但学习成本更高。如果只是组合几个 RPC 调用,CompletableFuture 足够。Q: 异步调用失败怎么重试?不要简单循环重试,应采用指数退避策略(如初始 100ms,每次翻倍,最多 3 次),配合熔断器在连续失败时快速失败。CompletableFuture 可用 exceptionally 或 handle 捕获异常后触发重试逻辑。
服务端阅读 05月27日 23:23

RPC 调用如何保证安全性?认证、加密与授权怎么做?

RPC 调用走的是网络,任何中间人都能截获、篡改甚至伪造请求,所以安全性不是可选项,而是必选项。核心要解决三个问题:你是谁(认证)、数据别被偷看(加密)、你能干什么(授权)。身份认证:确认调用方身份最常见的是 Token 认证,客户端每次请求携带 JWT 或 OAuth2 Token,服务端校验后放行。gRPC 中通常用拦截器统一拦截:public class AuthInterceptor implements ServerInterceptor { @Override public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall( ServerCall<ReqT, RespT> call, Metadata headers, ServerCallHandler<ReqT, RespT> next) { String token = headers.get(Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER)); if (!validateToken(token)) { call.close(Status.UNAUTHENTICATED.withDescription("Invalid token"), headers); return new ServerCall.Listener<ReqT>() {}; } return next.startCall(call, headers); }}内部服务间调用更简单的做法是 API Key,给每个服务分配固定密钥,缺点是一旦泄露很难快速更换。安全性要求高的场景用双向 TLS(mTLS):客户端和服务端互相验证证书,只有持有合法证书的才能通信,即使 Token 被盗也无法伪造连接。数据加密:防止传输中被窃听和篡改传输层加密是第一道防线。所有 RPC 框架都支持 TLS,gRPC 配置示例:NettyChannelBuilder.forAddress(host, port) .sslContext(GrpcSslContexts.forClient() .trustManager(new File("ca.pem")) .build()) .build();仅靠 TLS 不够,敏感字段还应做应用层加密(AES/RSA),这样即便 TLS 被中间人攻破,核心数据仍有保护。完整性校验用 HMAC 或数字签名。发送方对请求体计算签名,接收方验证签名是否一致,能发现任何篡改行为。授权:控制调用方能访问什么认证通过后还要判断有没有权限。最实用的是 RBAC,给服务或用户分配角色,角色绑定权限集合。也可以做到方法级别的细粒度控制,比如只有 admin 角色才能调用 DeleteUser。实际项目中通常用注解 + 拦截器的方式:@RequireRole("admin")public void deleteUser(UserRequest req) { ... }拦截器在调用前统一校验角色,业务代码无需关心权限逻辑。防重放和限流重放攻击是拿合法请求重复发送。解法是请求中加时间戳 + Nonce(一次性随机数),服务端校验时间窗口内的请求是否重复。再加上请求签名,把时间戳、Nonce 和参数一起签名,篡改任何一项都会验签失败。限流用令牌桶算法,防止某个调用方吃满资源:RateLimiter rateLimiter = RateLimiter.create(100);if (!rateLimiter.tryAcquire()) { throw new RateLimitExceededException();}配合 IP 黑白名单,可疑来源直接拦截。安全实践要点最小权限:服务只开通必需的调用权限密钥轮换:定期更换 Token、证书,用配置中心管理而非硬编码审计日志:记录调用方、时间、参数,异常模式及时告警框架差异:gRPC 原生支持 TLS + 拦截器;Dubbo 用 Filter 扩展;Thrift 用 TSSLTransport追问:mTLS 和普通 TLS 的区别?普通 TLS 只验证服务端证书,客户端不提供证书;mTLS 要求双方都提供证书互相验证,安全性更高但证书管理成本也更大,适合零信任网络架构。
服务端阅读 05月27日 23:23

什么是 RPC?RPC 的基本原理和工作流程是什么?

RPC 是什么?RPC(Remote Procedure Call)让一个进程像调用本地函数一样调用远端进程的函数,调用方无需关心底层网络细节。简单说,你写 result = addUser(user) 的时候,addUser 可能跑在另一台机器上,但你代码看起来和本地调用没区别。一次 RPC 调用经历了什么?以调用 userServer.getUser(id) 为例:客户端调用 Stub:调用方发起调用,实际先到客户端存根(Stub),Stub 负责把方法名、参数打包序列化:将参数对象转成二进制流(如 Protobuf、Hessian),这是"编组"过程网络传输:二进制数据通过 TCP/HTTP2 发往服务端,gRPC 默认走 HTTP/2服务端反序列化:Skeleton 接收数据,还原出方法名和参数执行本地方法:服务端找到真实实现类执行,拿到返回值结果序列化回传:返回值同样序列化后走网络回客户端客户端反序列化:Stub 把二进制还原成结果对象,返回给调用方整个过程对业务代码透明,网络通信、序列化、寻址全部由框架处理。核心组件Stub/Skeleton:客户端和服务端的代理层,屏蔽网络细节序列化协议:Protobuf 体积最小性能最好,JSON 通用但空间开销大,Hessian 折中传输协议:TCP 直连延迟最低,HTTP/2 支持多路复用,gRPC 底层用 HTTP/2 + Protobuf注册中心:服务端注册地址,客户端从注册中心发现服务(Consul、Etcd、Nacos)为什么不直接用 HTTP?HTTP 1.1 是文本协议,头部冗余大,每次请求都要建连接;RPC 框架通常基于长连接 + 自定义二进制协议,序列化体积小、连接复用,吞吐量高出一个量级。当然 gRPC 基于 HTTP/2 也能兼顾性能和通用性,具体选型看场景。常见追问RPC 和本地调用有什么区别?本地调用可靠且零延迟;RPC 存在网络抖动、超时、服务不可用等问题,需要重试、熔断、降级等容错机制。序列化怎么选?对性能敏感选 Protobuf,跨语言交互且可读性优先选 JSON,Java 体系内 Dubbo 默认 Hessian 也够用。服务挂了怎么办?注册中心心跳检测摘除异常节点,客户端侧配合熔断器(如 Sentinel)快速失败,避免级联雪崩。
服务端阅读 05月27日 23:23

主流 RPC 框架 gRPC、Dubbo、Thrift 该怎么选?

核心结论选 RPC 框架先定语言生态,再看服务治理需求,最后看性能瓶颈:Java 项目优先 Dubbo,跨语言项目优先 gRPC,遗留系统多协议兼容考虑 Thrift。三个框架的本质区别选型之前,先搞清楚它们在通信模型和序列化机制上的根本差异,这决定了它们的性能天花板和适用边界。通信协议不同:gRPC 基于 HTTP/2,天然支持多路复用、流式通信、头部压缩Dubbo 默认走 TCP 长连接 + 自定义协议,单连接上传输更紧凑Thrift 支持多种传输方式(TSocket、THttp、TFramed),灵活但需要自己选序列化机制不同:gRPC 用 Protobuf,强类型 + 二进制,序列化体积最小,但需要 .proto 文件编译生成代码Dubbo 默认 Hessian2,支持多序列化切换(Kryo、Protobuf 等),Java 原生兼容好Thrift 用自己的 IDL 生成代码,支持 Binary/Compact/JSON 多种格式按场景选型Java 单栈微服务 → DubboDubbo 的核心优势不在性能,而在服务治理体系的完整度:内置注册中心(ZooKeeper/Nacos)、负载均衡策略(随机/轮询/一致性哈希)、熔断降级、服务分组和版本控制。Spring Cloud Alibaba 生态下,Dubbo 与 Nacos、Sentinel 的整合几乎是零成本。如果团队全是 Java 栈,选 Dubbo 省的是运维和治理的成本。注意:Dubbo 3.x 已经支持 Triple 协议(基于 HTTP/2),可以和 gRPC 互通,跨语言能力在补齐。跨语言微服务 → gRPCgRPC 的跨语言是第一等公民:一个 .proto 文件生成 Go、Java、Python、C++ 等十几种语言的客户端和服务端代码,接口定义即契约。HTTP/2 带来的流式通信(客户端流、服务端流、双向流)是 Dubbo 和 Thrift 不原生支持的特性,适合实时数据推送、大文件分片传输等场景。代价是调试不方便——二进制协议无法直接用 curl 打,需要 grpcurl 或 grpc-web 做代理;服务治理需要自己搭(配合 Consul/etcd 做服务发现,配合 Jaeger 做链路追踪)。多协议兼容 / 遗留系统 → ThriftThrift 最大的价值是灵活性:传输层可选 TSocket/THttp/TFramed,协议层可选 TBinary/TCompact/TJSON,序列化格式可以混搭。对于既有 C++ 老系统又要对接 Java 新服务的场景,Thrift 的多协议支持比 gRPC 的单一 Protobuf 更容易兼容。但社区活跃度不如 gRPC 和 Dubbo,遇到问题排查成本高。性能数据参考同一环境下的 QPS 对比(小型请求体,单连接):gRPC (Protobuf):约 35k QPSDubbo (Hessian2):约 28k QPSThrift (TBinary):约 30k QPSFeign (JSON/HTTP1.1):约 12k QPS差距在日常业务中感知不大,瓶颈通常在数据库和 IO 而非框架本身。不要为了 20% 的框架性能差异牺牲 50% 的开发效率。面试追问方向Dubbo 的服务降级和熔断怎么实现?(Sentinel 集成 + mock 机制)gRPC 的流式通信在什么业务场景下不可替代?Protobuf 和 JSON 序列化的性能差距在什么量级?什么场景下 JSON 更合适?Dubbo 3.x 的 Triple 协议解决了什么问题?
服务端阅读 05月27日 23:21

Gradle 是什么?它有哪些核心概念和优势?

Gradle 是一个基于 JVM 的构建自动化工具,融合了 Ant 的灵活性与 Maven 的约定优于配置理念,使用 Groovy 或 Kotlin DSL 编写构建脚本。它的核心概念围绕 Project、Task、Plugin 三层展开。Project每个 Gradle 构建由一个或多个 Project 组成。单模块项目只有一个 Project,多模块项目在 settings.gradle.kts 中声明子项目。每个 Project 对应一个 build.gradle.kts 脚本,是配置的顶级容器。TaskTask 是 Gradle 执行的最小单元,如编译、测试、打包。Task 之间通过 dependsOn 声明依赖关系,Gradle 据此生成有向无环图(DAG),按拓扑排序执行:tasks.register("copyFiles") { dependsOn("processResources") doLast { copy { from("build/resources") into("dist") } }}关键点:Task 分为配置阶段和执行阶段,doFirst / doLast 中的逻辑只在执行阶段运行。PluginPlugin 是功能扩展的核心机制。二进制插件通过 plugin ID 应用:plugins { id("org.springframework.boot") version "3.2.0"}插件内部通过注册 Task、扩展 DSL、配置依赖来改变构建行为。Java Plugin、Android Plugin 都是这个模式。构建生命周期Gradle 构建分三个阶段:初始化:执行 settings.gradle.kts,确定参与构建的 Project配置:执行所有 build.gradle.kts,构建 Task 依赖图执行:按 DAG 顺序执行目标 Task 及其依赖理解生命周期是排查构建问题的关键——配置阶段的代码每次构建都会执行,即使只运行一个 Task。依赖管理Gradle 支持 Maven、Ivy 仓库和本地文件依赖,提供 resolutionStrategy 处理版本冲突,默认取最新版本。implementation 与 api 的区别是常见追问点:implementation 依赖不传递,api 依赖会传递到上层模块。Gradle Wrapper项目自带 gradlew 脚本,自动下载指定版本的 Gradle,保证团队构建环境一致。无需手动安装 Gradle 即可构建项目。核心优势增量构建:只重新执行输入输出有变化的 Task构建缓存:跨项目复用相同 Task 的输出并行执行:多模块并行构建,利用多核 CPU这三项机制叠加,使 Gradle 在大型项目中的构建速度远超 Maven。常见追问Gradle 和 Maven 的核心区别? Maven 基于 XML 声明式配置,逻辑固定在插件中;Gradle 用 DSL 编程式定义构建逻辑,灵活度更高,且增量构建和缓存机制使其速度更快。implementation 和 api 有什么区别? implementation 的依赖对上层模块不可见,修改时不触发上层重新编译;api 的依赖会传递,适合暴露给消费者的公共 API 依赖。
服务端阅读 05月27日 23:20

Gradle 中的 Task 是什么?如何创建和配置 Task?

核心答案Task 是 Gradle 构建的最小执行单元,代表一个原子操作(编译、打包、测试等)。每个 Task 包含名称、类型、动作(Action)、依赖关系和输入/输出。创建 Task 有三种方式,推荐使用 tasks.register() 实现延迟初始化,避免配置阶段不必要的开销。创建 Taskregister vs create vs task 关键字// 推荐:延迟创建,仅在实际需要时实例化tasks.register('myTask') { doLast { println 'Executing myTask' }}// 立即创建,配置阶段就会实例化tasks.create('myTask') { doLast { println 'Executing myTask' }}// DSL 快捷方式,等同 createtask myTask { doLast { println 'Executing myTask' }}register 与 create 的核心区别在于:register 返回 TaskProvider,Task 实例在首次被引用时才创建。大型项目中大量使用 create 会导致配置阶段耗时增加,register 可显著改善构建性能。指定 Task 类型tasks.register('copyFiles', Copy) { from 'src/main/resources' into 'build/resources'}继承内置类型(Copy、Delete、Exec 等)可以复用已有的文件操作逻辑,不必从零实现。配置 Tasktasks.register('myTask') { group = 'Custom Tasks' // Gradle 面板中的分组 description = '自定义任务描述' // gradle tasks 时的说明 dependsOn 'clean', 'compileJava' // 依赖其他任务 mustRunAfter 'test' // 硬排序约束 shouldRunAfter 'build' // 软排序约束(可被 Gradle 忽略) inputs.file('config.properties') // 声明输入 outputs.dir('build/output') // 声明输出 doFirst { println '执行前' } doLast { println '执行后' }}声明 inputs/outputs 的意义在于启用增量构建:Gradle 检测到输入输出未变化时会跳过 Task 执行,这是提升构建速度的关键机制。依赖与排序dependsOn:声明必须先完成的依赖,Gradle 据此构建有向无环图(DAG)决定执行顺序mustRunAfter:硬约束,两个任务同时被调度时保证先后顺序,但不引入依赖shouldRunAfter:软约束,Gradle 在并行执行优化时可以忽略finalizedBy:无论当前任务成功或失败,都会执行收尾任务(如资源清理)tasks.register('taskA') { finalizedBy 'cleanup' doLast { println 'Task A' }}tasks.register('cleanup') { doLast { println 'Cleanup' }}执行控制// 条件执行tasks.register('conditionalTask') { onlyIf { project.hasProperty('runConditional') } doLast { println '条件满足才执行' }}// 禁用任务tasks.register('disabledTask') { enabled = false}// 抛出 StopExecutionException 跳过当前动作doFirst { if (!project.hasProperty('force')) { throw new StopExecutionException() }}追问register 创建的 Task 如何在其他地方引用? 使用 tasks.named('myTask') 获取 TaskProvider,再通过 .configure { } 追加配置。不要对 register 的 Task 直接用 tasks.myTask,那会触发立即实例化,失去延迟初始化的优势。mustRunAfter 和 dependsOn 有什么区别? dependsOn 会建立真正的依赖关系,被依赖的任务一定会执行;mustRunAfter 只是排序约束,仅当两个任务都在本次构建的执行计划中时才生效,不会把对方拉入执行图。为什么不要在 Task 体中直接写逻辑而要用 doFirst/doLast? Task 闭包中的代码在配置阶段执行,而 doFirst/doLast 中的代码在执行阶段执行。配置阶段写逻辑会导致每次运行任何 Task 都会执行这些代码,产生副作用和性能浪费。
服务端阅读 05月27日 23:19

Gradle 的增量构建是如何工作的?

核心机制Gradle 增量构建通过跟踪任务的输入(inputs)和输出(outputs)的哈希值来决定是否跳过执行。当输入未变化且输出存在时,任务标记为 UP-TO-DATE 并跳过;只有输入变化或输出缺失时才重新执行。关键点:首次构建记录所有输入输出的快照(哈希值)后续构建比较当前快照与历史快照增量任务可进一步获取文件级变更细节(新增/修改/删除),只处理变化部分输入输出声明没有正确声明输入输出的任务无法参与增量构建:abstract class ProcessTask extends DefaultTask { @InputDirectory abstract DirectoryProperty getInputDir() @OutputDirectory abstract DirectoryProperty getOutputDir() @TaskAction void execute(InputChanges changes) { if (changes.incremental) { changes.getFileChanges(inputDir).each { change -> // change.changeType: ADDED / MODIFIED / REMOVED // 只处理变化的文件 } } else { // 全量处理 } }}运行时 API 方式(无需自定义 Task 类):tasks.register('processFiles') { inputs.dir('src/main/resources') outputs.dir('build/processed') doLast { /* ... */ }}构建性能优化手段1. 启用增量编译与构建缓存# gradle.propertiesorg.gradle.caching=trueorg.gradle.parallel=true2. 远程构建缓存(CI 环境)buildCache { remote(HttpBuildCache) { url = 'https://cache.example.com/cache/' push = System.getenv('CI') != null }}3. 配置缓存(Configuration Cache):缓存项目配置阶段结果,避免每次构建重新解析 build.gradle,Gradle 8.x 已趋于稳定。4. 避免配置阶段耗时操作:将逻辑移入 doLast / doFirst,而非任务配置闭包中。常见增量构建失效的原因输入输出未声明或声明不全 → 任务每次都执行输出被外部进程修改 → 缓存校验失败任务存在非确定性输出(如含时间戳) → 同样输入产生不同输出outputs.upToDateWhen 中包含非确定性逻辑调试命令:./gradlew build --info # 查看 UP-TO-DATE 原因./gradlew build --rerun-tasks # 强制全量执行./gradlew build --scan # 生成构建分析报告追问Q: 增量构建和构建缓存有什么区别?增量构建在同一项目内比较输入输出快照跳过任务;构建缓存可以跨项目、跨机器复用任务输出,存储在本地或远程。两者互补:增量构建避免本地重复执行,构建缓存避免跨环境重复执行。Q: 输入归一化(Input Normalization)是什么?Gradle 比较输入时会对 classpath 做归一化处理——忽略 JAR 中的时间戳和顺序差异,只比较实际内容。可通过 @Classpath 和 @CompileClasspath 注解控制归一化策略,减少不必要的任务重新执行。Q: 配置缓存对增量构建有什么影响?配置缓存缓存的是配置阶段的结果(任务图、属性值),增量构建缓存的是执行阶段的输入输出快照。两者作用于不同阶段,同时启用效果叠加。但配置缓存要求构建脚本无副作用,部分插件可能不兼容。
服务端阅读 05月27日 23:18

Cypress 的 beforeEach、before、afterEach 和 after 钩子有什么区别?

核心区别四个钩子的根本区别在于执行频率和作用域:beforeEach / afterEach:每个 it 用例前后各执行一次,作用域为当前 describe 块内所有用例before / after:整个 describe 块开始前和结束后各执行一次,作用域为整个测试套件| 钩子 | 执行时机 | 执行次数 | 典型用途 ||------|---------|---------|--------|| beforeEach | 每个 it 之前 | N 次(N = 用例数) | 重置状态、登录、访问页面 || afterEach | 每个 it 之后 | N 次 | 清除 cookie、会话、快照 || before | 所有 it 之前 | 1 次 | 种子数据、全局配置 || after | 所有 it 之后 | 1 次 | 数据库清理、资源释放 |执行顺序嵌套 describe 时,钩子的执行顺序遵循"从外到内"原则:describe("外层", () => { before(() => cy.log("outer before")); // 1 beforeEach(() => cy.log("outer beforeEach")); // 3, 7 describe("内层", () => { before(() => cy.log("inner before")); // 2 beforeEach(() => cy.log("inner beforeEach")); // 4, 8 it("测试A", () => cy.log("test A")); // 5 it("测试B", () => cy.log("test B")); // 9 afterEach(() => cy.log("inner afterEach")); // 6, 10 }); afterEach(() => cy.log("outer afterEach")); // 最后执行});输出:outer before → inner before → (outer beforeEach → inner beforeEach → 测试A → inner afterEach → outer afterEach) × 2轮选择依据需要每个用例都从干净状态开始 → beforeEach,不要用 before用例之间允许共享状态(如只读数据) → before 一次性初始化afterEach 适合清理当前用例产生的副作用(如 localStorage)after 适合清理整个套件的资源(如测试数据库)常见错误:在 before 中登录,然后所有用例共享登录态。一旦某个用例意外注销,后续用例全部失败。正确做法是用 beforeEach 登录,保证每个用例的独立性。追问Q: beforeEach 中 cy.visit() 和 cy.request() 有什么区别?cy.visit() 会加载完整页面并等待页面事件,较慢;cy.request() 只发 HTTP 请求不渲染,适合用 API 预设数据来加速测试。Q: 钩子中的断言失败会影响用例执行吗?会。beforeEach 中断言失败,该用例跳过;afterEach 中失败会标记用例为失败,但不影响下一个用例的执行。Q: 为什么 Cypress 官方更推荐 beforeEach 而非 before?因为 Cypress 的核心原则是测试隔离。before 共享状态容易导致用例间耦合,一个用例的副作用会污染后续用例。beforeEach 保证每个用例从相同初始状态运行,测试更稳定。
服务端阅读 05月27日 23:18

Gradle 插件有哪些类型?如何创建和使用自定义插件?

核心回答Gradle 插件分两类:脚本插件和二进制插件。创建自定义插件有三种方式:在 build script 中直接编写、在 buildSrc 模块中组织、以及创建独立项目发布。插件类型脚本插件是额外的 .gradle 文件,通过 apply from 引入:apply from: 'gradle/checkstyle.gradle'只能在本项目复用,不推荐用于复杂逻辑。二进制插件实现了 Plugin<Project> 接口,打包为 JAR 后可跨项目共享:plugins { id 'java' // 核心插件,无需版本 id 'org.springframework.boot' version '3.0.0' // 社区插件,需指定版本}推荐使用 plugins DSL 而非旧式 apply plugin:。创建自定义插件的三种方式1. Build Script 内联直接在 build.gradle 中编写,适合一次性逻辑:class GreetingPlugin implements Plugin<Project> { void apply(Project project) { project.tasks.register('greet') { doLast { println 'Hello from plugin' } } }}apply plugin: GreetingPlugin无法在其他项目复用。2. buildSrc 模块在项目根目录创建 buildSrc/src/main/groovy/ 目录,插件类放于此。整个项目可见,可编写测试,但不能跨项目共享。3. 独立项目(推荐)创建独立的 Gradle 插件项目,发布到 Maven 仓库供任意项目使用:// 插件项目 build.gradleplugins { id 'java-gradle-plugin' id 'maven-publish'}gradlePlugin { plugins { customPlugin { id = 'com.example.custom-plugin' implementationClass = 'com.example.CustomPlugin' } }}插件实现类:package com.exampleclass CustomPlugin implements Plugin<Project> { void apply(Project project) { def ext = project.extensions.create('customConfig', CustomExtension) project.tasks.register('customTask') { group = 'Custom' doLast { println "Config: ${ext.name}" } } }}class CustomExtension { String name = 'default'}注册插件 ID(META-INF/gradle-plugins/com.example.custom-plugin.properties):implementation-class=com.example.CustomPlugin使用方引入:plugins { id 'com.example.custom-plugin'}customConfig { name = 'MyApp'}追问plugins DSL 与 apply 有什么区别? plugins DSL 在配置阶段解析,支持类型安全和版本管理;apply 在脚本执行时才应用,无法提前校验。自定义插件如何测试? 使用 Gradle TestKit 编写功能测试,在临时项目中运行构建验证行为。为什么推荐用 Java/Kotlin 而非 Groovy 实现插件? 静态类型减少二进制不兼容风险,Kotlin DSL 还能获得 IDE 补全支持。
服务端阅读 05月27日 23:18

Cypress 自定义命令怎么用?

Cypress 自定义命令(Custom Commands)是通过 Cypress.Commands.add() 在 cypress/support/commands.js 中注册的可复用测试函数,调用方式与 cy.visit() 等内置命令一致,核心目的是消除跨用例的重复代码。创建自定义命令在 cypress/support/commands.js 中定义:Cypress.Commands.add('login', (email, password) => { cy.visit('/login'); cy.get('[data-testid="email"]').type(email); cy.get('[data-testid="password"]').type(password); cy.get('[data-testid="submit"]').click();});测试中直接调用 cy.login('user@example.com', 'password'),无需每次重复编写登录步骤。三种命令类型Cypress.Commands.add() 第二个参数可选 prevSubject,决定命令的调用方式:父命令(默认):独立调用,如 cy.login()子命令:必须链式接在前一个命令后,对获取到的元素操作Cypress.Commands.add('drag', { prevSubject: 'element' }, (subject, options) => { cy.wrap(subject) .trigger('mousedown', { button: 0 }) .trigger('mousemove', { clientX: options.x, clientY: options.y }) .trigger('mouseup');});// 使用:cy.get('.box').drag({ x: 100, y: 200 })双重命令:{ prevSubject: 'optional' },既可独立调用也可链式调用覆盖已有命令用 Cypress.Commands.overwrite() 改写内置命令行为:Cypress.Commands.overwrite('visit', (originalFn, url, options) => { return originalFn(url, { ...options, headers: { Authorization: 'Bearer ...' } });});TypeScript 类型支持在 cypress/support/index.d.ts 中声明类型,避免 TS 报错:declare namespace Cypress { interface Chainable { login(email: string, password: string): Chainable<void>; drag(options: { x: number; y: number }): Chainable<void>; }}常见追问自定义命令和普通函数的区别? 自定义命令运行在 Cypress 命令队列中,支持重试和超时机制;普通 JS 函数是同步执行,不具备这些能力。什么时候不该用自定义命令? 仅在单个 spec 文件中复用的逻辑,写成普通函数更轻量;自定义命令适合跨文件、跨模块共享的场景。命令命名冲突怎么办? 自定义命令会覆盖同名内置命令,建议用业务前缀(如 cy.authLogin)避免冲突。
服务端阅读 05月27日 23:17

Cypress 如何处理跨域问题?

答案Cypress 处理跨域问题有两种主要方式:禁用 Chrome Web 安全:在 cypress.config.js 中设置 chromeWebSecurity: false,允许跨域导航和访问跨域 iframe。这是最简单的方式,但仅适用于 Chromium 内核浏览器。使用 cy.origin() 命令:从 Cypress 9.6.0 起,可通过 cy.origin() 在不同域上执行操作,Cypress 会为新源创建 iframe 并通过 postMessage 通信。这是官方推荐的跨域测试方案。// 方式一:禁用 Chrome Web 安全// cypress.config.jsmodule.exports = { e2e: { chromeWebSecurity: false }};// 方式二:cy.origin()cy.origin("https://example.com", () => { cy.visit("/login"); cy.get("input[name=email]").type("test@example.com"); cy.get("button[type=submit]").click();});追问:chromeWebSecurity: false 有什么局限?仅对 Chromium 内核浏览器有效,Firefox 等浏览器不支持此选项不会绕过 cy.visit() 的超域限制,即不能在同一个测试中 cy.visit() 不同超域的 URL生产环境不存在此开关,测试可能掩盖真实的跨域问题追问:cy.origin() 的注意事项?回调函数内无法直接引用外部作用域的变量,需通过第二个参数传入:const username = "test@example.com";cy.origin("https://example.com", { args: { username } }, ({ username }) => { cy.get("input[name=email]").type(username);});回调内不能使用 cy.session()、自定义命令等部分 API每次调用 cy.origin() 会创建新的 iframe 上下文,有性能开销追问:还有其他方案吗?可通过服务器端反向代理(如 nginx)将不同域的 API 映射到同源路径下,从根本上消除跨域。这种方式不依赖 Cypress 配置,但需要额外的基础设施支持。# nginx 反向代理示例location /external-api/ { proxy_pass https://api.example.com/;}
服务端阅读 05月27日 23:17

Gradle 如何实现多项目构建?项目间依赖怎么配置?

Gradle 通过 settings.gradle 声明子项目,通过 project() 依赖建立项目间引用,配合 allprojects/subprojects 共享配置,实现多项目构建。核心机制多项目构建由一个根项目和若干子项目组成。settings.gradle 定义了哪些子项目参与构建,Gradle 据此建立项目间的依赖图并决定构建顺序。// settings.gradlerootProject.name = 'my-project'include 'app', 'library', 'common'// 嵌套项目include ':data:repository'project(':data:repository').projectDir = new File(rootDir, 'modules/data/repository')项目间依赖配置子项目之间通过 project() 函数声明依赖,Gradle 自动保证被依赖项目先构建:// app/build.gradledependencies { implementation project(':library') implementation project(':common') // 指定配置 implementation project(path: ':library', configuration: 'runtimeClasspath')}// library/build.gradledependencies { api project(':common') // api 会传递依赖给上层}implementation 与 api 的区别在多项目中尤为关键:api 暴露依赖给消费方,implementation 则不传递。共享配置的三种方式1. allprojects / subprojects 注入// 根 build.gradleallprojects { group = 'com.example' version = '1.0.0' repositories { mavenCentral() }}subprojects { apply plugin: 'java' java { sourceCompatibility = JavaVersion.VERSION_17 }}2. 约定插件(Convention Plugin)在 buildSrc 或独立插件项目中定义,比 subprojects 更灵活、可测试:// buildSrc/src/main/groovy/my-java-convention.gradleplugins { id 'java-library'}java { sourceCompatibility = JavaVersion.VERSION_17}// 子项目使用// library/build.gradleplugins { id 'my-java-convention' }3. 版本目录(Version Catalog)# gradle/libs.versions.toml[versions]spring-boot = "3.0.0"[libraries]spring-boot-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "spring-boot" }// 子项目中使用dependencies { implementation libs.spring.boot.web}构建执行./gradlew projects # 查看项目结构./gradlew :app:build # 构建指定项目(自动构建其依赖项)./gradlew build --parallel # 并行构建并行构建需要在 gradle.properties 中启用:org.gradle.parallel=trueorg.gradle.caching=trueorg.gradle.configureondemand=true循环依赖问题项目 A 依赖 B,B 又依赖 A,Gradle 构建时会报错。解决方式是抽取公共部分到第三个模块,或者用 compileOnly/testImplementation 打断传递链。可通过 ./gradlew :app:dependencies --configuration runtimeClasspath 排查依赖链路。追问allprojects 和 subprojects 有什么区别?——allprojects 包含根项目,subprojects 只作用于子项目。约定插件相比 subprojects 闭包有什么优势?——可复用、可测试、支持按需引入,避免全局污染。如何让子项目独立构建而不触发其他项目?——configureondemand 配合按需配置,Gradle 6+ 默认部分启用。
服务端阅读 05月27日 23:16

Gradle 依赖管理有哪些配置类型?如何解决冲突?

Gradle 通过 configurations(依赖配置)和 repositories(仓库)两大机制管理依赖。configurations 定义依赖的可见范围和生命周期,repositories 定义依赖的获取来源。核心依赖配置类型| 配置 | 编译时 | 运行时 | 传递给消费者 | 典型场景 ||------|--------|--------|--------------|----------|| implementation | 可见 | 可见 | 不可见 | 默认选择,大多数依赖用这个 || api | 可见 | 可见 | 可见 | 库模块需要暴露给下游的 API 依赖 || compileOnly | 可见 | 不可见 | 不可见 | Lombok 等仅编译期需要的依赖 || runtimeOnly | 不可见 | 可见 | 不可见 | JDBC 驱动等仅运行时需要的依赖 || annotationProcessor | 可见 | 不可见 | 不可见 | 注解处理器 |dependencies { implementation 'org.apache.commons:commons-lang3:3.12.0' api 'org.apache.commons:commons-math3:3.6.1' // 消费者也能访问 compileOnly 'org.projectlombok:lombok:1.18.24' // 编译后丢弃 runtimeOnly 'mysql:mysql-connector-java:8.0.28' // 运行时才加载 annotationProcessor 'org.projectlombok:lombok:1.18.24'}implementation 与 api 的关键区别: implementation 的依赖不会出现在消费者的编译类路径中,改动后只重新编译当前模块;api 会传递,改动后消费者也要重新编译。优先用 implementation 可以显著缩短构建时间。版本管理推荐使用版本目录(Version Catalog),在 gradle/libs.versions.toml 中集中声明:[versions]spring-boot = "3.0.0"[libraries]spring-boot-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "spring-boot" }dependencies { implementation libs.spring.boot.web}多模块项目也可用 BOM 统一版本:dependencies { implementation platform('org.springframework.boot:spring-boot-dependencies:3.0.0') implementation 'org.springframework.boot:spring-boot-starter-web' // 版本由 BOM 控制}依赖冲突与排除Gradle 默认取最高版本。当冲突不可自动解决时,可强制版本或排除:// 排除特定传递依赖implementation('org.springframework.boot:spring-boot-starter-web:3.0.0') { exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat'}// 强制版本configurations.all { resolutionStrategy { force 'org.apache.commons:commons-lang3:3.12.0' }}查看依赖树排查冲突:./gradlew dependencies --configuration implementation追问Q: 为什么 implementation 改了不会触发下游模块重编译?A: implementation 的依赖被隔离在当前模块的编译类路径内,消费者编译时看不到这些依赖,因此改动不影响消费者的编译产物。这是 Gradle 3.4 引入 implementation 替代 compile 的核心动机。Q: 什么时候必须用 api 而不能用 implementation?A: 当模块的公开 API 方法签名中用到了某个依赖的类型时(如返回值或参数类型来自该依赖),必须用 api,否则消费者编译时会找不到类。Q: 动态版本(如 3.+)有什么问题?A: 构建不可复现——同一份代码不同时间构建可能拉到不同版本,导致线上行为不一致。生产环境必须锁定版本。
服务端阅读 05月27日 23:16

Gradle 的生命周期包括哪些阶段?

三个阶段:初始化 → 配置 → 执行Gradle 构建生命周期分为 初始化(Initialization)、配置(Configuration)、执行(Execution) 三个阶段,依次完成项目确定、任务图构建和任务执行。初始化阶段读取 settings.gradle,确定参与构建的项目并为每个项目创建 Project 实例:// settings.gradlerootProject.name = 'my-app'include 'app', 'core', 'utils'钩子:gradle.projectsLoaded配置阶段执行所有项目的 build.gradle,配置插件、依赖和任务,建立任务依赖图。无论最终执行哪个任务,所有项目的构建脚本都会被执行——这是性能问题的常见来源。// 延迟注册任务,避免配置阶段不必要的开销tasks.register('myTask') { onlyIf { project.hasProperty('enableMyTask') }}钩子:gradle.beforeProject / gradle.afterProject执行阶段按依赖图顺序执行目标任务及其依赖。支持增量构建(仅处理变更文件)和并行执行(--parallel)。钩子:gradle.taskGraph.whenReady / gradle.taskGraph.beforeTask / gradle.taskGraph.afterTask常见追问Q: 为什么配置阶段会成为性能瓶颈?每个项目的 build.gradle 都会执行,即使只跑一个任务。大量 I/O 或复杂逻辑放在配置阶段会拖慢整个构建。应使用 tasks.register() 延迟创建、启用 --configuration-cache 缓存配置结果。Q: afterEvaluate 的作用?在项目配置完成后回调,常用于在插件配置完成后再添加依赖或修改属性,确保顺序正确。Q: 增量构建的原理?Gradle 为每个 Task 维护输入输出的哈希,若输入输出均未变化则跳过执行(标记 UP-TO-DATE),而非真正重新运行。
前端阅读 05月27日 23:14

FFmpeg 怎么从视频中提取音频?

用 -vn 丢弃视频流,配合编码参数输出目标格式即可。核心命令:# 提取音频为 MP3(重新编码)ffmpeg -i input.mp4 -vn -q:a 0 -map a output.mp3# 直接复制音频流(不重新编码,速度最快,质量无损)ffmpeg -i input.mp4 -vn -acodec copy output.aac两条命令的关键区别:-acodec copy 原样拷贝音频流,不重新编码,适合只需从容器中剥离音频的场景;-q:a 0 会重新编码为 VBR 最高质量的 MP3,适合需要格式转换的场景。常用参数说明-vn:禁用视频流,只保留音频-map a:映射所有音频流,防止视频流被误包含-q:a N:VBR 质量等级,0 最高,2 中等-b:a 192k:CBR 比特率,128/192/320 为常用值-ar 48000:采样率-ac 2:声道数(1 单声道,2 立体声)多音频流处理MKV、WebM 等容器可能包含多条音轨(不同语言),需用 -map 指定:# 提取第一条音频流ffmpeg -i input.mkv -map 0:a:0 -c:a libmp3lame -q:a 2 output.mp3# 提取第二条音频流ffmpeg -i input.mkv -map 0:a:1 -c:a libmp3lame -q:a 2 output.mp3先用 ffmpeg -i input.mkv 查看流索引,Stream #0:1 中的 1 即为音频流编号。输出不同格式# WAV(无损,适合音频编辑)ffmpeg -i input.mp4 -vn -acodec pcm_s16le -ar 48000 -ac 2 audio.wav# AAC(适合网络传输和流媒体)ffmpeg -i input.mp4 -vn -c:a aac -b:a 128k audio.aac提取指定时间段ffmpeg -i input.mp4 -vn -ss 00:01:30 -t 00:00:20 -acodec copy output.aac-ss 起始时间,-t 持续时长。批量提取for file in *.mp4; do ffmpeg -i "$file" -vn -acodec copy "${file%.mp4}.aac"done常见问题提取后音频无声:用 ffmpeg -i 确认源文件含音频流;尝试加 -f mp3 显式指定格式;某些容器需指定编码器如 -c:a libmp3lame。文件过大:VBR 用 -q:a 2 替代 -q:a 0;CBR 用 -b:a 128k 控制比特率。提取后无法播放:输出格式和编码器要匹配,MP3 输出用 -c:a libmp3lame,AAC 输出用 -c:a aac,不要给 WAV 文件指定 AAC 编码器。
服务端阅读 05月27日 23:12

如何对 JWT 进行测试

JWT 测试需要从三个层面入手:单元测试验证签名和解析逻辑,集成测试验证完整认证流程,安全测试验证防护常见攻击。下面逐层展开。单元测试:Token 生成与验证单元测试关注 JWT 库本身的行为是否正确。核心测试点有三个:生成出的 token 结构是否合法、payload 是否正确写入、过期时间是否生效。Token 生成测试const jwt = require('jsonwebtoken');const { expect } = require('chai');describe('JWT Token Generation', () => { const SECRET = 'test-secret'; const payload = { userId: '123', role: 'user' }; it('should generate a valid three-part token', () => { const token = jwt.sign(payload, SECRET); expect(token.split('.')).to.have.lengthOf(3); }); it('should encode payload correctly', () => { const decoded = jwt.decode(jwt.sign(payload, SECRET)); expect(decoded.userId).to.equal('123'); expect(decoded.role).to.equal('user'); }); it('should set expiration', () => { const decoded = jwt.decode(jwt.sign(payload, SECRET, { expiresIn: '1h' })); expect(decoded.exp).to.be.greaterThan(Math.floor(Date.now() / 1000)); });});Token 验证测试验证环节容易出问题,重点测三类异常:签名不匹配、token 过期、算法不符。describe('JWT Token Verification', () => { const SECRET = 'test-secret'; it('should verify a valid token', () => { const token = jwt.sign({ userId: '123' }, SECRET); const decoded = jwt.verify(token, SECRET); expect(decoded.userId).to.equal('123'); }); it('should reject expired token', () => { const expired = jwt.sign({ userId: '123' }, SECRET, { expiresIn: '-1s' }); expect(() => jwt.verify(expired, SECRET)).to.throw('jwt expired'); }); it('should reject wrong secret', () => { const token = jwt.sign({ userId: '123' }, SECRET); expect(() => jwt.verify(token, 'wrong')).to.throw('invalid signature'); }); it('should reject wrong algorithm', () => { const token = jwt.sign({ userId: '123' }, SECRET, { algorithm: 'HS256' }); expect(() => jwt.verify(token, SECRET, { algorithms: ['RS256'] })) .to.throw('invalid algorithm'); });});注意 algorithms 白名单是必须的——后面安全测试会解释原因。集成测试:认证流程单元测试只覆盖了库函数,集成测试验证 token 在 HTTP 请求中的真实表现。关键场景:登录拿 token → 带 token 访问受保护资源 → token 无效/过期时被拒绝。const request = require('supertest');const app = require('../app');describe('Auth Flow', () => { let token; it('should return token on login', async () => { const res = await request(app) .post('/auth/login') .send({ username: 'testuser', password: 'password123' }); expect(res.status).to.equal(200); expect(res.body.token).to.exist; token = res.body.token; }); it('should access protected route with valid token', async () => { const res = await request(app) .get('/api/protected') .set('Authorization', `Bearer ${token}`); expect(res.status).to.equal(200); }); it('should reject request without token', async () => { const res = await request(app) .get('/api/protected'); expect(res.status).to.equal(401); }); it('should reject expired token', async () => { const expired = jwt.sign({ userId: '123' }, process.env.JWT_SECRET, { expiresIn: '-1s' }); const res = await request(app) .get('/api/protected') .set('Authorization', `Bearer ${expired}`); expect(res.status).to.equal(401); });});如果系统使用 refresh token,还需要单独测试 refresh 端点:用合法 refresh token 换新 access token,用无效 refresh token 被拒绝。安全测试:防护常见攻击JWT 最常见的安全问题有两个:算法混淆攻击和 token 篡改。算法混淆攻击攻击者把 header 中的 alg 改为 none,绕过签名验证。防御方式是在 verify 时显式指定 algorithms 白名单:describe('Security - Algorithm Confusion', () => { it('should reject none algorithm', () => { const noneToken = jwt.sign({ userId: 'admin' }, '', { algorithm: 'none' }); expect(() => jwt.verify(noneToken, SECRET, { algorithms: ['HS256'] })) .to.throw(); });});这就是为什么前面单元测试中强调 algorithms 白名单——不写白名单的 verify 调用就是安全漏洞。Token 篡改攻击者修改 payload 后重新拼接 token,但无法生成合法签名,所以 verify 必须能检测到签名不匹配:describe('Security - Token Tampering', () => { it('should reject tampered payload', () => { const token = jwt.sign({ userId: '123' }, SECRET); const parts = token.split('.'); parts[1] = Buffer.from(JSON.stringify({ userId: 'admin' })).toString('base64'); const tampered = parts.join('.'); expect(() => jwt.verify(tampered, SECRET)).to.throw('invalid signature'); });});边界情况补充除了上述三层,实际项目中还需关注:时钟偏移:分布式系统中各节点时钟不同步,verify 时设置 clockTolerance(如 30 秒)避免误判过期。密钥轮换:旧密钥签发的 token 在新密钥上线后仍需能验证,需支持多密钥验证逻辑。并发刷新:多个请求同时触发 token 刷新,需要幂等处理避免竞态条件。追问JWT 的 alg: none 攻击原理是什么?如何防御?RS256 和 HS256 在验证时有什么安全差异?(提示:RS256 公钥验证 vs HS256 共享密钥,HS256 存在密钥泄露风险)如何在不停机的情况下轮换 JWT 签名密钥?
服务端阅读 05月27日 23:11

JWT 的签名算法有哪些,如何选择?

签名算法分三类JWT 签名算法分为三类:HMAC 对称签名(HS256/HS384/HS512)、RSA 非对称签名(RS256/RS384/RS512/PS256/PS384/PS512)、ECDSA 椭圆曲线签名(ES256/ES384/ES512)。如何选择默认选 RS256。它是非对称算法,私钥签名、公钥验证,公钥可安全分发给任何需要验证 token 的服务,适合分布式架构和微服务场景。如果只是单体应用、内部系统通信,HS256 足够且性能更好。如果对签名体积或性能敏感(移动端、IoT),选 ES256——安全性与 RS256 相当,签名体积小约 50%。| 场景 | 推荐算法 | 原因 ||------|----------|------|| 单体应用 / 内部通信 | HS256 | 对称密钥够用,性能最佳 || 分布式 / 微服务 / 公开 API | RS256 | 公钥可分发,私钥不泄露 || 移动端 / IoT / 高性能 | ES256 | 签名小、速度快、安全性高 || 金融 / 医疗等高安全 | PS256 | RSA-PSS 提供更强安全证明 |代码示例// HS256 — 对称密钥const token = jwt.sign(payload, 'secret-key', { algorithm: 'HS256' });const decoded = jwt.verify(token, 'secret-key', { algorithms: ['HS256'] });// RS256 — 非对称密钥const privateKey = fs.readFileSync('private.key');const publicKey = fs.readFileSync('public.key');const token = jwt.sign(payload, privateKey, { algorithm: 'RS256' });const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'] });// ES256 — 椭圆曲线const ecPrivate = fs.readFileSync('ec-private.key');const ecPublic = fs.readFileSync('ec-public.key');const token = jwt.sign(payload, ecPrivate, { algorithm: 'ES256' });const decoded = jwt.verify(token, ecPublic, { algorithms: ['ES256'] });密钥生成# RSA 密钥对openssl genrsa -out private.key 2048openssl rsa -in private.key -pubout -out public.key# ECDSA 密钥对openssl ecparam -name prime256v1 -genkey -noout -out ec-private.keyopenssl ec -in ec-private.key -pubout -out ec-public.key安全要点禁用 none 算法:永远不要接受 alg: none 的 token,攻击者可伪造任意内容。验证时显式指定算法:防止算法混淆攻击(攻击者将 RS256 改为 HS256,用公钥当对称密钥验证)。密钥强度:RSA 至少 2048 位,HMAC 至少 256 位,ECDSA 至少 P-256。定期轮换密钥:每 6-12 个月轮换,支持多密钥验证实现平滑过渡。追问:HS256 和 RS256 的核心区别是什么?HS256 是对称算法,签名和验证用同一密钥,任何能验证 token 的人也能伪造它;RS256 是非对称算法,私钥签名、公钥验证,验证方无法伪造。因此在微服务架构中,RS256 是唯一合理选择——你不会想把签名密钥交给每一个验证服务。追问:什么是算法混淆攻击?攻击者拿到 RS256 的公钥后,将 token header 的 alg 改为 HS256,再用该公钥作为 HMAC 密钥签名。如果服务端验证时没有显式指定算法,就会用公钥当 HMAC 密钥去验证,结果验证通过——攻击者成功伪造 token。防御方法:验证时必须通过 algorithms 参数显式指定允许的算法。
服务端阅读 05月27日 23:10

JWT 在微服务架构中如何使用

核心思路微服务中使用 JWT 的关键是:由认证服务签发令牌,各服务通过公钥本地验证,避免集中式校验带来的性能瓶颈和单点故障。具体架构:客户端登录 → 认证服务签发 JWT → 客户端携带 JWT 请求 API Gateway → Gateway 验证后转发 → 下游服务通过 JWK 端点获取公钥自行验证,无需回调认证服务。为什么用非对称加密(RS256)而不是对称加密(HS256)HS256 要求所有验证方共享同一个密钥,微服务场景下密钥分发和泄露风险高。RS256 签名用私钥(仅认证服务持有),验证用公钥(可公开分发),任何服务只需拿到公钥就能验证,无需共享敏感信息。认证服务签发 JWTconst jwt = require('jsonwebtoken');const { generateKeyPairSync } = require('crypto');const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }});// 签发令牌app.post('/auth/login', (req, res) => { const user = validateUser(req.body.username, req.body.password); const token = jwt.sign( { userId: user.id, role: user.role, permissions: user.permissions }, privateKey, { algorithm: 'RS256', keyid: 'key-20260527', expiresIn: '15m', issuer: 'auth-service', audience: 'my-platform' } ); const refreshToken = jwt.sign( { userId: user.id, type: 'refresh' }, privateKey, { algorithm: 'RS256', expiresIn: '7d' } ); res.json({ token, refreshToken });});// JWK Set 端点,供其他服务获取公钥app.get('/.well-known/jwks.json', (req, res) => { const jwk = jose.JWK.asKey(publicKey, { kid: 'key-20260527' }); res.json({ keys: [jwk.toJWK()] });});访问令牌有效期设短(15 分钟),配合 Refresh Token 续期,降低令牌泄露风险。API Gateway 验证与转发app.use(async (req, res, next) => { const token = req.headers['authorization']?.replace('Bearer ', ''); if (!token) return res.status(401).json({ error: 'No token' }); try { const publicKey = await getPublicKey(); // 从 JWK 端点获取,带缓存 req.user = jwt.verify(token, publicKey, { algorithms: ['RS256'], issuer: 'auth-service', audience: 'my-platform' }); // 将用户信息注入请求头,传递给下游服务 req.headers['x-user-id'] = req.user.userId; req.headers['x-user-role'] = req.user.role; next(); } catch (e) { res.status(401).json({ error: 'Invalid token' }); }});下游服务验证与 Token 传播下游服务有两种选择:Gateway 已验证,下游信任 Gateway:只检查 x-user-id 等请求头,适用于内部可信网络下游自行验证:从 JWK 端点获取公钥,本地校验,适用于跨团队或零信任场景服务间调用时,用拦截器自动传播 Token:const axios = require('axios');const serviceClient = axios.create({ timeout: 5000 });serviceClient.interceptors.request.use(config => { const token = getCurrentToken(); // 从请求上下文获取 if (token) config.headers['Authorization'] = `Bearer ${token}`; return config;});密钥轮换线上必须支持密钥轮换。流程:生成新密钥对 → 新令牌用新 kid 签发 → 旧令牌仍可用旧公钥验证 → 旧密钥过期后从 JWK Set 移除。验证方根据 JWT Header 中的 kid 字段匹配对应公钥,过渡期内 JWK Set 同时包含新旧公钥。Token 撤销的处理JWT 本身无状态,签出后无法直接撤销。常用方案:短有效期 + Refresh Token:最主流,泄露窗口小黑名单:Redis 存已撤销的 Token ID,每次验证时查询,牺牲部分无状态优势Gateway 层拦截:只对敏感操作生效,如修改密码后立即失效旧 Token追问Q: JWT 和 OAuth2 是什么关系?JWT 是令牌格式,OAuth2 是授权框架。OAuth2 可以用 JWT 作为 Access Token 的载体,也可以用不透明令牌。两者不在同一层面。Q: 微服务之间用 JWT 还是 mTLS?服务间通信(非用户请求)更推荐 mTLS(双向 TLS),双方通过证书互验身份,不依赖令牌传播。JWT 适合用户到服务的场景,mTLS 适合服务到服务。Q: JWK 端点挂了怎么办?验证方必须缓存公钥(TTL 5-10 分钟),JWK 端点短暂不可用时用缓存继续验证,不影响线上服务。
服务端阅读 05月27日 23:09

如何优化 JWT 的性能?

核心优化方向有三个:减小 Token 体积、加速签名验证、避免重复解析。减小 Token 体积JWT 每次请求都携带,体积直接影响带宽和解析耗时。// 只存必要字段,用短键名const token = jwt.sign({ uid: "123", rol: "admin" }, SECRET);// 而非 { userId, userName, userRole, userPermissions }Payload 只放用户 ID 和角色,其他信息从缓存取避免存数组、嵌套对象,每个字段都计入 Base64 编码后的体积压缩(如 pako deflate)仅在 Payload > 1KB 时有意义,小 Payload 压缩后反而更大加速签名验证算法选择对性能影响最大:| 算法 | 签名速度 | 验证速度 | Token 大小 | 适用场景 ||------|---------|---------|-----------|----------|| HS256 | 最快 | 最快 | ~32B | 单体应用,密钥可安全共享 || RS256 | 慢 | 中 | ~256B | 微服务,公钥可公开分发 || ES256 | 中 | 快 | ~64B | 移动端/IoT,兼顾速度与体积 |HS256 比 RS256 快 5-10 倍,但密钥泄露风险更高ES256 是分布式场景的平衡选择:签名比 RS256 小 4 倍,验证更快如果只用 HS256,可手写 HMAC 验证替代 jsonwebtoken 库,减少框架开销避免重复解析每次请求都 jwt.verify() 是浪费,可用缓存跳过:// 验证结果缓存(注意安全风险)const NodeCache = require("node-cache");const cache = new NodeCache({ stdTTL: 300 }); // 缓存 5 分钟function verifyCached(token) { const key = `tk:${crypto.createHash("sha256").update(token).digest("hex")}`; const hit = cache.get(key); if (hit) return hit; const decoded = jwt.verify(token, SECRET); cache.set(key, decoded); return decoded;}缓存的安全代价:Token 被撤销(如用户登出)后,缓存期内仍可访问。解法:缓存 TTL 设短(< Token 有效期的 1/10)登出时主动清除对应用户的缓存 key高安全场景不缓存,用算法优化替代多实例部署时用 Redis 替代本地缓存,公钥(JWKS)也做缓存避免每次远程拉取。异步验证// 同步验证阻塞事件循环const decoded = jwt.verify(token, SECRET); // 阻塞// 异步验证释放事件循环const decoded = await new Promise((resolve, reject) => { jwt.verify(token, SECRET, (err, d) => err ? reject(err) : resolve(d));});高并发下同步 crypto 操作会阻塞 Node.js 事件循环,异步 + worker_threads 才是正解。追问Q: 缓存验证结果后,Token 撤销怎么办?上面已提到,核心思路是短 TTL + 主动失效。也可以用 Token 版本号:用户登出时递增版本,缓存中版本不匹配则重新验证。Q: JWT 无状态的意义在哪,加了缓存不就有状态了?缓存是性能优化手段,不是架构依赖。缓存挂掉系统仍可用(降级为全量验证),这和 Session 必须依赖 Redis 有本质区别。Q: 什么时候该用 JWT,什么时候该用 Session?多服务/跨域/移动端选 JWT;单体内聚应用选 Session 更简单。JWT 的性能代价换来的是水平扩展能力,不是所有场景都需要。
前端阅读 05月27日 23:08

如何使用FFmpeg进行无损转码?需要注意哪些参数?

答案FFmpeg 无损转码有两种思路:只换容器,不重新编码:ffmpeg -i input.avi -c copy output.mp4,速度极快,音视频流原样复制,这是最可靠的无损方式。重新编码为无损格式:视频用 libx264/libx265 的无损模式,音频用 FLAC。视频无损编码:# H.264 无损ffmpeg -i input.mp4 -c:v libx264 -crf 0 -c:a copy output.mp4# H.265 无损ffmpeg -i input.mp4 -c:v libx265 -crf 0 -c:a copy output.mp4音频无损编码:# WAV → FLACffmpeg -i input.wav -c:a flac output.flac# 保留元数据ffmpeg -i input.wav -c:a flac -metadata title="原标题" output.flac核心原则:能用 -c copy 就不要重新编码。重新编码即使设 -crf 0,也可能因编码器内部精度引入细微差异。关键参数说明| 参数 | 作用 | 注意事项 ||------|------|----------|| -c copy | 直接复制流,不重新编码 | 最安全的无损方式,但只能换容器 || -c:v libx264 -crf 0 | H.264 无损编码 | 仅 libx264 支持 -crf 0,输出文件极大 || -c:v libx265 -crf 0 | H.265 无损编码 | libx265 的 -crf 0 同样无损 || -c:a flac | 音频无损压缩 | 仅对无损源有意义 || -c:a copy | 直接复制音频流 | 适用任何场景 |需要注意的问题-crf 0 不等于绝对无损。-crf 0 是编码器内部的无损模式,输出质量确实无损,但文件体积远大于原始文件(视频无损编码的输出通常是原文件的数倍)。如果只是想换容器格式,-c copy 才是正确选择。有损源转无损没有意义。将 MP3 转为 FLAC 不会恢复丢失的数据,只会白白增大文件体积。无损转码的前提是输入源本身无损。容器兼容性。MP4 容器不支持 FLAC 音频,也不支持某些无损视频编码。遇到兼容问题时需换用 MKV 等灵活容器:ffmpeg -i input.mp4 -c:v libx264 -crf 0 -c:a flac output.mkv验证无损。转码后可用 ffprobe 对比输入输出流信息,或对原始帧做哈希校验:# 逐帧 MD5 校验ffmpeg -i input.mp4 -f md5 input.md5ffmpeg -i output.mp4 -f md5 output.md5diff input.md5 output.md5追问:-c copy 和 -crf 0 什么时候用?-c copy:只需要改容器格式时(如 MKV 转 MP4、AVI 转 MP4)。不改变编码,速度快,零质量损失。-crf 0:需要改变编码格式时(如 H.264 转 H.265 无损)。会重新编码,速度慢,输出文件大,但保证画面无损。大多数"无损转码"需求实际上只需 -c copy。