历时 3 天!从需求评审到接口上线,我是如何设计高可靠 OpenApi 的

13 阅读10分钟

Java 实现安全可靠 OpenApi 全攻略:基于 sign 签名的设计与实践

今天老大给我布置了一项重要任务:开发一个对外提供服务的 OpenApi,用于和第三方系统对接。这个接口不仅要稳定可靠,还必须做好幂等性处理,并且使用sign签名机制保障数据传输的安全性。作为一名有七年 Java 开发经验的工程师,我将按照标准的开发流程,从需求分析入手,逐步完成技术选型、项目搭建与代码实现。

一、需求分析

1.1 明确接口功能与使用场景

与业务方沟通后,确定本次 OpenApi 主要用于实现订单数据同步、商品信息查询等功能。第三方合作伙伴会在业务流程中频繁调用这些接口,例如在订单支付成功后,通过接口将订单数据同步到我方系统;在商品展示页面,调用接口获取最新的商品信息。

1.2 幂等性需求

幂等性要求保证同一操作无论执行多少次,对系统产生的影响都是一致的。比如第三方因网络波动重复提交订单同步请求,系统不能创建多个重复订单,而是返回首次操作的结果,避免数据混乱。

1.3 安全性需求(sign 签名机制)

使用sign签名机制的核心目的是防止接口被非法调用、数据被篡改。具体来说,每次请求时,第三方需要根据请求参数、约定的密钥等信息生成一个签名(sign),随请求一起发送。服务端接收到请求后,按照相同的规则重新计算签名,并与请求中的签名进行比对。只有签名一致,才认为请求合法,否则拒绝请求,以此确保请求来源可靠、数据未被篡改。

1.4 可靠性需求

接口需要具备高可用性,即使在高并发、网络不稳定等情况下也能正常提供服务。这就要求设计合理的容错机制,如超时重试、限流降级;同时建立完善的监控体系,实时掌握接口运行状态,出现异常及时报警并处理。

二、技术选型

2.1 核心框架

选择 Spring Boot 作为项目的基础框架。它提供了 “开箱即用” 的特性,能够快速搭建项目结构,并且拥有丰富的依赖库和插件,方便集成各种功能。结合 Spring MVC 来处理 HTTP 请求和响应,实现 RESTful 风格的接口设计,便于第三方调用和理解。

2.2 数据存储与缓存

使用 MySQL 作为关系型数据库,存储订单、商品等业务数据。引入 Redis 作为缓存,一方面可以缓存高频访问的数据(如商品信息),减少数据库压力;另一方面,利用 Redis 的原子性操作和高性能,实现幂等性校验(存储请求标识)。

2.3 签名算法

选择 HMAC-SHA256 算法生成sign签名。HMAC-SHA256 是一种基于哈希的消息认证码算法,结合了密钥和哈希函数,能够保证签名的唯一性和不可伪造性。它在安全性和性能上都有不错的表现,适用于网络数据传输的签名验证场景。

2.4 可靠性保障

使用 Hystrix 实现服务熔断和降级,当接口调用超时或依赖的服务不可用时,自动触发熔断,返回预设的降级数据,防止请求长时间阻塞。通过集成 Sentinel 实现接口限流,控制单位时间内的请求量,避免因高并发导致系统崩溃。同时,利用 Prometheus 和 Grafana 搭建监控平台,对接口的调用量、响应时间、错误率等指标进行实时监控和可视化展示。

三、项目搭建

3.1 创建 Spring Boot 项目

通过 Spring Initializr(start.spring.io/)创建项目,选择 Web、Spring Data JPA(操作 MySQL)、Redis、Lombok(简化代码)等依赖。创建完成后,项目结构如下:

openapi-project
├── src
│   ├── main
│   │   ├── java
│   │   │   └── com
│   │   │       └── example
│   │   │           ├── OpenapiApplication.java
│   │   │           ├── config
│   │   │           ├── controller
│   │   │           ├── service
│   │   │           ├── dao
│   │   │           └── utils
│   │   └── resources
│   │       ├── application.yml
│   │       ├── mapper
│   │       └── static
│   └── test
└── pom.xml

3.2 配置文件设置

在application.yml中进行基础配置:

server:
  port: 8080 # 服务端口
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/openapi?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai # MySQL连接地址
    username: root # 数据库用户名
    password: 123456 # 数据库密码
    driver-class-name: com.mysql.cj.jdbc.Driver # 数据库驱动
  data:
    jpa:
      show-sql: true # 显示SQL语句
      hibernate:
        ddl-auto: none # 不自动创建表结构
  redis:
    host: localhost # Redis主机地址
    port: 6379 # Redis端口
# 自定义配置:签名密钥
app:
  sign:
    secret-key: your_secret_key # 签名密钥,需妥善保管,建议从环境变量获取

四、代码编写

4.1 签名工具类(SignUtils)

import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.Map;
import java.util.TreeMap;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.apache.commons.codec.binary.Hex;
import org.springframework.util.StringUtils;
/**
 * 签名工具类,用于生成和验证sign签名
 */
public class SignUtils {
    /**
     * 生成sign签名
     * @param params 请求参数Map,需包含除sign外的所有参数
     * @param secretKey 签名密钥
     * @return 生成的签名
     */
    public static String generateSign(Map<String, String> params, String secretKey) {
        // 对参数进行排序(按key的字典序),保证签名一致性
        Map<String, String> sortedParams = new TreeMap<>(params);
        
        // 拼接参数名和参数值,格式为 key1=value1&key2=value2...
        StringBuilder paramString = new StringBuilder();
        for (Map.Entry<String, String> entry : sortedParams.entrySet()) {
            if (!StringUtils.isEmpty(entry.getValue())) {
                paramString.append(entry.getKey()).append("=").append(entry.getValue()).append("&");
            }
        }
        // 去掉最后一个多余的 &
        if (paramString.length() > 0) {
            paramString.setLength(paramString.length() - 1);
        }
        
        // 将拼接后的字符串与密钥再次拼接
        String data = paramString.toString() + secretKey;
        
        try {
            // 使用HMAC-SHA256算法生成签名
            Mac mac = Mac.getInstance("HmacSHA256");
            SecretKeySpec secretKeySpec = new SecretKeySpec(secretKey.getBytes(), "HmacSHA256");
            mac.init(secretKeySpec);
            byte[] signData = mac.doFinal(data.getBytes());
            // 将签名结果转换为16进制字符串
            return Hex.encodeHexString(signData);
        } catch (NoSuchAlgorithmException | InvalidKeyException e) {
            e.printStackTrace();
            return null;
        }
    }
    /**
     * 验证sign签名
     * @param params 请求参数Map,需包含所有参数(包括sign)
     * @param secretKey 签名密钥
     * @return 验证结果,true表示签名正确,false表示签名错误
     */
    public static boolean verifySign(Map<String, String> params, String secretKey) {
        String requestSign = params.get("sign"); // 获取请求中的签名
        if (StringUtils.isEmpty(requestSign)) {
            return false;
        }
        
        // 移除请求中的sign参数,重新计算签名
        params.remove("sign");
        String calculatedSign = generateSign(params, secretKey);
        
        // 比较计算出的签名和请求中的签名是否一致
        return calculatedSign != null && calculatedSign.equals(requestSign);
    }
}

4.2 幂等性处理(IdempotencyService)

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
 * 幂等性服务类,基于Redis实现幂等性校验
 */
@Service
public class IdempotencyService {
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    /**
     * 生成幂等性请求标识
     * @return 唯一的请求标识
     */
    public String generateRequestId() {
        return UUID.randomUUID().toString();
    }
    /**
     * 校验请求是否重复
     * @param requestId 请求标识
     * @return 校验结果,true表示首次请求,false表示重复请求
     */
    public boolean isFirstRequest(String requestId) {
        // 使用Redis的setIfAbsent方法,保证原子性操作
        // 若返回true,表示请求标识不存在(首次请求),并设置有效期为5分钟
        // 若返回false,表示请求标识已存在(重复请求)
        return stringRedisTemplate.opsForValue().setIfAbsent(requestId, "processed", 5, TimeUnit.MINUTES);
    }
}

4.3 订单控制器(OrderController)

import com.example.utils.IdempotencyService;
import com.example.utils.SignUtils;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.util.HashMap;
import java.util.Map;
/**
 * 订单控制器,处理订单相关接口请求
 */
@RestController
@RequestMapping("/api/order")
public class OrderController {
    @Resource
    private IdempotencyService idempotencyService;
    // 假设的业务处理方法,这里简化为直接返回固定字符串
    private String processOrder(@RequestBody Map<String, String> orderData) {
        return "订单处理成功:" + orderData.get("orderId");
    }
    /**
     * 同步订单接口
     * @param requestId 幂等性请求标识
     * @param sign 签名
     * @param orderData 订单数据
     * @return 处理结果
     */
    @PostMapping("/sync")
    public String syncOrder(
            @RequestHeader("Request-Id") String requestId, // 从请求头获取幂等性请求标识
            @RequestHeader("Sign") String sign, // 从请求头获取签名
            @RequestBody Map<String, String> orderData) {
        // 1. 校验幂等性
        if (!idempotencyService.isFirstRequest(requestId)) {
            return "重复请求,操作已执行";
        }
        // 2. 构建参数Map,用于签名验证
        Map<String, String> allParams = new HashMap<>();
        allParams.putAll(orderData);
        allParams.put("Request-Id", requestId);
        allParams.put("Sign", sign);
        // 3. 验证签名
        if (!SignUtils.verifySign(allParams, "your_secret_key")) {
            return "签名验证失败,请求非法";
        }
        // 4. 执行业务逻辑
        return processOrder(orderData);
    }
}

4.4 全局异常处理(GlobalExceptionHandler)

import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
 * 全局异常处理器,统一处理接口调用过程中的异常
 */
@RestControllerAdvice
public class GlobalExceptionHandler {
    /**
     * 处理所有未捕获的异常
     * @param e 异常对象
     * @return 友好的错误提示信息
     */
    @ExceptionHandler(Exception.class)
    public String handleException(Exception e) {
        e.printStackTrace();
        return "接口调用发生异常,请稍后重试";
    }
}

五、测试验证

5.1 使用 Postman 测试接口

  1. 生成请求参数:假设订单数据为{"orderId": "123456", "orderAmount": "100.00"},生成幂等性请求标识requestId,使用SignUtils生成签名sign。
  1. 设置请求头
    • Request-Id:填写生成的请求标识
    • Sign:填写生成的签名
  1. 发送 POST 请求:地址为http://localhost:8080/api/order/sync,请求体为订单数据。
  1. 验证结果:首次请求应返回订单处理成功信息;再次使用相同requestId请求,应返回 “重复请求,操作已执行”;修改请求参数后不重新计算签名,应返回 “签名验证失败,请求非法”。

通过以上从需求分析到代码实现的完整流程,我们成功设计并开发了一个具备幂等性、基于sign签名保障安全的 OpenApi。在实际项目中,还可以根据具体需求进一步完善功能,如增加接口文档、优化性能、加强日志记录等,不断提升接口的稳定性和易用性。

六、Java OpenApi 测试类编写

为了验证 OpenApi 代码的正确性,我们可以使用 JUnit 5 和 Spring 的测试框架来编写测试类。以下是针对幂等性、签名验证以及接口业务逻辑的测试代码示例,通过这些测试可以确保接口在不同场景下都能正常工作。

一、引入测试依赖

在pom.xml文件中添加 JUnit 5 和 Spring Boot 测试相关依赖:

<dependencies>
    <!-- JUnit 5测试依赖 -->
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-api</artifactId>
        <version>5.8.2</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-engine</artifactId>
        <version>5.8.2</version>
        <scope>test</scope>
    </dependency>
    <!-- Spring Boot测试依赖 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

二、编写测试类

2.1 测试类基础设置

创建OrderControllerTest测试类,用于测试OrderController中的接口:

import com.example.OpenapiApplication;
import com.example.utils.IdempotencyService;
import com.example.utils.SignUtils;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
import java.util.HashMap;
import java.util.Map;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
// 标注使用WebMvcTest,只加载OrderController相关组件进行测试
@WebMvcTest(OrderController.class)
public class OrderControllerTest {
    @Autowired
    private MockMvc mockMvc; // 用于模拟HTTP请求
    @MockBean
    private IdempotencyService idempotencyService; // 模拟幂等性服务
    @MockBean
    private SignUtils signUtils; // 模拟签名工具类
}

2.2 幂等性测试

测试幂等性校验逻辑,验证重复请求时接口是否返回正确提示:

@Test
public void testIdempotency() throws Exception {
    String requestId = "test-request-id";
    // 模拟幂等性服务返回false,表示重复请求
    when(idempotencyService.isFirstRequest(requestId)).thenReturn(false);
    Map<String, String> orderData = new HashMap<>();
    orderData.put("orderId", "123456");
    orderData.put("orderAmount", "100.00");
    // 构建请求参数Map,用于生成签名
    Map<String, String> allParams = new HashMap<>();
    allParams.putAll(orderData);
    allParams.put("Request-Id", requestId);
    // 假设签名,实际测试中可根据具体逻辑生成
    allParams.put("Sign", "test-sign");
    // 模拟POST请求
    mockMvc.perform(MockMvcRequestBuilders.post("/api/order/sync")
           .header("Request-Id", requestId)
           .header("Sign", "test-sign")
           .contentType(MediaType.APPLICATION_JSON)
           .content("{"orderId":"123456","orderAmount":"100.00"}"))
           .andExpect(status().isOk())
           .andExpect(MockMvcResultMatchers.content().string("重复请求,操作已执行"));
}

2.3 签名验证测试

测试签名验证逻辑,验证签名错误时接口是否拒绝请求:

@Test
public void testSignVerificationFailure() throws Exception {
    String requestId = "test-request-id";
    // 模拟幂等性服务返回true,表示首次请求
    when(idempotencyService.isFirstRequest(requestId)).thenReturn(true);
    // 模拟签名验证失败
    when(signUtils.verifySign(MockMvcRequestBuilders.post("/api/order/sync")
           .header("Request-Id", requestId)
           .header("Sign", "wrong-sign")
           .contentType(MediaType.APPLICATION_JSON)
           .content("{"orderId":"123456","orderAmount":"100.00"}").build().getParams(), "your_secret_key")).thenReturn(false);
    Map<String, String> orderData = new HashMap<>();
    orderData.put("orderId", "123456");
    orderData.put("orderAmount", "100.00");
    // 构建请求参数Map,用于生成签名
    Map<String, String> allParams = new HashMap<>();
    allParams.putAll(orderData);
    allParams.put("Request-Id", requestId);
    allParams.put("Sign", "wrong-sign");
    // 模拟POST请求
    mockMvc.perform(MockMvcRequestBuilders.post("/api/order/sync")
           .header("Request-Id", requestId)
           .header("Sign", "wrong-sign")
           .contentType(MediaType.APPLICATION_JSON)
           .content("{"orderId":"123456","orderAmount":"100.00"}"))
           .andExpect(status().isOk())
           .andExpect(MockMvcResultMatchers.content().string("签名验证失败,请求非法"));
}

2.4 正常业务逻辑测试

测试接口在幂等性和签名验证都通过时,是否能正常执行业务逻辑:

@Test
public void testNormalBusinessLogic() throws Exception {
    String requestId = "test-request-id";
    // 模拟幂等性服务返回true,表示首次请求
    when(idempotencyService.isFirstRequest(requestId)).thenReturn(true);
    // 模拟签名验证通过
    when(signUtils.verifySign(MockMvcRequestBuilders.post("/api/order/sync")
           .header("Request-Id", requestId)
           .header("Sign", "valid-sign")
           .contentType(MediaType.APPLICATION_JSON)
           .content("{"orderId":"123456","orderAmount":"100.00"}").build().getParams(), "your_secret_key")).thenReturn(true);
    Map<String, String> orderData = new HashMap<>();
    orderData.put("orderId", "123456");
    orderData.put("orderAmount", "100.00");
    // 构建请求参数Map,用于生成签名
    Map<String, String> allParams = new HashMap<>();
    allParams.putAll(orderData);
    allParams.put("Request-Id", requestId);
    allParams.put("Sign", "valid-sign");
    // 模拟POST请求
    mockMvc.perform(MockMvcRequestBuilders.post("/api/order/sync")
           .header("Request-Id", requestId)
           .header("Sign", "valid-sign")
           .contentType(MediaType.APPLICATION_JSON)
           .content("{"orderId":"123456","orderAmount":"100.00"}"))
           .andExpect(status().isOk())
           .andExpect(MockMvcResultMatchers.content().string("订单处理成功:123456"));
}