Py学习  »  Python

Python中的类型化函数依赖注入

Python程序员 • 3 年前 • 400 次点击  

依赖注入是一个有争议的话题。关于如何使用DI框架,有一些已知的问题、技巧乃至一整套相关的方法论。


很多人问我:如何将许多类型化的函数概念与传统的面向对象依赖注入结合使用?


这个问题很有意义。因为函数式编程都是关于组合的。依赖注入只是个魔术。你会在代码的某些地方注入一些几乎随机的对象,之后,被注入的整个容器在运行时由各个部分神奇地组装而成。


这导致了与函数声明式风格的对立,你可能会感叹“我的天,这个类是从哪里来的?”

 

今天我们要用一种旧式的方法来解决这个问题。


常规函数


假设您有一个基于django的游戏,在这个游戏中,您为用户猜中的字母奖励分数(未猜到的字母标记为“.”):


以下是您的业务逻辑:


这是一个非常简单的应用程序:用户尝试猜测一些单词,同时,我们为每个猜中的字母加分。但是,我们有一个门槛。我们只对猜中6次或更多字母的情况给予6分或6分以上的奖励(猜中少于6次不奖励)。就这样。我们有框架层和我们优雅纯粹的逻辑。这段代码很容易阅读和修改。


就这样?我们试着修改一下看看。假设我们想通过设置点数阈值使我们的游戏更具挑战性。我们怎么能做到?


天真的尝试


好的,让我们使_award_points_for_letters接受第二个参数,阈值:int:


现在你的代码类型检查会失败。因为我们的调用就是这样的:


若要修复此calculate_points函数(以及所有其他上调用方函数),必须接受threshold:int作为参数,并将其传递给_award_points_for_letters,如下所示:


它还会影响我们的views.py:


我们可以看到这是可行的。但是,为了单个参数它需要更改整个调用堆栈。这就引出了一些明显的问题:


如果我们的调用堆栈很大,我们将不得不修改很多层来实现我们想要的功能,为其中每一层都增加了一个参数。我们所有的函数和类都会被我们传递的值所影响。这将有几个效果:您将无法理解哪些函数和类真正需要传递值,哪些只是作为传输层工作(如我们示例中的calculate_points)。当然,人们会在需要的地方开始使用这些传递的值。这将隐式地增加代码和环境的耦合。


很难将函数和类组合在一起。这个额外的参数总是会碍事的。函数的个数不匹配。


总而言之:这是可行的,但不是一个可扩展的解决方案。但是,在一个小程序中我可能会这么做。


框架


现在很多Django用户可能会问:为什么不像Django docs教我们的那样导入设置并使用它呢?


因为这太丑陋了!


目前,我们有独立于框架的业务逻辑。这是件好事。它免疫任何框架级别的更改。而且它也没有隐式地带来框架所有的复杂性。让我们看下django.conf.settings是如何工作的:

1.我们需要设置DJANGO_SETTINGS_MODULE环境变量来查找配置模块

2.在框架的某处会调用django.setup()

3.django将导入此模块

4.此模块可能包含依赖于设置的逻辑

5.设置还将访问环境、文件,甚至是云服务来获取一些特定值

6.然后django将拥有几乎不可变的单例对象,您可以从代码中的任何位置导入该对象

7.您必须在测试中模拟配置文件,以便将其他值传递给您的函数。或者多个(以及更多)值,如果你严重依赖配置模块(可能在任何应用程序中都是这样)

值得吗?也许吧。有时django.settings足够好了。可能只有几个基本值需要注入。这样做可以很快完成。


但是,如果你真的想构建一个边界清晰、领域逻辑全面的大型应用程序,我不建议任何人这么做。我甚至建议在您的逻辑中使用import linter来禁止从django导入任何内容。


那么,如果没有什么好用的方法,我如何将这个麻烦的值传递给函数呢?参数太嘈杂,容器太神奇,很难使用,全局设置只是披着单例形式外衣的纯粹邪恶?


组成


程序员是聪明人。真正的。他们可以用纯函数做任何事情。


他们还以优雅的方式解决了依赖性问题,同时保持了简单性。函数式依赖注入的核心思想是,我们不调用依赖于我们没有的上下文的东西。相反,我们安排他们稍后再调用。让我们看看,在采用这种想法之后,我们原来的逻辑将如何改变:


请注意,我们现在如何将factory传递到应用程序的顶层。另外,还要特别注意我们的新Deps协议:它允许我们使用结构子类型来定义所需的API。换句话说,具有WORD_THRESHOLD int属性的所有对象都将通过检查(要强制执行django设置,请使用django stubs 类型检查)。


只剩下从最上面调用返回的工厂函数:

看起来很简单!我们的所有要求都得到满足:

1.我们没有魔法,真的是零

2.所有东西都输入好了

3.我们的逻辑仍然是纯粹的,独立于框架


我们现在唯一的问题是我们的逻辑结构不好。让我用一个新的要求来说明我的观点。在假期里,我们的比赛可能会随机给最后的比分加一分。下面是我们如何调整源代码以满足新的需求:


但是,awarded_points具有Callable[[[u Deps],int]类型,不能用这个新函数轻松组合。当然,我们可以在_maybe_add_extra_holiday_point里创建一个新的函数,只是为了组合:


但是这么做好吗?我希望大多数人会同意我的看法,不好。


让我们记住,函数式程序员是聪明人。他们可以用纯函数做任何事情。包括组合代码。这就是为什么他们提出了Reader monad(或者我们称之为RequiresContext容器,以防在Python 世界中太晦涩):它是这种特定情况下的组合助手。让我们再次重构代码,看看它是如何工作的:


我们已经更改了函数的返回类型,还添加了awarded_points.map(_maybe_add_extra_holiday_point)。这是另一种用法“用这个纯函数_maybe_add_extra_holiday_point组合RequiresContext容器”。我们根本不改变框架层。


它是如何工作的?

   1.当我们调用calculate_points(user_words)时,它实际上并没有开始执行任何操作,它只返回稍后要调用的requireContext容器

   2.容器足够聪明,可以理解.map方法。它记得在执行之后需要调用maybe_add_extra_holiday_point函数

   3.当我们以calculate_points(user_words)(settings)的形式向容器添加上下文时,def factory(deps:_deps)将执行并返回等待已久的值

    4.然后_may_add_extra_holiday_point执行并返回最终值


就这样。没有混乱,没有魔法,没有框架内部。但是类型和、组合且简单。



透明依赖项


如果您还想将表示未猜中的字母(当前为”.”)的符号更改为可配置的,该怎么办?有些用户更喜欢”.”,有些用户喜欢”_”。好吧,我们能做到,不是吗?

在这一步中,新手可能会有一些疑惑。因为deps只可以在“_award_points_for_letters”内使用,而不能在“calculate_points”内使用。又要通过组合的方式。对于这种情况,我们有一个特殊的组合助手:Context.ask(),它从当前上下文获取依赖项,并允许我们在需要时显式地使用它:

这里有两件事要提:

   1.Context.ask()需要用_Deps显式地标注,因为mypy不能在这里推断类型

   2.bind方法也是一个组合实用程序。与.map相比,.map使用纯函数组成容器,.bind允许我们使用返回相同类型容器的函数组成容器


现在,我们的所有代码共享相同的不可变只读上下文。


静态类型


静态类型不仅仅是意外地尝试将字符串添加到整数中。它与你复杂系统的每一部分的架构和规则有关。


很多读者可能已经发现了我在这个例子中留下的一个陷阱。让我们揭示一个丑陋的事实:_maybe_add_extra_holiday_point并不纯粹。如果没有随机模拟,我们就无法测试它。因为它是不纯粹的,它的类型是Callable[[int], IO[int]]。


等等,这是什么?当我们谈论好的架构和高质量的编码规则时,IO是您最好的朋友。它是一个明确的标记,表明您的函数不是纯函数。如果没有这种类型,我们可以自由编写难看、不稳定的代码。我们没有任何协议要遵守。我们可以为混乱祈祷。


另外,不要忘记,有些函数可能最终会成功执行,也可能失败。在业务逻辑中抛出异常是很难看的。它还从您的架构中移除显式协议。这是野蛮人的做法。如果出了问题,我们必须做好准备。这就是为什么很多人用结果来表示事物可以(也会!)出错了。


所有这两种类型都与RequiresContext容器密切相关。因为它的返回类型可能很复杂:

  • RequiresContext[EnvType,ReturnType]表示我们使用的是一个不能失败的纯函数(就像我们的示例中没有random)

  • RequiresContext[EnvType,Result[ValueType,ErrorType]]表示我们使用的纯函数可能会失败(例如,当我们将/和0一起使用或任何逻辑失败时)

  • RequiresContext[EnvType,IO[ReturnType]]表示我们使用了一个不能失败的不纯粹函数(就像我们使用random的例子)

  • RequiresContext[EnvType,IO[Result[ValueType,ErrorType]]表示我们使用了一个可能失败的不纯粹函数(如HTTP、文件系统或数据库调用)

  • 编码看起来不好玩,是吗?这就是为什么我们还提供有用的组合,结果如下:

  • RequireContextResult可以方便地使用RequireContext,它的返回类型是Result

  • RequiresContextIOResult可以轻松地使用以IO[Result]作为返回类型的RequiresContext


这样,返回使用了这些组合规则的库函数,并为我们的最终用户提供良好的API。


DI容器


  “那么,你是说我根本不应该使用任何DI框架?“看完这篇文章,你可能会问。这确实是一个有趣的问题。


我自己花了很多时间思考这个问题。我的答案是:您可以(而且可能应该)在框架级别上使用依赖注入容器框架。


原因如下:在现实世界的应用程序中,你的Deps类很快就会变得非常大。里面会有很多东西:

  • 存储库和数据库相关实用程序

  • HTTP服务和API集成

  • 权限和身份验证帮助程序

  • 缓存层

  • 使用异步任务和实用程序

  • 可能还有更多!

 

要处理这些东西,你需要导入很多其他的东西。你需要以某种方式创建这个可怕的对象。这就是DI框架可以帮助您的地方。很多。


有了他们的魔力,创造这样的环境可能会更容易、更安全。你需要复杂的工具来对抗真正的复杂性。


这就是为什么每一个复杂系统都需要像依赖性这样的工具:功能性的和命令性的。


结论


让我们总结一下我们今天学到的东西:

  • 不同的应用程序需要不同层次的体系结构,对依赖注入有不同的要求

  • 如果您害怕魔力般的DI容器,请使用类型化函数组合:RequiresContext可以帮助您从顶层到最底层提供所需的上下文,并定义良好的组合API

  • 在编写高级代码时,请考虑您的体系结构,并将契约显式定义为类型。使用IO和Result指示可能的故障和杂质

  • 有疑问时使用组合助手

  • 当复杂性超出你的控制时,在你的应用程序的最顶层使用DI容器

函数式编程既有趣又简单!如果您喜欢上述概念,请随时收藏我们的代码库。


或者通过文档学习更多的新东西。


非常特别的感谢尼古拉·福米尼卡和阿泰姆对本文的审阅。



英文原文:https://sobolevn.me/2020/02/typed-functional-dependency-injection
译者:QL

Python社区是高质量的Python/Django开发社区
本文地址:http://www.python88.com/topic/61866
 
400 次点击