一个控制器一个Action - Janos Pasztor


你在控制器中放了多少个动作Action?5-6?20?如果我告诉你我的限制只能是一种Action方法,你会怎么说?
可以肯定地说,大多数Web应用程序在其控制器中都有太多的Action操作方法,但它很快就会失去控制,违背单一责任原则违规行为。我一直在和朋友谈论这个问题,他们建议在一个控制器类中只放一个动作Action 的方法可能就是解决这个问题的方法。听起来很荒谬,让我们按照这条路走一会儿。

一个控制器......
构建控制器的一种非常流行的方法是沿着CRUD(Create-Read-Update-Delete)的分离。如果我们要编写一个非常简单的API来处理BlogPost遵循这种方法的实体,我们会得到这样的结果:

class BlogPostController {
    @Route(method="POST", endpoint="/blogposts")
    public BlogPostCreateResponse create(String title
/*...*/) {
       
//...
    }

    @Route(method=
"GET", endpoint="/blogposts")
    public BlogPostListResponse list() {
       
//...
    }

    @Route(method=
"GET", endpoint="/blogposts/:id")
    public BlogPostGetResponse get(String id) {
       
//...
    }
    
    @Route(method=
"PATCH", endpoint="/blogposts/:id")
    public BlogPostUpdateResponse update(String id, String title
/*...*/) {
       
//...
    }

    @Route(method=
"DELETE", endpoint="/blogposts/:id")
    public BlogPostDeleteResponse delete(String id) {
       
//...
    }
}

从表面上看,这看起来很好,因为与BlogPost实体相关的所有功能都组合在一起。但是,我们留下了一部分:构造函数。如果我们使用依赖注入(我们真的应该),我们的构造函数必须声明所有依赖项,如下所示:

class BlogPostController {
    private UserAuthorizer userAuthorizer;
    private BlogPostBusinessLogic blogPostBusinessLogic;
    
    public BlogPostController(
        UserAuthorizerInterface userAuthorizer,
        BlogPostBusinessLogicInterface blogPostBusinessLogic
    ) {
        this.userAuthorizer        = userAuthorizer;
        this.blogPostBusinessLogic = blogPostBusinessLogic;
    }
    
    /* ... */
}

现在,让我们写一个测试。你测试你的应用程序,对吧?首先,我们测试get方法:

class BlogPostControllerTest {
    @Test
    public void testGetNonExistentShouldThrowException() {
        BlogPostController controller = new BlogPostController(
            //get does not need an authorizer
            null,
            new FakeBlogPostBusinessLogic()
        );
        
       
//Do the test
    }
}

等等......你看到了吗?构造函数的第一个参数是null。你可能在想,那又怎样?但这非常重要:null表示您的控制器的get() 方法不需要的依赖项。

如果是这种情况,您将违反单一责任原则,因为您可以删除该依赖项而不影响该get()方法的功能。

确实,单一责任是在业务意义上定义的,而不是在编码意义上定义,但如果您遵循CRUD设置,那么您在商业意义上也可能违反SRP。

单一责任原则:一个class应该只有一个改变的理由。

一个Action动作
当我开始用这种实现来查看我的代码时,我不得不承认:在查找SRP违规时,CRUD风格更应该进行仔细检查。
所以,我提出了一个激进的解决方案:一个控制器,一个动作。重构后,我们的代码如下所示:

class BlogPostGetController {
    private BlogPostBusinessLogicInterface blogPostBusinessLogic;
    
    public BlogPostGetController(
        BlogPostBusinessLogicInterface blogPostBusinessLogic
    ) {
        this.blogPostBusinessLogic = blogPostBusinessLogic;
    }
    
    @Route(method="GET", endpoint="/blogposts/:id")
    public BlogPostGetResponse get(String id) {
       
//...
    }
}

简单,包装精美,最重要的是:责任不再是单一的。但等等,还有更多!看看BlogPostBusinessLogicInterface。从API来看,还必须有一些公平的方法。有一个叫做接口隔离原理的东西。

接口隔离原则:不应强制客户端(调用者)依赖它不使用的方法。

如果我们想要坚持这个原则,我们需要将该接口分为BlogPostGetBusinessLogicInterface 几个。然后,实现可能如下所示:

class BlogPostBusinessLogicImpl
    implements
        BlogPostGetBusinessLogicInterface,
        BlogPostCreateBusinessLogicInterface,
        /* ... */ {
        
   
/* ... */
}

但是,这个类可能会遇到与我们的控制器相同的问题:它是单一责任原则违规的体现。获取博客文章和创建博客文章的业务逻辑根本不同。

为了解决这个问题,我们可以采用与控制器相同的方法:将(大概数千行)BlogPostBusinessLogicImpl拆分成整齐打包的单方法类。

然后我们继续进入数据存储层,并在那里发现同样的事情。所以我们分割接口以及实现本身。
如果我们遵循这个逻辑,你最终得到的应用程序被切割成只有一个动作的类。但是,虽然我们正在努力,但我们可以进一步推动事情。

这是......函数性的吗?!
如果你稍微眯一眼就会看到一个奇怪的模式出现:我们的构造函数的唯一目的是在实例变量中存储传入的依赖项,在我们的例子中是blogPostBusinessLogic对象。blogPostBusinessLogic本身也是一个具有单个函数的类实例,它将在执行期间由操作使用。

正如我们将在本节中看到的,只有一个构造函数和一个方法的类与函数式编程中使用的两个概念的组合非常相似:高阶函数和currying。

高阶函数是一个采用了一种不同的函数作为参数。JavaScript中的一个简单示例如下所示:

//foo gets bar (a function) as a parameter for execution
function foo (bar) {
   
//The function stored in the variable bar is executed and the result returned
    return bar();
}

Currying 就是我们将一个带有两个参数的函数拆分成一个带有一个参数的函数,该参数再次返回第二个函数,这第二个函数也还是一个参数。

无Curring

function add (a, b) {
    return a + b;
}
//yields 42
add(32, 10);

变成Curring:

function add (a) {
    return function(b) {
        return a + b;
    }
}
//yields 42
intermediate = add(32);
final = intermediate(10);


Currying允许更多的关注点分离,因为第一次调用可以完全独立于第二次调用。

加入更高阶函数和currying,我们以前的Java代码可以用函数式Javascript重写,如下所示:

/**
 * This is the constructor, which is receiving the dependencies.
 * 
 * @param {function} blogPostGetBusinessLogicInterface
 */

function BlogPostGetController(blogPostGetBusinessLogicInterface) {
   
/**
     * This is the actual action method. 
     * 
     * @param {string} id
     */

    return function(id) {
       
//Call the business logic passed in the outer function.
       
//(analogous to the getById method)
        return blogPostGetBusinessLogicInterface(id)
    }
}

如果仔细观察,函数风格的Javascript和OOP Java实现具有相同的功能,即从业务逻辑中获取博客文章并将其返回。
所以从本质上讲,每个控制器只有一个动作使我们更接近编写函数代码,因为单方法类几乎完全符合高阶函数的行为。您仍然可以继续编写OOP代码并使用函数式编程的一些有益方面。
但我们可以更进一步,我们实际上可以使我们的Java代码纯净。(纯函数中没有可变状态。)为了实现这一点,我们声明所有变量,final以便在设置后不能修改它们:

class BlogPostGetController {
    private final BlogPostGetBusinessLogicInterface blogPostGetBusinessLogic;
    
    public BlogPostGetController(
        final BlogPostGetBusinessLogicInterface blogPostGetBusinessLogic
    ) {
        this.blogPostGetBusinessLogic = blogPostGetBusinessLogic;
    }
    
    @Route(method="GET", endpoint="/blogposts/:id")
    public BlogPostGetResponse get(final String id) {
       
//All variables here should be final
        return new BlogPostGetResponse(
            blogPostGetBusinessLogic.getById(id) 
        );
    }
}

正在用Java进行函数式编程!嗯,无论如何,或多或少。函数风格的编程不会让您的代码神奇地变得更好。你仍然可以编写长达数千行的方法,但这只会有点困难。

这不是OOP与FP
互联网上的许多讨论似乎都是OOP是函数式编程的致命敌人,FP既是编程的未来,也是时髦的时尚,取决于你倾听哪一方。
然而,事实是OOP和FP相处得很好。面向对象为您提供了结构,而函数式编程为您提供了不变性并且更容易测试代码。
一个控制器一个动作范例,当与不变性结合时,在我看来导致OOP和FP的有益混合。