一个Result Monad思考

1 阅读7分钟

关于写代码的一个思考

写代码,最舒心的事肯定是用户使用我们软件的方式,一定是按照我们想好的方式去执行的。我们代码只要按照一条正确的路往下走就可以了。然而实际情况是用户总是像在马路上做布朗运动的小朋友,根本没有规律。记得刚工作的时候,领导和我说,别管别人给你传的参数可不可能空,先判空。后来我知道福尔摩斯里的一句名言,“当你排除了一切看似不可能的情况后,剩下的,无论多么难以置信,都必然是真相。”。一个健壮的代码也应该是排除掉所以错误的路径,留下的才是正确的。所以我一般来说,写代码的习惯是最后一行返回正确的结果。

如何管理错误

一般来说,也就是两种办法,制定好规则限制或者容忍某些不危险的行为。现代软件,复杂度是非常高的,必需涉及协作(不仅是多人,也是自己和自己的协作,因为我大概率会忘记一个月前写的代码,所以这里有一个当下的我如何与一个月前的我协作的问题),基本会使用拆分逻辑段。那么逻辑段之间如何传递错误就是一个必须要考虑的一个问题了。 代码写多了总要处理各种错误,但是大部分情况下处理错误都是

if(是错误){
    log.error();
    return;
}

元组管理

这种代码写多了以后,会比较烦躁,太机械化。 作为Java程序员还有一种正统的方式就是使用Exception。代码差不多,无非是if变成了Try。 一开始呢,发现了commons-lang3中提供的元组Pair。当时就觉得这玩意不就是可以作为规范化返回错误类型吗?(这里涉及了Result是不是可以下探到Service的问题,后面我继续说)左值放错误的结果,右值放正确的结果,所以我的Service接口一度如下图:

image.png

但是元组这东西吧,本质不是这么用的。而且我强行这么用的时候的话,只是规范Service如何传递错误(除原生的Exception),然后让调用方必须强制的处理异常情况,否则你拿不到你要的值。本质上我要写的if依然还在。

Either管理

还是比较烦躁。后来就开始学习FP编程,接触到了函数式编程的中的vavr库,也接触了里面的Either。Either这东西,作为一种要么是A,要么是B的一种Monad。关于Monad,一个著名的笑话就是一个单子(Monad)说白了不过就是自函子范畴上的一个幺半群而已,这有什么难理解的?。关于Monad是干什么的,其实在写这篇之前,应该先写Monad是什么,不过因为某些原因(后面讲),我还是先写这篇了。而且网上写Monad是什么的一大堆。不过Java中并没有实现纯正Monad的方式,只能是和类型结合的FlatMap,后面如果写Monad相关的文章,再细写。 不管怎么说,Either基本满足了我对一个错误管理的需求,规范了错误管理(Exception也做到了)。抽象了if判断(函数式编程特有的魅力,令人着迷)。举个例子,如果我使用Pair管理,那么处理Pair错误信息的代码是这样的:

Pair<String, String> result = xxxxService.rename(id, name,userId);
return StringUtils.isEmpty(result.getLeft()) ? Result.success(result.getRight()) : Result.fail(result.getLeft());   

如果我使用Either,那么代码是这样的:

Either<String, String> result = xxxxService.rename(id, name,userId);
return result.fold(Result::fail, Result::success);

当我连续处理错误信息的时候,我可以使用flatMap。像这样

Either<String, String> resultA = xxxxService.getLoginUserId();
return resultA.flatMap(userId->xxxxService.rename(id, name,userId)).fold(Result::fail, Result::success);

或者当我需要批量处理数据,短路错误的时候是这样的:

var resultA = ids.stream()
    .map(id -> xxxxService.rename(id, name,userId))
    .toList();
return Either.sequenceRight(resultA)
                .map(Value::toJavaList)
                .fold(Result::fail, Result::success);

接下来

接下来,我在项目中开始充分实践Either,于是我的接口开始变成以下这样

image.png

很自然的,你就会发现,left总是String,或者如果需要,你可以定义一个ErrorDTO放在left。但是不管如何,你这个left总是固定的类型,固定的错误类型。所以实际我们可以创建一个Result这种类型的Moand。好了,看上去和传统的Result似乎是一个东西。但实际上已经是不同的东西了。新的Result抽象了if判断,代表了某种运算规则,某种行为(如果是错误的,如何如何-mapError,如果是正确的如何如何-map)。而旧的Result则是一种数据结构,不包含行为。我们再为这个Result写一个Jackson序列化器(如果使用JSON数据响应前端)。那么我们这个新Result也就可以使用在Controller中了。 接下来我在掘金中看到了这么一篇文章《Spring 项目别再乱注入 Service 了!用 Lambda 封装个统一调用组件,爽到飞起》 文章的内容。抛开争议的部分,他这个工具包装错误日志的想法,那其实我们的新Result类中,也可以把错误信息写入到日志中。这样我们就可以得到一个抽象了if判断和抽象了日志写入的Result Monad。这也是我在这文章里评论里说的,我也想写一个这个东西的来源。

倒反天罡

主要是前段时间,总是刷到一堆为什么Result不能写在Service的文章,我觉得大家也都刷到了。不过我倒是不担心我这个Result,毕竟我这个Result和他们说的Result,只是名字一样。直到我刷到了这篇文章《DDD学习实践:Service层应该返回Result吗?》。好吧,我对DDD了解不够深入。但是作者的思路,一个OperationResult<E, T> 本质就是Either。但是我在讨论中突然意识到一个问题,如果说旧的Result是从Controller侵入到了Service,这是一个错误的设计,那么我的新Result补充对应的Jackson序列化器后,是否是Service的Result入侵到了Controller,在DDD中这种设计有问题吗?还是说就应该这么设计?

对Result Monad的迷思

实际上Result Monad非常好写,我把vavr 的Either丢给AI,然后把我的想法告诉AI,让AI仿写一个Result Monad就行了。但是我现在困惑了。

第一:从实际代码运行效果看if判断只是被抽象了,并没有消失,它的代码简洁度,是通过逻辑的深度换来的,这就像我只是把广度优先改为了深度优先。是的代码是简洁了,但是使用者的需要的前置知识却多了,我能和三个月前的我保持同一个前置知识,但是我怎么和不停流动的同事保持?当然这个新Result可以同时兼容老的Result API接口。但是从另一角度来说,现如今AI在接手代码编写的现在,Result是Monad还是数据结构,有丁点区别吗?可能消耗的token不一样。写这玩意,我是为了炫技给AI看?越想越难受。

第二:Result这玩意从来只和公司的代码规范挂钩,它根本不是一个通用的东西,它不是一个可以超越项目代码规范的东西。Either是,但是Result不是,它不通用。甚至我们公司里的内部不同项目的Result都没有实现统一。 这东西,最后只会变成个别人的自嗨。实际上在AI下,函数式编程,都像是一部分人的自嗨。 不写了,越写越难受。