🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付

接口開發,咱得整得“優雅”點

大家好,我是曉凡。

一、為什麼要“優雅”?

產品一句話: “凡哥,接口明天上線,支持 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% 的生產場景。
祝你在領導問起接口怎麼樣了?的時候,可以淡淡來一句:
“接口已經準備好了,壓測報告發群裡了。”


原文出處:https://juejin.cn/post/7571749988080435209


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝13   💬4   ❤️5
423
🥈
我愛JS
📝2   💬3   ❤️3
66
🥉
酷豪
1
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付