社区所有版块导航
Python
python开源   Django   Python   DjangoApp   pycharm  
DATA
docker   Elasticsearch  
aigc
aigc   chatgpt  
WEB开发
linux   MongoDB   Redis   DATABASE   NGINX   其他Web框架   web工具   zookeeper   tornado   NoSql   Bootstrap   js   peewee   Git   bottle   IE   MQ   Jquery  
机器学习
机器学习算法  
Python88.com
反馈   公告   社区推广  
产品
短视频  
印度
印度  
Py学习  »  Python

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

Python程序员 • 5 年前 • 559 次点击  

依赖注入是一个有争议的话题。关于如何使用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
 
559 次点击