如何正确地对接口进行防御式编程

我们平时做业务开发工作,本质上是处理数据与存储、逻辑的关系。而我们程序的数据,绝大部分来自外部接口输入,对接口输入的检查必须要做。否则就会导致应用和各个微服务的数据被写脏,出现各种数据不一致的问题,进而引发运行时逻辑出错和程序 crash 等问题。这时防御式编程的重要性就体现出来了,而大家也清楚这一点。

但是在具体的工作中,我发现很多同学没有做对接口入参的校验,完全信任外部系统的入参;有部分同学做了校验,但是不全面。那到底该怎么正确、优雅、全面地对接口入参进行校验,做好防御式编程呢?这就是我们接下来要讨论的问题。

假设我们有这样一个需求:设计一个订单商城系统。它会涉及到商品创建、商品发布、用户购物车管理、用户下单、购买、取消订单等非常多的用例。以商户端创建商品这个接口设计为例,我们看下如何运用防御式编程来处理。

typescript 复制代码
class CreateProductRequestData {
    private Productproduct;
}
classProduct {
private StoreId storeId;
private String name;
private String imageId;
private Type type;
privateList<SKU> skus;
  ...
}

针对前端提交的创建商品的数据,我们必须对数据做正确、全面的校验,防止客户端写 Bug 或者黑客攻击导致系统应用数据被写脏。

在这个 case 中,我们要根据产品需求校验参数,在和前端开发同学定义好接口文档后,也同样要按照定义去校验参数。storeId 必传、商品 name 必传、type 必须为枚举中的值、以及逐个校验 skus 中的 sku。

typescript 复制代码
if (storeId == null) {
  throw new ApiException("storeId 不允许为 null");
}
if (Strings.isNullOrEmpty(name)) {
  throw new ApiException("name 不允许为 null");
}
if (type == null) {
  throw new ApiException("产品类型 type 不允许为 null");
}
if (CollectionUtils.isEmpty(skus)) {
  throw new ApiException("skus 不允许为空");
}
// 校验skus

我们来看上面这段校验代码。其实在工作中,我们通常不会这么写,而是抽出单独的工具类,对前端接口或者微服务跨系统之间的调用参数进行断言校验。如果参数内容和接口定义不符或者和产品需求不一致,就抛出 Exception,由自定义的全局异常处理器捕获 Exception。在我们的例子里是 ApiException,然后生成 response 返回前端。ApiException 代表客户端写出了 Bug,或者有黑客对接口进行攻击,乱传入参数引发的异常。

这段代码片段是我们抽出的通用工具类,可以用来校验前端参数和微服务跨系统调用的入参:

typescript 复制代码
public class Asserts {
    //校验表达式,可以用来检查客户端接口数据的逻辑关联性
    public static void assertTrue(String description, boolean value) {
        if(!value) {
            throw new ApiException(description);
        }
    }
    //校验字段不为空
    public static void assertFieldNotEmpty(String field, String value)    {
        assertFieldNotEmpty(field, value, false /* trim */);
    }
    // 校验字段不为空的重载方法
    public static String assertFieldNotEmpty(String field, String value, boolean trim) {
        boolean fieldNotEmpty = value != null;
        if(fieldNotEmpty) {
            if(trim) {
                value = value.trim();
            }
            if(Strings.isNullOrEmpty(value)) {
                fieldNotEmpty = false;
            }
        }
        if(!fieldNotEmpty) {
            throw new ApiException("字段" + field + "不应为空。");
        }
        return value;
    }
    //校验字段不为null
    public static void assertFieldNotNull(String field, Object value) {
        if(value == null) {
            throw new ApiException(field + " 不应为 Null.");
        }
    }
    //... 其他校验方法
}

这里需要提醒你一点,在防御式编程中,我们要尽可能地处理错误,而不是异常。这句话怎么理解呢?还是拿刚才的例子来说,假如不校验 storeId,任由 null 值进入系统,那么在系统运行中,我们就必然要处理因为 storeId 为 null 而产生的 crash,例如 NullPointerException。现在我们在接口层校验处理了这个错误(校验 storeId 不为 null),那么就可以保证应用中的数据是安全的,是经过检查的,没有脏数据。其他字段的校验也是一样的作用,通过对外部数据进行校验,做防御性保护,尽可能处理错误,我们的系统就会非常干净,运行状态也会非常健康。

你可能会问,那如果接口数量很多,该怎么设计,在什么地方校验这些参数?针对这个问题,可以这么解决,我们定义一个 Validatable 通用接口,里面只有一个方法 validate(),它是一个通用校验的钩子方法。我们的各种 RequestData、各种 model(可以是 RequestVO) 实现这个接口,实现 validate 钩子方法,做业务相关的参数检查。

比如这段代码片段,我们可以对之前的代码做改造和重构,让它们变得更加优雅。这样每个 RequestData 只关心自己的校验逻辑,做好自己的防御性保护就行了。

typescript 复制代码
public interface Validatable {
  void validate();
}
public class Product implements Validatable {
private StoreId storeId;
private String name;
private String imageId;
private Type type;
private List<SKU> skus;
  // product的其他属性
  @Override
public void validate() {
    Asserts.assertFieldNotNull("storeId", this.storeId);
    Asserts.assertFieldNotEmpty("name", this.name);
    Asserts.assertFieldNotNull("type", this.type);
    Asserts.assertTrue("skus 不允许为空",!CollectionUtils.isEmpty(skus));
    for(SKU sku : skus) {
      sku.validate();
    }
    // ...校验product的其他字段
  }
}
public class SKU implements Validatable {
private int ordinal;
private String name;
  // ...sku的其他属性
  @Override
public void validate() {
    Asserts.assertFieldNotEmpty("name", this.name);
    // ...校验sku的其他字段
  }
}
public class CreateProductRequestData implements Validatable{
private Product product;
  @Override
public void validate() {
    Asserts.assertFieldNotNull("product", this.product);
    product.validate();
  }
}

这里有一个问题,这么多 RequestData,我们在开发中到底如何关联具体的处理器呢?我在这里也分享一种解决方案。你可以定义与 RequestData 对应的 RequestHandler,然后通过泛型绑定 RequestData 和 ResponseData。通过一个特定的 handlerRepo 在容器启动的时候就把这些 handler 全部组装好。这样,我们增加一个接口时只需要增加对应的 RequestData、ResponseData 和 handler,满足了对扩展开放,对修改关闭。请求来的时候,根据请求路径能知道具体的 handler,在这里可以回调 validate 钩子完成校验。你可以参考这段代码:

scala 复制代码
@Service
public class CreateProductRequestHandler extends RequestHandler<CreateProductRequestData,CreateProductResponseData>{
  @Override
public CreateProductResponseDataexecuteRequest(RequestContext,
  CreateProductRequestData req) {
    // ...
  }
}
@Service
public class HandlerRepo {
// 当web容器接收请求(请求url约定就是key)后,通过key拿到RequestEntry中的handler,根据requestClazz反序列化出对应的RequestData,再统一校验,调用validate钩子方法。validateRequestData可以做在RequestHandler抽象类中,让具体开发接口的同学无需关心如何调用validate方法。此处设计不在本次课程中。
class RequestEntry {
    final RequestHandlerhandler;
    final Class<? extendsApiRequestData> requestClazz;
    final Class<? extendsApiResponseData> responseClazz;
    // ...
  }
private final Map<String, RequestEntry> map = Maps.newHashMap();
  @Autowired
public void initHandlers(RequestHandler<? extends ApiRequestData,
  ? extends ApiResponseData>[]) {
    // 把每一个RequestHandler解析成RequestEntry,key可以是包名加类名的分割,如
    /api/com/abc/xyx/createProduct
  }
}

在关于接口相关的防御式编程中,除了要校验系统外部数据的格式和合法性之外,有的校验还需要上下文的支持。

例如,在我们这一讲的例子中,除了常规参数校验,还需要校验 storeId 的逻辑关系。这句话怎么理解?当商户端在后台登录时,登录态中可以拿到当前商家信息。接口提交上来的 storeId,必须是当前商家所属的 Store,因为肯定不能创建其他商家的产品。这种校验,其实很多同学都会忽略,但又是非常重要的。假如不校验,有人绕过界面调用接口,传入其他 storeId,就会引发服务器 Bug,造成应用数据被写脏。这种错误校验也属于 ApiException,我们认为也是客户端 Bug。

正确的校验命令,你可以参考这段代码:

scss 复制代码
public void createProduct(Product product) {
  Asserts.assertTrue("只能创建自己的商品:" + product.getStoreId(), product.getStoreId() == RequestContext().getSession().getStoreId());
  //... 产品信息入库逻辑
}

当然了,在实际的工作中,还有很多情况是需要结合上下文来校验前端参数的。比如删除文章这个接口,在产品设计上,我们只能删除自己的文章,如果客户端提交上来的文章作者是其他用户,你没有经过校验就做删除,那就是不对的。在我的职业经历中,无论是大型互联网公司,还是创业公司,很多业务代码都没有这种防御处理,完全信任客户端的入参,这种做法非常危险。所以,服务器对于这种在异常交互下提交上来的参数,必须做检查。我们考虑得越全面,写出来的代码就越健壮,应用数据越安全。

我用一张思维导图给你总结一下讲的内容。对于接口的防御式编程处理方式呢,我们首先要进行契约检查,也就是数据格式、定义是不是合法的,这是最基本的校验要素。其次,还要检查接口入参有没有逻辑上的 Bug,这需要结合你当前的具体业务来判断,因为接口本质上只是一个输入输出,不应该和具体的界面联系起来。

相关推荐
朝新_1 小时前
【多线程初阶】阻塞队列 & 生产者消费者模型
java·开发语言·javaee
立莹Sir1 小时前
Calendar类日期设置进位问题
java·开发语言
XMYX-02 小时前
Spring Boot + Prometheus 实现应用监控(基于 Actuator 和 Micrometer)
spring boot·后端·prometheus
季鸢3 小时前
Java设计模式之状态模式详解
java·设计模式·状态模式
@yanyu6663 小时前
springboot实现查询学生
java·spring boot·后端
ascarl20103 小时前
准确--k8s cgroup问题排查
java·开发语言
magic 2453 小时前
Lombok 的 @Data 注解失效,未生成 getter/setter 方法引发的HTTP 406 错误
java
爱敲代码的憨仔4 小时前
分布式协同自动化办公系统-工作流引擎-流程设计
java·flowable·oa
酷爱码4 小时前
Spring Boot项目中JSON解析库的深度解析与应用实践
spring boot·后端·json
纪元A梦4 小时前
分布式拜占庭容错算法——PBFT算法深度解析
java·分布式·算法