大家好,我是晓凡。
一、为什么要"优雅"?
产品一句话: "凡哥,接口明天上线,支持 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 & 分布式锁双保险)
对于一些涉及到数据一致性的接口一定要做好幂等设计,以防数据出现重复问题
思路
- 下单前先申请一个幂等 Token(存在 Redis,5 分钟失效)。
- 下单时带着 Token,后端用 Lua 脚本"判断存在并删除",原子性保证只能用一次。
- 对并发极高场景,再补一层分布式锁(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接口的稳定性至关重要。
-
起服务:
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 直接降一半。
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% 的生产场景。
祝你在领导问起接口怎么样了?的时候,可以淡淡来一句:
"接口已经准备好了,压测报告发群里了。"