如何避免写流水账代码?

在日常工作中我观察到,面对老系统重构和迁移场景,有大量代码属于流水账代码,通常能看到开发在对外的API接口里直接写业务逻辑代码,或者在一个服务里大量的堆接口,导致业务逻辑实际无法收敛,接口复用性比较差。所以本文主要想系统性的解释一下如何通过DDD的重构,将原有的流水账代码改造为逻辑清晰、职责分明的模块。

一  案例简介

这里举一个简单的常见案例:下单链路。假设我们在做一个checkout接口,需要做各种校验、查询商品信息、调用库存服务扣库存、然后生成订单:

一个比较典型的代码如下:


@RestController
@RequestMapping("/")
public class CheckoutController {

    @Resource
    private ItemService itemService;

    @Resource
    private InventoryService inventoryService;

    @Resource
    private OrderRepository orderRepository;

    @PostMapping("checkout")
    public Result<OrderDO> checkout(Long itemId, Integer quantity) {
        // 1) Session管理
        Long userId = SessionUtils.getLoggedInUserId();
        if (userId <= 0) {
            return Result.fail("Not Logged In");
        }
        
        // 2)参数校验
        if (itemId <= 0 || quantity <= 0 || quantity >= 1000) {
            return Result.fail("Invalid Args");
        }

        // 3)外部数据补全
        ItemDO item = itemService.getItem(itemId);
        if (item == null) {
            return Result.fail("Item Not Found");
        }

        // 4)调用外部服务
        boolean withholdSuccess = inventoryService.withhold(itemId, quantity);
        if (!withholdSuccess) {
            return Result.fail("Inventory not enough");
        }
      
        // 5)领域计算
        Long cost = item.getPriceInCents() * quantity;

        // 6)领域对象操作
        OrderDO order = new OrderDO();
        order.setItemId(itemId);
        order.setBuyerId(userId);
        order.setSellerId(item.getSellerId());
        order.setCount(quantity);
        order.setTotalCost(cost);

        // 7)数据持久化
        orderRepository.createOrder(order);

        // 8)返回
        return Result.success(order);
    }
}

为什么这种典型的流水账代码在实际应用中会有问题呢?其本质问题是违背了SRP(Single Responsbility Principle)单一职责原则。这段代码里混杂了业务计算、校验逻辑、基础设施、和通信协议等,在未来无论哪一部分的逻辑变更都会直接影响到这段代码,当后人不断地在上面叠加新的逻辑时,会使代码复杂度增加、逻辑分支越来越多,最终造成bug或者没人敢重构的历史包袱。

所以我们才需要用DDD的分层思想去重构一下以上的代码,通过不同的代码分层和规范,拆分出逻辑清晰,职责明确的分层和模块,也便于一些通用能力的沉淀。

主要的几个步骤分为:

  • 分离出独立的Interface接口层,负责处理网络协议相关的逻辑。

  • 从真实业务场景中,找出具体用例(Use Cases),然后将具体用例通过专用的Command指令、Query查询、和Event事件对象来承接。

  • 分离出独立的Application应用层,负责业务流程的编排,响应Command、Query和Event。每个应用层的方法应该代表整个业务流程中的一个节点。

  • 处理一些跨层的横切关注点,如鉴权、异常处理、校验、缓存、日志等。

下面会针对每个点做详细的解释。

二  Interface接口层

随着REST和MVC架构的普及,经常能看到开发同学直接在Controller中写业务逻辑,如上面的典型案例,但实际上MVC Controller不是唯一的重灾区。以下的几种常见的代码写法通常都可能包含了同样的问题:

  • HTTP 框架:如Spring MVC框架,Spring Cloud等。

  • RPC 框架:如Dubbo、HSF、gRPC等。

  • 消息队列MQ的“消费者”:比如JMS的 onMessage,RocketMQ的MessageListener等。

  • Socket通信:Socket通信的receive、WebSocket的onMessage等。

  • 文件系统:WatcherService等。

  • 分布式任务调度:SchedulerX等。

这些的方法都有一个共同的点就是都有自己的网络协议,而如果我们的业务代码和网络协议混杂在一起,则会直接导致代码跟网络协议绑定,无法被复用。

所以,在DDD的分层架构中,我们单独会抽取出来Interface接口层,作为所有对外的门户,将网络协议和业务逻辑解耦。

1  接口层的组成

接口层主要由以下几个功能组成:

  • 网络协议的转化:通常这个已经由各种框架给封装掉了,我们需要构建的类要么是被注解的bean,要么是继承了某个接口的bean。

  • 统一鉴权:比如在一些需要AppKey+Secret的场景,需要针对某个租户做鉴权的,包括一些加密串的校验

  • Session管理:一般在面向用户的接口或者有登陆态的,通过Session或者RPC上下文可以拿到当前调用的用户,以便传递给下游服务。

  • 限流配置:对接口做限流避免大流量打到下游服务

  • 前置缓存:针对变更不是很频繁的只读场景,可以前置结果缓存到接口层

  • 异常处理:通常在接口层要避免将异常直接暴露给调用端,所以需要在接口层做统一的异常捕获,转化为调用端可以理解的数据格式

  • 日志:在接口层打调用日志,用来做统计和debug等。一般微服务框架可能都直接包含了这些功能。

当然,如果有一个独立的网关设施/应用,则可以抽离出鉴权、Session、限流、日志等逻辑,但是目前来看API网关也只能解决一部分的功能,即使在有API网关的场景下,应用里独立的接口层还是有必要的。

在Interface层,鉴权、Session、限流、缓存、日志等都比较直接,只有一个异常处理的点需要重点说下。

2  返回值和异常处理规范,Result vs Exception

注:这部分主要还是面向REST和RPC接口,其他的协议需要根据协议的规范产生返回值。

在我见过的一些代码里,接口的返回值比较多样化,有些直接返回DTO甚至DO,另一些返回Result。

接口层的核心价值是对外,所以如果只是返回DTO或DO会不可避免的面临异常和错误栈泄漏到使用方的情况,包括错误栈被序列化反序列化的消耗。所以,这里提出一个规范:

Interface层的HTTP和RPC接口,返回值为Result,捕捉所有异常

Application层的所有接口返回值为DTO,不负责处理异常

Application层的具体规范等下再讲,在这里先展示Interface层的逻辑。

举个例子:


@PostMapping("checkout")
public Result<OrderDTO> checkout(Long itemId, Integer quantity) {
    try {
        CheckoutCommand cmd = new CheckoutCommand();
        OrderDTO orderDTO = checkoutService.checkout(cmd);    
        return Result.success(orderDTO);
    } catch (ConstraintViolationException cve) {
        // 捕捉一些特殊异常,比如Validation异常
        return Result.fail(cve.getMessage());
    } catch (Exception e) {
        // 兜底异常捕获
        return Result.fail(e.getMessage());
    }
}

当然,每个接口都要写异常处理逻辑会比较烦,所以可以用AOP做个注解:


@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ResultHandler {

}

@Aspect
@Component
public class ResultAspect {
    @Around("@annotation(ResultHandler)")
    public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
        Object proceed = null;
        try {
            proceed = joinPoint.proceed();
        } catch (ConstraintViolationException cve) {
            return Result.fail(cve.getMessage());
        } catch (Exception e) {
            return Result.fail(e.getMessage());
        }
        return proceed;
    }
}

然后最终代码则简化为:


@PostMapping("checkout")
@ResultHandler
public Result<OrderDTO> checkout(Long itemId, Integer quantity) {
    CheckoutCommand cmd = new CheckoutCommand();
    OrderDTO orderDTO = checkoutService.checkout(cmd);
    return Result.success(orderDTO);
}
  • 38
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
好的,我明白了。这是一个关于资金账户管理的程序,需要实现创建账户、添加记录、显示记录、查询余额等功能。同时,还需要定义并调用Update Log()函数以便修改已有记录。账户文件名为cashbox.dat。 针对您的问题,我可以回答如下: 首先,需要定义一个结构体来表示资金账户记录,包括记录ID、发生日期、发生事件、发生金额和余额等信息。具体定义如下: ``` struct Record { int id; // 记录ID char date[20]; // 发生日期 char event[100]; // 发生事件 double amount; // 发生金额 double balance; // 余额 }; ``` 然后,在程序中需要实现以下功能: 1. 创建账户文件并添加记录:可以定义一个函数createRecord(),根据用户输入添加记录到文件中,并计算余额。具体实现如下: ``` void createRecord() { FILE *fp; Record record; fp = fopen("cashbox.dat", "a+"); fseek(fp, 0, SEEK_END); // 定位到文件结尾 if (ftell(fp) == 0) { // 文件为空,添加第一条记录 record.id = 1; record.balance = 0; } else { fseek(fp, -sizeof(Record), SEEK_END); // 定位到最后一条记录 fread(&record, sizeof(Record), 1, fp); // 读取最后一条记录 record.id++; // ID自增1 } printf("请输入发生日期(格式如:2022-01-01):"); scanf("%s", record.date); printf("请输入发生事件:"); scanf("%s", record.event); printf("请输入发生金额:"); scanf("%lf", &record.amount); record.balance += record.amount; // 计算余额 fwrite(&record, sizeof(Record), 1, fp); // 将记录入文件 fclose(fp); printf("记录添加成功!\n"); } ``` 2. 显示所有记录:可以定义一个函数showAllRecords(),遍历文件中的所有记录,并输出到屏幕上。具体实现如下: ``` void showAllRecords() { FILE *fp; Record record; fp = fopen("cashbox.dat", "rb"); if (fp == NULL) { printf("打开文件失败!\n"); return; } printf("记录ID\t发生日期\t\t发生事件\t\t发生金额\t余额\n"); while (fread(&record, sizeof(Record), 1, fp) == 1) { printf("%d\t%s\t%s\t%.2lf\t%.2lf\n", record.id, record.date, record.event, record.amount, record.balance); } fclose(fp); } ``` 3. 查询最后一条记录:可以定义一个函数getLastRecord(),读取文件中的最后一条记录,并输出到屏幕上。具体实现如下: ``` void getLastRecord() { FILE *fp; Record record; fp = fopen("cashbox.dat", "rb"); if (fp == NULL) { printf("打开文件失败!\n"); return; } fseek(fp, -sizeof(Record), SEEK_END); // 定位到最后一条记录 fread(&record, sizeof(Record), 1, fp); // 读取最后一条记录 printf("最后一条记录:\n"); printf("记录ID\t发生日期\t\t发生事件\t\t发生金额\t余额\n"); printf("%d\t%s\t%s\t%.2lf\t%.2lf\n", record.id, record.date, record.event, record.amount, record.balance); fclose(fp); } ``` 4. 修改已有记录:可以定义一个函数updateRecord(),根据用户输入的记录ID,找到对应的记录并进行修改。具体实现如下: ``` void updateRecord() { FILE *fp; Record record; int id, found = 0; fp = fopen("cashbox.dat", "rb+"); if (fp == NULL) { printf("打开文件失败!\n"); return; } printf("请输入要修改的记录ID:"); scanf("%d", &id); while (fread(&record, sizeof(Record), 1, fp) == 1) { if (record.id == id) { found = 1; printf("请输入发生日期(格式如:2022-01-01):"); scanf("%s", record.date); printf("请输入发生事件:"); scanf("%s", record.event); printf("请输入发生金额:"); scanf("%lf", &record.amount); record.balance += record.amount; // 重新计算余额 fseek(fp, -sizeof(Record), SEEK_CUR); // 定位回原位置 fwrite(&record, sizeof(Record), 1, fp); // 将修改后的记录回文件 break; } } if (!found) { printf("未找到对应的记录!\n"); } else { printf("记录修改成功!\n"); } fclose(fp); } ``` 最后,在主函数中可以通过一个循环来实现菜单选择,具体代码如下: ``` int main() { int choice; do { printf("\n1. 创建账户并添加记录\n"); printf("2. 显示所有记录\n"); printf("3. 查询最后一条记录\n"); printf("4. 修改已有记录\n"); printf("5. 退出程序\n"); printf("请选择操作(输入数字1-5):"); scanf("%d", &choice); switch (choice) { case 1: createRecord(); break; case 2: showAllRecords(); break; case 3: getLastRecord(); break; case 4: updateRecord(); break; case 5: printf("程序已退出!\n"); break; default: printf("输入有误,请重新选择!\n"); break; } } while (choice != 5); return 0; } ```

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值