接口开发,咱得整得“优雅”点

大家好,我是晓凡。

一、为什么要“优雅”?

产品一句话: “凡哥,接口明天上线,支持 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 & 分布式锁双保险)

对于一些涉及到数据一致性的接口一定要做好幂等设计,以防数据出现重复问题

思路

  1. 下单前先申请一个幂等 Token(存在 Redis,5 分钟失效)。
  2. 下单时带着 Token,后端用 Lua 脚本“判断存在并删除”,原子性保证只能用一次。
  3. 对并发极高场景,再补一层分布式锁(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接口的稳定性至关重要。

  1. 起服务:
    java -jar -Xms1g -Xmx1g demo-api.jar

  2. JMeter 线程组:
    500 线程、Ramp-up 10s、循环 20。

  3. 观测:

    • Sentinel 控制台看 QPS、RT
    • top -H 看 CPU
    • arthas 火焰图找慢方法
  4. 调优:

    • 限流阈值 = 压测 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% 的生产场景。
祝你在领导问起接口怎么样了?的时候,可以淡淡来一句:
“接口已经准备好了,压测报告发群里了。”

发表评论

评论已关闭。

相关文章