大家好,我是晓凡。
一、为什么要“优雅”?
产品一句话: “凡哥,接口明天上线,支持 10w 并发,数据脱敏,不能丢单,不能重复,还要安全。”
优雅不是装,是为了让自己少加班、少背锅、少掉发。
今天晓凡就把压箱底的东西掏出来,手把手带你撸一套能扛生产的模板。
为方便阅读,晓凡以Java代码为例给出“核心代码 + 使用姿势”,全部亲测可直接使用。
二、项目骨架(Spring Boot 3.x)
demo-api ├── src/main/java/com/example/demo │ ├── config // 配置:限流、加解密、日志等 │ ├── annotation // 自定义注解(幂等、日志、脱敏) │ ├── aspect // 切面统一干活 │ ├── interceptor // 拦截器(签名、白名单) │ ├── common // 统一返回、异常、常量 │ ├── controller // 对外暴露 │ ├── service │ └── DemoApplication.java └── pom.xml
三、 签名(防篡改)
对外提供的接口要做签名认证,认证不通过的请求不允许访问接口、提供服务
思路
“时间戳 + 随机串 + 业务参数”排好序,最后 APP_SECRET 拼后面,SHA256 一下。
前后端、第三方都统一,拒绝撕逼。
工具类
public class SignUtil { /** * 生成签名 * @param map 除 sign 外的所有参数 * @param secret 分配给你的私钥 */ public static String sign(Map<String, String> map, String secret) { // 1. 参数名升序排列 Map<String, String> tree = new TreeMap<>(map); // 2. 拼成 k=v&k=v String join = tree.entrySet().stream() .map(e -> e.getKey() + "=" + e.getValue()) .collect(Collectors.joining("&")); // 3. 最后拼密钥 String raw = join + "&key=" + secret; // 4. SHA256 return DigestUtils.sha256Hex(raw).toUpperCase(); } /** 验签:直接比对即可 */ public static boolean verify(Map<String, String> map, String secret, String requestSign) { return sign(map, secret).equals(requestSign); } }
拦截器统一验签
@Component public class SignInterceptor implements HandlerInterceptor { @Value("${sign.secret}") private String secret; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 只拦截接口 if (!(handler instanceof HandlerMethod)) return true; Map<String, String> params = Maps.newHashMap(); request.getParameterMap().forEach((k, v) -> params.put(k, v[0])); String sign = params.remove("sign"); // 签名不参与计算 if (!SignUtil.verify(params, secret, sign)) { throw new BizException("签名错误"); } return true; } }
四、 加密(防泄露)
敏感数据在网络传输过程中都应该加密处理
思路
AES 对称加密,密钥放配置中心,支持一键开关。
只对敏感字段加密,别一上来全包加密,排查日志想打人。
AES 工具
public class AesUtil { private static final String ALG = "AES/CBC/PKCS5Padding"; // 16 位 private static final String KEY = "1234567890abcdef"; private static final String IV = "abcdef1234567890"; public static String encrypt(String src) { try { Cipher cipher = Cipher.getInstance(ALG); SecretKeySpec keySpec = new SecretKeySpec(KEY.getBytes(), "AES"); IvParameterSpec ivSpec = new IvParameterSpec(IV.getBytes()); cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); return Base64.getEncoder().encodeToString(cipher.doFinal(src.getBytes())); } catch (Exception e) { throw new RuntimeException("加密失败", e); } } public static String decrypt(String src) { try { Cipher cipher = Cipher.getInstance(ALG); SecretKeySpec keySpec = new SecretKeySpec(KEY.getBytes(), "AES"); IvParameterSpec ivSpec = new IvParameterSpec(IV.getBytes()); cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); return new String(cipher.doFinal(Base64.getDecoder().decode(src))); } catch (Exception e) { throw new RuntimeException("解密失败", e); } } }
五、 IP 白名单
限制请求的IP,增加IP白名单,一般在网关层处理
配置
white: ips: 127.0.0.1,10.0.0.0/8,192.168.0.0/16
拦截器
@Component public class WhiteListInterceptor implements HandlerInterceptor { @Value("#{'${white.ips}'.split(',')}") private List<String> allowList; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String ip = IpUtil.getIp(request); boolean ok = allowList.stream() .anyMatch(rule -> IpUtil.match(ip, rule)); if (!ok) throw new BizException("IP 不允许访问"); return true; } }
六、 限流(Sentinel 注解版)
尤其对外提供的接口,无法保障调用频率,应该做限流处理,保障接口服务正常的提供服务
依赖
<dependency> <groupId>com.alibaba.csp</groupId> <artifactId>sentinel-spring-boot-starter</artifactId> <version>1.8.6</version> </dependency>
配置
spring: application: name: demo-api sentinel: transport: dashboard: localhost:8080
使用姿势
@GetMapping("/order/{id}") @SentinelResource(value = "getOrder", blockHandler = "getOrderBlock") public Result<OrderVO> getOrder(@PathVariable Long id) { return Result.success(orderService.get(id)); } // 限流兜底 public Result<OrderVO> getOrderBlock(Long id, BlockException e) { return Result.fail("访问太频繁,稍后再试"); }
七、 参数校验(JSR303 + 分组)
即使前端做了非空,规范性校验,服务端参数校验任然是必不可少的
DTO
public class OrderCreateDTO { @NotNull(message = "用户 ID 不能为空") private Long userId; @NotEmpty(message = "商品列表不能为空") @Size(max = 20, message = "一次最多买 20 件") private List<Item> items; @Valid @NotNull private PayInfo payInfo; @Data public static class PayInfo { @Min(value = 1, message = "金额必须大于 0") private Integer amount; } }
分组接口
public interface Create {}
Controller
@PostMapping("/order") public Result<Long> create(@RequestBody @Validated(Create.class) OrderCreateDTO dto) { Long orderId = orderService.create(dto); return Result.success(orderId); }
八、 统一返回值
提供统一的返回结果,不应该返回值五花八门
@Data @AllArgsConstructor @NoArgsConstructor public class Result<T> implements Serializable { private int code; private String msg; private T data; public static <T> Result<T> success(T data) { return new Result<>(200, "success", data); } public static <T> Result<T> fail(String msg) { return new Result<>(500, msg, null); } /** 返回 200 但提示业务失败 */ public static <T> Result<T> bizFail(int code, String msg) { return new Result<>(code, msg, null); } }
九、 统一异常处理
系统报错信息需要提供友好的提示,避免暴露出SQL异常的信息给调用方和客户端。
@RestControllerAdvice public class GlobalExceptionHandler { private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); /** 业务异常 */ @ExceptionHandler(BizException.class) public Result<Void> handle(BizException e) { log.warn("业务异常:{}", e.getMessage()); return Result.bizFail(e.getCode(), e.getMessage()); } /** 参数校验失败 */ @ExceptionHandler(MethodArgumentNotValidException.class) public Result<Void> handleValid(MethodArgumentNotValidException e) { String msg = e.getBindingResult() .getFieldErrors() .stream() .map(DefaultMessageSourceResolvable::getDefaultMessage) .collect(Collectors.joining(",")); return Result.fail(msg); } /** 兜底 */ @ExceptionHandler(Exception.class) public Result<Void> handleAll(Exception e) { log.error("系统异常", e); return Result.fail("服务器开小差"); } }
十、 请求日志(切面 + 注解)
记录请求的入参日志和返回日志,出问题时方便快速定位。也给运维人员提供了方便
注解
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface ApiLog {}
切面
@Aspect @Component public class LogAspect { private static final Logger log = LoggerFactory.getLogger("api.log"); @Around("@annotation(apiLog)") public Object around(ProceedingJoinPoint p, ApiLog apiLog) throws Throwable { long start = System.currentTimeMillis(); ServletRequestAttributes attr = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); HttpServletRequest req = attr.getRequest(); String uri = req.getRequestURI(); String params = JSON.toJSONString(p.getArgs()); Object result; try { result = p.proceed(); } catch (Exception e) { log.error("【{}】params={} error={}", uri, params, e.getMessage()); throw e; } finally { long cost = System.currentTimeMillis() - start; log.info("【{}】params={} cost={}ms", uri, params, cost); } return result; } }
用法
@ApiLog @PostMapping("/order") public Result<Long> create(...) {}
十一、幂等设计(Token & 分布式锁双保险)
对于一些涉及到数据一致性的接口一定要做好幂等设计,以防数据出现重复问题
思路
- 下单前先申请一个幂等 Token(存在 Redis,5 分钟失效)。
- 下单时带着 Token,后端用 Lua 脚本“判断存在并删除”,原子性保证只能用一次。
- 对并发极高场景,再补一层分布式锁(Redisson)。
代码
@Service public class IdempotentService { @Resource private StringRedisTemplate redis; /** 申请 Token */ public String createToken() { String token = UUID.fastUUID().toString(); redis.opsForValue().set("token:" + token, "1", Duration.ofMinutes(5)); return token; } /** 验证并删除 */ public boolean checkToken(String token) { String key = "token:" + token; // 原子删除成功才算用过 return Boolean.TRUE.equals(redis.delete(key)); } }
Controller
@GetMapping("/token") public Result<String> getToken() { return Result.success(idempotentService.createToken()); } @PostMapping("/order") @ApiLog public Result<Long> create(@RequestBody @Valid OrderCreateDTO dto, @RequestHeader("Idempotent-Token") String token) { if (!idempotentService.checkToken(token)) { throw new BizException("请勿重复提交"); } Long orderId = orderService.create(dto); return Result.success(orderId); }
十二、限制记录条数(分页 + SQL 保护)
对于批量数据接口,一定要限制返回的记录条数,不让会造成恶意攻击导致服务器宕机。
MyBatis-Plus 分页插件
@Configuration public class MybatisConfig { @Bean public MybatisPlusInterceptor interceptor() { MybatisPlusInterceptor i = new MybatisPlusInterceptor(); i.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return i; } }
Service
public Page<OrderVO> list(OrderListDTO dto) { // 前端不传默认 10 条,最多 200 long size = Math.min(dto.getPageSize(), 200); Page<Order> page = new Page<>(dto.getPageNo(), size); LambdaQueryWrapper<Order> w = Wrappers.lambdaQuery(); if (StrUtil.isNotBlank(dto.getUserName())) { w.like(Order::getUserName, dto.getUserName()); } Page<Order> po = orderMapper.selectPage(page, w); return po.convert(o -> BeanUtil.copyProperties(o, OrderVO.class)); }
十三、 压测(JMeter + 自带脚本)
上线前,务必要对API接口进行压力测试,知道各个接口的qps情况。以便我们能够更好的预估,需要部署多少服务节点,对于API接口的稳定性至关重要。
-
起服务:
java -jar -Xms1g -Xmx1g demo-api.jar -
JMeter 线程组:
500 线程、Ramp-up 10s、循环 20。 -
观测:
- Sentinel 控制台看 QPS、RT
top -H看 CPUarthas火焰图找慢方法
-
调优:
- 限流阈值 = 压测 80% 最高水位
- 发现慢 SQL 加索引
- 热点数据加本地缓存(Caffeine)
十四、异步处理
如果同步处理业务,耗时会非常长。这种情况下,为了提升API接口性能,我们可以改为异步处理
下单成功后,发 MQ 异步发短信/扣库存,接口 RT 直接降一半。
@Async("asyncExecutor") // 自定义线程池 public void sendSmsAsync(Long userId, String content) { smsService.send(userId, content); }
十五、数据脱敏
业务中对与用户的敏感数据,如密码等需要进行脱敏处理
返回前统一用 Jackson 序列化过滤器,字段加注解就行,代码零侵入。
@JsonSerialize(using = SensitiveSerializer.class) @Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) public @interface Sensitive { SensitiveType type(); } public enum SensitiveType { PHONE, ID_CARD, BANK_CARD } public class SensitiveSerializer extends JsonSerializer<String> { @Override public void serialize(String value, JsonGenerator g, SerializerProvider p) throws IOException { if (StrUtil.isBlank(value)) { g.writeString(value); return; } g.writeString(DesensitizeUtil.desPhone(value)); } }
十六、完整的接口文档(Knife4j)
提供在线接口文档,既方便开发调试接口,也方便运维人员排查错误
依赖
<dependency> <groupId>com.github.xiaoymin</groupId> <artifactId>knife4j-openapi3-spring-boot-starter</artifactId> <version>4.1.0</version> </dependency>
配置
knife4j: enable: true setting: language: zh_cn
启动后访问
http://localhost:8080/doc.html
支持在线调试、导出 PDF、Word。
十七、小结
接口开发就像炒菜:
- 签名、加密是“食材保鲜”
- 限流、幂等是“火候掌控”
- 日志、文档是“摆盘拍照”
每道工序做到位,才能端到桌上“色香味”俱全。
上面 13 段核心代码,直接粘过去就能跑,跑通后再按业务微调,基本能扛 90% 的生产场景。
祝你在领导问起接口怎么样了?的时候,可以淡淡来一句:
“接口已经准备好了,压测报告发群里了。”