大家好,我是曉凡。
產品一句話: “凡哥,接口明天上線,支持 10w 並發,資料脫敏,不能丟單,不能重複,還要安全。”
優雅不是裝,是為了讓自己少加班、少背鍋、少掉髮。
今天曉凡就把壓箱底的東西掏出來,手把手帶你撸一套能扛生產的模板。
為方便閱讀,曉凡以Java代碼為例給出“核心代碼 + 使用姿勢”,全部親測可直接使用。
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白名單,一般在網關層處理
配置
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;
}
}
尤其對外提供的接口,無法保障調用頻率,應該做限流處理,保障接口服務正常的提供服務
依賴
<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("訪問太頻繁,稍後再試");
}
即使前端做了非空,規範性校驗,服務端參數校驗任然是必不可少的
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(...) {}
對於一些涉及到資料一致性的接口一定要做好幂等設計,以防資料出現重複問題
思路
代碼
@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);
}
對於批量資料接口,一定要限制返回的記錄條數,不讓會造成惡意攻擊導致伺服器癱瘓。
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));
}
上線前,務必要對API接口進行壓力測試,知道各個接口的qps情況。以便我們能夠更好的預估,需要部署多少服務節點,對於API接口的穩定性至關重要。
java -jar -Xms1g -Xmx1g demo-api.jartop -H 看 CPU arthas 火焰圖找慢方法 如果同步處理業務,耗時會非常長。這種情況下,為了提升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));
}
}
提供在線接口文檔,既方便開發調試接口,也方便運維人員排查錯誤
依賴
<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% 的生產場景。
祝你在領導問起接口怎麼樣了?的時候,可以淡淡來一句:
“接口已經準備好了,壓測報告發群裡了。”