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

大家好,我是晓凡。

一、为什么要"优雅"?

产品一句话: "凡哥,接口明天上线,支持 10w 并发,数据脱敏,不能丢单,不能重复,还要安全。"

优雅不是装,是为了让自己少加班、少背锅、少掉发。

今天晓凡就把压箱底的东西掏出来,手把手带你撸一套能扛生产的模板。

为方便阅读,晓凡以Java代码为例给出"核心代码 + 使用姿势",全部亲测可直接使用。

二、项目骨架(Spring Boot 3.x)

arduino 复制代码
demo-api
├── src/main/java/com/example/demo
│   ├── config          // 配置:限流、加解密、日志等
│   ├── annotation      // 自定义注解(幂等、日志、脱敏)
│   ├── aspect          // 切面统一干活
│   ├── interceptor     // 拦截器(签名、白名单)
│   ├── common          // 统一返回、异常、常量
│   ├── controller      // 对外暴露
│   ├── service
│   └── DemoApplication.java
└── pom.xml

三、 签名(防篡改)

对外提供的接口要做签名认证,认证不通过的请求不允许访问接口、提供服务

思路

"时间戳 + 随机串 + 业务参数"排好序,最后 APP_SECRET 拼后面,SHA256 一下。

前后端、第三方都统一,拒绝撕逼。

工具类

java 复制代码
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);
    }
}

拦截器统一验签

java 复制代码
@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 工具

java 复制代码
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白名单,一般在网关层处理

配置

yaml 复制代码
white:
  ips: 127.0.0.1,10.0.0.0/8,192.168.0.0/16

拦截器

java 复制代码
@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 注解版)

尤其对外提供的接口,无法保障调用频率,应该做限流处理,保障接口服务正常的提供服务

依赖

xml 复制代码
<dependency>
    <groupId>com.alibaba.csp</groupId>
    <artifactId>sentinel-spring-boot-starter</artifactId>
    <version>1.8.6</version>
</dependency>

配置

yaml 复制代码
spring:
  application:
    name: demo-api
sentinel:
  transport:
    dashboard: localhost:8080

使用姿势

java 复制代码
@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

java 复制代码
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;
    }
}

分组接口

java 复制代码
public interface Create {}

Controller

java 复制代码
@PostMapping("/order")
public Result<Long> create(@RequestBody @Validated(Create.class) OrderCreateDTO dto) {
    Long orderId = orderService.create(dto);
    return Result.success(orderId);
}

八、 统一返回值

提供统一的返回结果,不应该返回值五花八门

java 复制代码
@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异常的信息给调用方和客户端。

java 复制代码
@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("服务器开小差");
    }
}

十、 请求日志(切面 + 注解)

记录请求的入参日志和返回日志,出问题时方便快速定位。也给运维人员提供了方便

注解

java 复制代码
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiLog {}

切面

java 复制代码
@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;
    }
}

用法

java 复制代码
@ApiLog
@PostMapping("/order")
public Result<Long> create(...) {}

十一、幂等设计(Token & 分布式锁双保险)

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

思路

  1. 下单前先申请一个幂等 Token(存在 Redis,5 分钟失效)。
  2. 下单时带着 Token,后端用 Lua 脚本"判断存在并删除",原子性保证只能用一次。
  3. 对并发极高场景,再补一层分布式锁(Redisson)。

代码

java 复制代码
@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

java 复制代码
@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 分页插件

java 复制代码
@Configuration
public class MybatisConfig {
    @Bean
    public MybatisPlusInterceptor interceptor() {
        MybatisPlusInterceptor i = new MybatisPlusInterceptor();
        i.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return i;
    }
}

Service

java 复制代码
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 直接降一半。

java 复制代码
@Async("asyncExecutor")   // 自定义线程池
public void sendSmsAsync(Long userId, String content) {
    smsService.send(userId, content);
}

十五、数据脱敏

业务中对与用户的敏感数据,如密码等需要进行脱敏处理

返回前统一用 Jackson 序列化过滤器,字段加注解就行,代码零侵入。

java 复制代码
@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)

提供在线接口文档,既方便开发调试接口,也方便运维人员排查错误

依赖

xml 复制代码
<dependency>
    <groupId>com.github.xiaoymin</groupId>
    <artifactId>knife4j-openapi3-spring-boot-starter</artifactId>
    <version>4.1.0</version>
</dependency>

配置

yaml 复制代码
knife4j:
  enable: true
  setting:
    language: zh_cn

启动后访问
http://localhost:8080/doc.html

支持在线调试、导出 PDF、Word。

十七、小结

接口开发就像炒菜:

  • 签名、加密是"食材保鲜"
  • 限流、幂等是"火候掌控"
  • 日志、文档是"摆盘拍照"

每道工序做到位,才能端到桌上"色香味"俱全。

上面 13 段核心代码,直接粘过去就能跑,跑通后再按业务微调,基本能扛 90% 的生产场景。

祝你在领导问起接口怎么样了?的时候,可以淡淡来一句:

"接口已经准备好了,压测报告发群里了。"

相关推荐
bagadesu2 小时前
IDEA + Spring Boot 的三种热加载方案
java·后端
二进制person3 小时前
JavaEE初阶 --文件操作和IO
java·java-ee
@老蝴3 小时前
Java EE - 线程安全的产生及解决方法
java·开发语言·java-ee
せいしゅん青春之我3 小时前
【JavaEE初阶】网络层-IP协议
java·服务器·网络·网络协议·tcp/ip·java-ee
Han.miracle3 小时前
Java ee初阶——定时器
java·java-ee
飞鱼&4 小时前
HashMap相关问题详解
java·hashmap
没有bug.的程序员4 小时前
Spring Cloud Alibaba 生态总览
java·开发语言·spring boot·spring cloud·alibaba
快乐非自愿5 小时前
Java垃圾收集器全解:从Serial到G1的进化之旅
java·开发语言·python
树在风中摇曳5 小时前
Java 静态成员与继承封装实战:从报错到彻底吃透核心特性
java·开发语言