使用生成式-AI-的金融软件构建指南-全-

使用生成式 AI 的金融软件构建指南(全)

原文:zh.annas-archive.org/md5/f4ceddca9a9e024e49e8968f5fcd7c41

译者:飞龙

协议:CC BY-NC-SA 4.0

第一部分:金融科技与自动清算所

本部分为我们在整本书中将要构建的项目奠定了基础——为一家虚构的金融科技公司开发软件。

第一章简要介绍了金融科技,讨论了自动清算所(ACH)的作用。它还涵盖了在使用生成式人工智能工具时必要的某些基础概念。

第二章深入探讨了 ACH,探索了我们将在整本书中处理的数据文件结构和记录类型。

一旦你完成了前两章,你应该对 ACH 有基本的了解,从而开始着手构建 ACH 仪表板。

第一章:探索 FinTech 和生成式 AI

本章涵盖

  • FinTech 简介

  • ACH 是什么以及为什么它很重要

  • 如何使用全栈开发来构建 ACH 仪表板

  • 生成式 AI 的益处和陷阱

“FinTech”这个术语在过去的几十年中变得普遍,作为金融技术的简称。实际上,“金融技术”这个术语可以应用于很久以前的发明,比如 19 世纪的现金登记机和电报,甚至可以追溯到公元前 3300 年的最早的钱包。如今,FinTech 通过 GoFundMe 和 Kickstarter 等网站帮助赋予众筹机会。像 Uber 和 DoorDash 这样的零工服务应用依赖 FinTech 来促进快速支付。像 Venmo 和 Zelle 这样的支付服务能够提供快速的个人对个人(P2P)和企业对企业(B2B)支付,这都得益于 FinTech。比特币和以太坊这样的加密货币当然被归类为 FinTech,但即使是更传统的银行、投资和信贷公司,其核心也都有 FinTech 的元素。

那么,什么是 FinTech 呢?简单来说,它就是利用技术来改善金钱的处理方式。如今,FinTech 指的是提供更快、更方便和/或新的金钱处理方式的软件。

正如你所见,FinTech 开发者有机会从事许多种类的软件开发。不难想象,对于全栈 FinTech 开发者,或者那些能够从前端到后端的所有方面工作的开发者,职业道路会敞开。我们喜欢说,全栈开发者有机会对许多不同的领域感到好奇。作为一名全栈开发者,你将有机会从事构成应用程序的不同部分——从客户将看到的用户界面(UI)到满足 UI 请求的后端处理,以及两者之间的一切。

想象一下收到关于客户互动耗时过长且破坏用户体验(UX)的报告问题。其他开发者可能会在他们的分配边界之一放弃,而你将有机会从按钮点击开始追踪该问题,通过应用程序编程接口(API),到服务器,再到具体的结构化查询语言(SQL)语句,看看如何解决它。

本书将教授您使用真实世界金融科技公司项目进行全栈开发。本书旨在提供对金融科技公司(无论大小)遇到的一些软件开发方面的深入了解。在完成以下章节中介绍的项目后,您应该对您最感兴趣的软件开发领域有更好的理解,以及您可能想要深入研究的话题。正如金融科技和全栈开发为我们提供了许多不同的机会一样,我们希望向读者提供类似的学习金融科技和软件开发的机会。多样化的主题应该激发您的想象力,并鼓励您深入研究一个或多个主题。

1.1 未来金融科技公司项目

本书将带您踏上一段旅程,以一位虚构的金融科技公司——未来金融科技公司的软件新开发者身份。这意味着,如果您在现代编程语言(如我们这样)中有一点软件开发经验,这将是有益的,因为我们并不旨在从零开始教您编程。相反,您应该对循环、if 语句和类似概念有基本的理解。由于本书不专注于单一的语言或技术,新开发者将有很多东西可以学习,而经验更丰富的开发者可能会发现一些新的工具或技术。

为了帮助您将所学知识付诸实践,本书将采用未来金融科技公司等大型公司的一个典型项目。该项目旨在使未来金融科技公司众多产品中的一款现代化。需要现代化的产品通常是公司长期存在的现有产品。它们通常在市场上已经确立,并被许多客户使用。公司可能出于任何原因进行这些产品的现代化,例如

  • 该公司因收购或合并而拥有的一款产品不符合公司现有的设计指南。

  • 由于新的合规或监管变化,一款产品受到了重新审视。

  • 一款产品正在其市场上重新定位或向新的受众进行营销。

  • 一款产品需要昂贵的硬件或额外的软件来维护它,而现代化它将提供更经济有效的解决方案。

我们正在承担的项目是更新一个与支付系统相关的仪表板,该系统在美国用于促进支付(如支付抵押贷款或汽车贷款)和资金转移(将资金从支票账户转移到另一机构的更高利率储蓄账户)——自动清算所(ACH)。

我们的 ACH 仪表板项目将要求我们了解 ACH 处理和全栈开发,我们在处理数据库设计、API、后端处理以及通过 UI 展示一切时,会介绍我们作为金融科技开发者所使用过的不同方法和技术。我们还将探索生成式 AI 工具如何使我们日常工作更加高效,帮助我们写作、故障排除以及执行许多其他任务。

1.2 ACH 仪表板

如前所述,我们将构建一个在美国支付中常用的示例支付系统,称为 ACH。然而,ACH 系统通常会与其他国家的等效系统进行交互,例如

  • 加拿大的电子资金转账(EFT)

  • 欧洲的单欧元支付区(SEPA)

  • 英国的银行自动化清算系统(BACS)

  • 澳大利亚的大批量电子清算系统(BECS)

  • 印度的国家自动化清算所(NACH)

ACH 支付是了解金融科技的一个很好的方式,因为它们是金融机构之间交易流程的真实世界例子。一旦我们将交易流入数据库,金融科技的世界将真正对我们敞开。在我们构建并增强 ACH 仪表板的过程中,我们将涉及软件开发和金融科技的许多领域,如图 1.1 所示。

了解为仪表板提供数据的各个部分有助于看到整体情况。我们还可以看看这些部分在将 ACH 文件加载到仪表板时可能如何发挥作用。图 1.2 为我们提供了一个高级概述,展示了我们的仪表板一旦构建完成将执行的事件序列。能够加载 ACH 文件是与仪表板交互的第一步。

公司结构图  自动生成的描述

图 1.1 构建 ACH 仪表板

一旦我们完成 ACH 仪表板的构建,我们将审视所有对我们项目开放的可能路径,并希望找到一个引人入胜的项目发展方向。虽然 ACH 可能对我们产生常规影响(您的工资是否自动存入,或者您是否安排信用卡支付、抵押贷款支付或其他贷款?),但您之前可能从未听说过它。让我们看看 ACH 究竟有多重要。

1.2.1 ACH 有多重要?

图 1.3 展示了 2000 年至 2021 年间非现金支付的趋势,金额以万亿美元计,这是根据最新的美联储支付研究收集的(mng.bz/5g2z)。注意 ACH 转账是如何显著增加的,占据了更大的商业份额,尤其是在支票使用量下降之后。

ACH 是美国的一个主导标准,但实时支付的需求正在上升。之前我们提到了 P2P 和 B2B。还有其他支付类型,但基本理念是在两个账户之间进行快速实时支付。作为回应,ACH 提出了当日 ACH 的概念,这可以显著加快处理 ACH 支付所需的时间,将其从几天缩短到几小时。

软件系统图  自动生成的描述

图 1.2 通过 ACH 仪表板加载文件

实时支付及其提供商正将此标准定位为支付领域的下一场革命,并且他们很可能会在未来获得一些影响力。预计到 2026 年,实时处理将负责每年 1000 亿美元,但仍需走很长的路才能影响到每年通过 ACH 处理的数万亿美元。在任何情况下,从本书中学到的经验教训都将使我们能够掌握支付处理,无论使用的技术是什么。现在我们已经了解了 ACH,让我们来看看我们将如何完成这个项目的构建,该项目将使用一种名为 SAFe 的敏捷方法。

1.3 SAFe 敏捷

根据《敏捷状态年度报告》第 17 次年度报告,71%的受访者在其软件开发生命周期(SDLC)中使用敏捷(见mng.bz/6e2y)。本书借鉴了 SAFe 敏捷的原则,因为我们在大型的财富 500 强金融科技公司中有直接的经验。此外,规模敏捷(SAFe 的创造者)表示,全球超过 20,000 个组织使用规模敏捷框架(SAFe)。

不同颜色线条的图表  自动生成的描述

图 1.3 各种支付方法的年度比较

如前所述,即使是那些传统上在技术采用上落后的大型金融科技公司,也已经转向了敏捷。我们加入的那个大型虚构金融科技公司名为“未来金融科技”,也不例外,并采用了 SAFe 作为其开发方法。在接下来的章节中,当我们构建 ACH 项目时,我们将努力遵守框架和敏捷方法的一般原则。SAFe 敏捷中的一个关键事件是程序增量(PI)规划会议,它有助于规划即将到来的冲刺的工作。

定义 A 程序增量(PI)是一个时间盒(一定的时间量),长度从 8 周到 12 周不等。PI 通常由四个称为“冲刺”的迭代组成;这个术语来自 Scrum,并被许多不同的软件工具(例如,JIRA)所使用。冲刺通常是一个两周的周期,但根据团队的决定,可能会有所增减。

作为 PI 规划的一部分,部门将展示下一年度的产品路线图,其中显示了企业所做出的各种承诺、预期的监管变化以及公司倡议。在这次 PI 规划会议期间,工作将被划分为史诗级项目。这些史诗级项目是较大的项目或倡议。例如,一些公司可能将他们的发布称为史诗级(如 2024 年春季发布),并将发布的项目分解为用户故事。还有一些公司可能将他们的个别项目作为史诗级,并将该项目的任务分解为用户故事。在整个书中,我们尝试在短的开发周期内工作,这也是敏捷开发的主要概念之一。如果我们将工作分解成更小的可管理部分,我们应该没问题,无论我们如何称呼这些小块(当然,保持一致性有助于沟通)。

敏捷方法论

每家公司都不同,这并不令人惊讶。许多金融科技公司起源于瀑布方法论,但最近转向了敏捷。我们可能会遇到不同程度上遵循敏捷的团队(部门、业务单元或团队),从纯粹的敏捷实践者到被迫接受敏捷的团队。

我们的目的是不是真正教授敏捷,而是在我们处理项目时大致在敏捷框架的范围内工作。如果你想更深入地了解敏捷,我们鼓励你阅读 Greg Smith 和 Ahmed Sidky 的《成为敏捷》(2009 年,Manning 出版社),以及敏捷宣言([agilemanifesto.org/](https://agilemanifesto.org/))也是必读内容。但别担心:宣言是一篇快速阅读的文章!

PI 规划是作为一个团队聚集在一起,你将与业务分析师、质量保证(QA)、开发者、技术作家等互动,为即将到来的工作制定计划。图 1.4 展示了使用 Miro 模板的 PI 规划板,Miro 是一种流行的协作工具,专门用于此类活动。

在 PI 规划板上,我们应该能够找到我们的团队和分配的工作。在我们这个案例中,我们已经分配了一个项目,作为公司范围内现代化遗留软件的一部分,创建一个新的 ACH 仪表板。通常,PI 规划会议持续一天,重点是优先排序工作、确定依赖关系和明确必须满足的要求。此外,我们使用 ROAM(已解决、已拥有、已接受、已缓解)板来识别风险,最后进行信心投票,每个人使用“拳头五指投票”的方式(他们举起手并投票或通过支持的工具)。如果平均投票是三票或更高,则计划被确认,我们可以开始工作。如果不是,我们则审查并重新制定计划。通常,人们会担心工作量、完成时间以及任何现有的知识差距。解决这些担忧是过程的一部分,可能需要一些时间。我们将快速跳过这部分,直接进入我们团队分配的项目,因为我们总是可以在他们的网站上找到更多关于 SAFe 及其所有动态部分的信息(https://scaledagileframework.com/)。

1.4 ACH 仪表板

在 PI 规划会议结束后,我们应该对我们的项目和即将到来的迭代中我们追求的目标有一个有希望的想法。我们的团队已经选择了现代化 ACH 仪表板的任务。现代化遗留软件可能很棘手,因为我们不仅要处理设计和开发的技术挑战,还要考虑我们的变化对客户的影响。一些行业(以及个人)可能因改变缓慢而闻名。在个人的情况下,他们了解当前的系统、流程及其特性。他们已经学会了如何导航系统以完成工作,那么为什么要改变一个行之有效的东西呢?这些问题是我们在任何项目中都应该考虑的合理问题。

图片

图 1.4 Miro 中的 PI 规划模板

个人)可能因改变缓慢而闻名。在个人的情况下,他们了解当前的系统、流程及其特性。他们已经学会了如何导航系统以完成工作,那么为什么要改变一个行之有效的东西呢?这些问题是我们在任何项目中都应该考虑的合理问题。

在我们这个案例中,公司将我们的 ACH 仪表板项目视为一种工具型任务——对业务来说是必需的,但仅此而已。然而,由于 ACH 是金融机构处理交易如此重要的一部分,客户已经一段时间以来开始要求增强功能。为了保持竞争优势,公司希望现代化仪表板。有潜力对交易进行更好的分析、欺诈监控,以及将仪表板的使用范围扩展到发起交易的客户。有了这些能力在议程上,我们需要考虑提供一款不仅提供这些功能,而且外观和感觉与其他提供的产品一致的产品。图 1.5 展示了当前遗留界面的一个示例。

尽管界面功能齐全,但仍有改进的空间。公司已经报告了团队应努力解决的局限性。

计算机截图  自动生成描述

图 1.5 遗留 ACH 界面

现有遗留界面的限制如下:

  • 无法上传文件—目前操作必须确保文件已上传,然后后台办公室可以从下拉菜单中选择它们。这会在处理过程中造成不必要的延迟。

  • 基本报告—只报告文件总数。这种方法不会产生有意义的分析。系统将文件存储在适合报告的遗留数据库中。

  • 访问限制—该界面需要连接到运行在 IBM Power 上的后端服务器,而此系统可以运行一个 web 服务器。由于此服务器有广泛的路由和防火墙保护,我们的 SecOps 团队更倾向于将此任务卸载到不同的服务器上。

  • 样式—新的仪表板最终将被作为更广泛系统或管理 ACH 文件的用户的一个独立组件进行营销。业务希望将现有的样式指南应用于新项目。

业务已经设想了一个现代化的网站(图 1.6),将解决这些问题,并已指派我们的团队来处理这个项目。

计算机截图  自动生成描述

图 1.6 现代 ACH 仪表板

技术架构师还从技术方面提供了支持公司现代化计划的要求。图 1.7 提供了 ACH 仪表板的概述。

软件应用图  自动生成描述

图 1.7 ACH 仪表板启动计划

现代化计划包括以下内容:

  • PostgreSQL—确保使用关系数据库,并将 ACH 文件解析到数据库中,以便业务智能团队能够对数据进行分析。

  • Next.js—使用如 Next.js 这样的生产就绪的 React 框架。确保界面可以支持可视化。

  • Docker—我们希望确保我们的应用程序被分割成在容器中运行的组件。

  • Python—目前,我们的遗留应用程序,如 ACH 处理系统,是用 COBOL 编写的。新项目应使用 Python 作为主要语言。

应该注意的是,我们可以根据需要替换这些组件(为了完成这个项目;在工作中,我们可能不会有这样的奢侈)。这是我们将在以下章节中反复提到的事情。例如,如果你更熟悉 MySQL,或者你的公司使用 Java 而不是 Python,并且使用这些技术会对你更有帮助,那么,当然,你可以使用它们。你可能需要更努力地将 Python 示例重构成 Java(如果你对两者都没有经验),但这样你也会学到很多。你希望我们使用的技术是什么?请随时告诉我们——也许我们会在下一章处理那个问题!

我们还想指出,现代化是一个你在职业生涯中会反复遇到的过程。可能在你职业生涯的某个阶段,你公司中的所有 Python/Java/Golang 代码都需要被下一代语言所取代。可能公司会聘请一位在另一个技术栈方面更有经验的新首席技术官,并希望朝那个方向发展。无论这个决定是好是坏,我们可能被迫忍受这个过渡。现在我们对项目有了更多了解,让我们看看我们将要使用的一些工具。

1.4.1 行业工具

作为 ACH 仪表板项目的一部分,团队将使用以下工具。正如前文建议读者可以选择用 Java 替换 Python,或者选择用 MySQL 替换 Postgres——根据他们当前的技术栈——同样适用于这些工具。如果你有更适合你当前环境的工具,请随时使用。许多这些工具将在后面的章节中讨论,但我们想给你一个正在使用的工具集的概览。

我们团队选择使用的工具有

  • JetBrains PyCharm—社区版或专业版。我们使用专业版,并在其中运行我们的 API、UI 和 Docker。

  • JetBrains Webstorm—用于我们在 Next.js 中 UI 开发的额外工具。

  • Visual Studio Code—我们用它来处理 Docker 和 Plant UML,但它也可以替代 JetBrains PyCharm/Webstorm。

  • JetBrains Qodana—用于代码审查。

  • ChatGPT—我们选择它作为编码辅助工具,因为有一个免费版本可供我们实验。此外,如果我们公司不能提供集成体验,我们仍然可以通过访问chat.openai.com/来使用生成式 AI。

  • GitHub Copilot—为各种 IDE 提供 AI 编码辅助。拥有 GitHub Copilot 的订阅将使我们能够在 PyCharm、Webstorm 和 Visual Studio Code 等 IDE 中获得辅助,仅举几个例子。

我们还将介绍我们使用的各种插件;然而,在我们开始深入本书的其余部分之前,我们想确保我们已经设定了竞争的舞台。也有可能使用单个 IDE 来运行项目。个人来说,我们倾向于使用 JetBrains PyCharm Professional 进行所有开发(Python、Next.js 和 Docker),效果非常好。

在我们深入项目之前,让我们更仔细地看看生成式 AI。这本书不是关于生成式 AI 的全面书籍。为此,你可能想看看 Numa Dhamani 和 Maggie Engler 所著的《生成式 AI 入门》(2024 年,Manning)或 Amit Bahree 所著的《生成式 AI 实战》(2024 年,Manning)。相反,我们将简要介绍生成式 AI 以及与之合作时的一些重要概念。然后,随着我们完成项目,我们将展示一些我们发现生成式 AI 有帮助(以及一些它成为障碍)的具体案例。

1.5 生成式 AI 的第一印象

在他们上一次公司全体会议上,Futuristic FinTech 的执行团队表示有兴趣采用生成式 AI 工具来帮助提高公司内部的效率。公司不同部门已经启动了几个倡议,以评估不同的工具和将生成式 AI 融入的方式。ACH 仪表板项目也可以在现代化项目中使用生成式 AI。公司要求我们更好地了解生成式 AI 的能力、局限性和责任。那么,我们如何思考生成式 AI 呢?

1.5.1 更好的橡皮鸭

我们可以将生成式 AI 视为橡皮鸭的下一迭代。橡皮鸭调试是由 Andy Hunt 和 Dave Thomas 所著的书籍《实用程序员》(Addison-Wesley,1999 年)普及的,它提供了一种鼓励我们将思维过程表达给一个比老板或团队领导不那么令人畏惧的听众的方法!通过大声对橡皮鸭(或任何非生命物体)说话,我们通常能够在涉及其他人之前就识别出问题。我们经常叫来同事,指向屏幕上展示我们的观众如何证明这段代码不可能失败,结果却发现我们坚实的代码中存在立即的问题。有一个橡皮鸭来“弹跳”想法是一种无价资源(而且相当经济实惠)。如果我们害羞于大声说话(即使没有人在场),一些工具提供了将我们的想法打字出来的能力。Slack 有一个频道,可以作为我们思想的共鸣板。进入频道会显示“你也可以在这里自言自语,但请记住,你将不得不提供对话的双方。”还有rubberduckdebugging.com/cyberduck/上的橡皮鸭聊天机器人服务以及多个生成式 AI 橡皮鸭网站,如rubberducktherapy.app/

我们可以将生成式 AI 看作是那个橡皮鸭的进化。生成式 AI 让我们的橡皮鸭不仅能作为倾听者,还能提供想法和反馈。我们仍然会鼓励使用橡皮鸭,因为它能洞察我们的思维过程,在我们一天的工作结束后,代码不合作时,它还能充当一个便宜的咨询师。

生成式 AI 是机器学习的一个子集,它为 ChatGPT 和 GitHub Copilot 等工具提供动力。生成式 AI 可以生成新的内容,并基于大型语言模型(LLMs)的使用。我们看到生成式 AI 正在迅速被各种行业和角色的工作流程所采用,不仅限于技术领域,还包括如人力资源和市场营销等领域。生成式 AI 为我们提供了更高的效率,这反过来又节省了时间,我们都知道时间就是金钱。

在下一节中,我们将使用生成式 AI 给经典的“Hello, World!”程序加入金融科技的元素。

1.5.2 你好,检查 21!

“嗯,从哪里开始呢?”当我们第一次看到 ChatGPT 的提示时,我们可能会这样想。我们可能想直接开始,看看 ChatGPT 的编码能力,但让我们先看看 ChatGPT 的其他可能性。金融科技领域充满了我们需要注意的法规,所以我们首先让它“请总结一下检查 21 法规。”阅读完响应后,我们发现它有点枯燥,因为我们作为软件开发者,不是业务分析师。所以,虽然我们可能稍后会需要细节,但我们只想得到一个大致的印象。我们可以尝试改进我们的提示:“总结一下检查 21 对普通银行客户的好处。”这给了我们一些可能更符合我们最初想要了解的内容。我们可以进一步改进我们的提示:“用几句话告诉我检查 21 的情况,”然后我们再次得到了一个合适的响应。一个有趣的问题可以是“如果我们必须用一个词来描述检查 21,那会是什么?”答案是“数字化”,这实际上是一个很好的描述方式。

当然,作为软件开发者,我们来到这里是为了编码!让我们假设我们没有 Python 编码经验,但也许我们对其他语言和软件开发有一般性的了解。我们想看看 ChatGPT 能否帮助我们开始 Python 编程。

让我们以传统的“Hello, World!”程序为例,但给它加入金融科技的创新,改为打印“当前余额:$1000.00。”使用 ChatGPT,我们说,“在 Python 中,给我展示一行打印‘当前余额:$1000.00’的代码”然后我们得到了相关的 print(“当前余额:$1000.00”)代码。需要注意的是,ChatGPT 的更新以及 ChatGPT 的非确定性可能会导致我们收到的响应有所不同。我们可能会发现我们多次输入相同的提示,每次都会收到不同的响应。所以,如果你在跟随,如果有些响应略有不同,那是可以的。

如您所见,使用 Python,我们只需打印一行;我们不需要像在 Java 或 C 中可能遇到的那样导入库或设置主函数。因此,我们决定通过告诉 ChatGPT,“扩展程序以允许用户输入当前余额金额,然后打印出来。”这也按预期工作,给出了以下列表中所示的一个简单程序。

列表 1.1 ChatGPT 生成的代码
# Ask the user to enter the current balance, note that we expect the user
# to enter a numeric value otherwise we will receive a message similar to
# ValueError: could not convert string to float
current_balance = input("Enter current balance: ") #1

# Print the entered balance in the desired format
print(f"Current Balance: ${float(current_balance):,.2f}")  #2

1 从键盘接收输入并将其存储在 current_balance 中

2 打印一行显示当前余额,并相应地格式化 current_balance

观察代码后,我们决定如果我们将这部分代码拆分成一个函数可能会更有用。然后我们要求系统“更新示例代码,以调用一个打印 current_balance 的函数。”

列表 1.2 ChatGPT 更新后的带有函数的代码
def print_balance(current_balance): #1
 """ #2
 Prints the current balance in a formatted string. #2
 #2
 Parameters: #2
 - current_balance: The current balance to be printed. #2
 """ 
 print(f"Current Balance: ${float(current_balance):,.2f}") #3

# Ask the user to enter the current balance
user_input = input("Enter current balance: ")

# Call the function to print the balance
print_balance(user_input) #4

1 定义一个接受当前余额参数的函数

2 函数及其参数的文档

3 将打印语句移动到函数中

4 使用用户输入调用函数

代码看起来更好了,而且我们收到了一个使用 Google 风格的 docstrings 注释的函数代码,这很棒。然而,我们认为如果代码包括类型提示,将更容易维护,正如在 peps.python.org/pep-0484/ 中提到的。因此,我们请求系统通过输入“请在上面的代码中添加类型提示”来包括类型提示。

列表 1.3 带有类型提示的代码扩展
def print_balance(current_balance: str) -> None: #1
    """
    Prints the current balance in a formatted string.

    Parameters:
    - current_balance (str): The current balance to be printed, as a string.
    """
    print(f"Current Balance: ${float(current_balance):,.2f}")

# Ask the user to enter the current balance
user_input = input("Enter current balance: ")

# Call the function to print the balance
print_balance(user_input)

1 现在我们展示 current_balance 是一个字符串,该函数不返回任何内容。

现在,我们真的很喜欢我们迄今为止编写的简单程序。然而,当我们查看 print_balance 的返回值时,我们发现它返回 None。这很合理,因为我们只是在代码中调用了一个打印语句。然而,为了使代码更加易于维护,我们只需返回格式化的字符串,让调用者决定他们想要如何处理它。毕竟,这个函数不应该决定如何处理输出,因为调用者可能想要将其写入文件或通过 API 调用返回。我们认为,不假设返回值总是以相同的方式处理(在这种情况下,打印)是一个好习惯。我们经常不得不重新编写大量代码,因为假设它总是会被打印出来(在我们的一个案例中,打印到一个开放的网络上)。结果,业务逻辑变得非常复杂,截止日期非常紧张,我们最终不得不复制现有的代码,移除打印语句,以便返回我们需要的。这导致了技术债务(未来需要完成的工作以解决重复的代码)。因此,我们只想返回格式化的字符串,以养成不假设我们的函数应该对输出执行特定操作的习惯。因此,我们告诉 ChatGPT 返回一个字符串而不是打印它,“更新代码,使 print_balance 返回字符串并打印它。”

列表 1.4 返回一个字符串
def print_balance(current_balance: str) -> str:  #1
    """
    Returns the current balance in a formatted string.

    Parameters:
    - current_balance (str): The current balance to be formatted, as a string.

    Returns:                                #2
    - str: The formatted current balance.   
    """
    return f"Current Balance: ${float(current_balance):,.2f}" #3

# Ask the user to enter the current balance
user_input = input("Enter current balance: ") #4

# Call the function to get the formatted balance and then print it
formatted_balance = print_balance(user_input)  #5
print(formatted_balance)   #6

1 函数已被更新以显示它返回一个字符串。

2 文档也已相应更新。

3 返回字符串而不是打印它

4 在名为 user_input 的变量中存储余额,该变量类型为字符串

5 在名为 formatted_balance 的变量中存储格式化的字符串,该变量类型为字符串

6 打印格式化的字符串

非常有趣的是,我们仅凭软件开发知识就能创建一个可工作的程序,而不一定需要任何 Python 知识(可能我们需要查阅 Python 的类型信息)。虽然这表明 ChatGPT 可以非常有帮助,但它只能像我们放入提示中的信息一样有帮助。

提示工程和问题表述

生成式 AI 在提示工程和问题表述领域引起了很大的关注。有些讽刺的是,这些不仅是生成式 AI 的主题,而且可能是我们日常生活中应该一直在努力工作的领域!提示工程被称为能够为 LLM 创建有意义的输入(提示)。毕竟,模型需要能够解析提示并形成响应,因此输入确实很重要,因为垃圾输入往往会导致垃圾输出。

  • 清晰和具体的提示

  • 保持提示简洁

  • 正确的语法和拼写

  • 在适当的时候使用示例

有一种观点认为,提示工程并不那么重要,因为模型正在不断改进,并且越来越擅长处理提示。然而,能够清晰地表达我们的观点并保持其简洁是一种我们大多数人都能从中受益的技能。你有没有读过一封冗长的电子邮件然后停下来?是的,我们也是。虽然提示工程可能不再是那么受欢迎,但有许多不同的方法,其中一些可能取决于你正在处理的问题而成为必要。我们建议查看www.promptingguide.ai/techniques上定义的各种技术,或者阅读 Amit Bahree 的《生成式 AI 实战》(Manning,2024 年)。

问题表述是指我们清晰地定义问题的能力。这是另一种显然适用于日常生活,而不仅仅是我们的新生成式 AI 统治者的技能。我们更愿意与那些通过说“这个程序坏了,我肯定没有做错什么!”来寻求我们帮助的人合作,还是与那些说“我试图加快这个程序的执行速度,作为 123 号工单的一部分,现在格式不正确”的同事合作呢?

虽然让生成式 AI 编写我们的代码可能会有所帮助,但作为新的软件开发人员,我们将面临大量的支持性工作,我们需要解决这些问题。让我们看看生成式 AI 如何帮助我们进行调试。

1.5.3 帮助!这段代码不起作用!

生成式 AI 的另一个好处是帮助我们识别代码中的问题。这对保持我们的理智或让我们的同事看起来像英雄一样都很有价值。例如,假设我们有一段代码,它应该保存客户最近进行的交易列表,这样我们就可以轻松地在代码中检索它们。这对大多数金融软件来说可能不是一个不合理的功能。在下面的列表中,我们定义了一个简单的函数,用于保存交易列表。

列表 1.5 存储交易的简单函数
def last_transactions(user_transactions: list, 
➥transaction: str, max: int = 3) -> list: #1
    """
    Adds a transaction to the list of user transactions 
 ➥and returns the updated list.
    :param max_length: Maximum transactions to keep
    :param user_transactions: List of current transactions
    :param transaction: New transaction
    :return: Updated list of transactions
    """
    if len(user_transactions) + 1 >= max:  #2
        user_transactions.pop(0)           

    user_transactions.append(transaction)  #3

    return user_transactions               #4

1 定义了一个函数,该函数接受一个交易列表、一个新交易以及要保留的最大交易数量;如果没有传递任何内容,则默认为三个。

2 如果存储的交易长度超过了我们的最大值,那么就删除第一个。

3 存储新交易

4 返回更新后的交易列表

这相当直接——我们传入一个交易列表、我们想要添加到列表中的交易以及保持列表的最大大小。如果我们超过了长度,我们将使用参数为零的方法删除列表中的第一个项目。当我们编写使用此函数的程序时,我们会发现它的行为并不完全符合预期。

列表 1.6 使用我们的函数
from source.last_transactions import last_transactions  #1

transactions: list[str] = [] #2

transactions = last_transactions(transactions, "deposit $100") #3
transactions = last_transactions(transactions, "deposit $200")   #3
transactions = last_transactions(transactions, "deposit $300")   #3
transactions = last_transactions(transactions, "deposit $400")   #3

print(transactions)  #4

1 导入我们的函数

2 定义了一个包含字符串的空列表

3 添加一些交易

4 打印结果

列表 1.6 的输出是['deposit $300', 'deposit $400'],所以我们只有两个条目,而不是我们预期的三个。所以,要么将参数默认设置为3的逻辑不起作用,要么弹出事务的逻辑不起作用。另一种可能性是发生了我们没有预料到的事情。请记住这一点,因为有时我们会盯着我们认为有问题的地方,但我们无法找出问题所在。我们可能花费数小时努力,最终发现问题和答案都存在于代码的另一个部分。我们还有一些与这个问题相关的单元测试,这些测试将在下一列表中展示。

列表 1.7 为我们的函数定义单元测试
def test_first_transaction_is_dropped():
    expected_value = 3  #1
    transactions: list[str] = []

    transactions = last_transactions(transactions, "deposit $100», 3)
 transactions = last_transactions(transactions, "deposit $200", 3)
 transactions = last_transactions(transactions, "deposit $300", 3)
 transactions = last_transactions(transactions, "deposit $400", 3)

  assert (
        len(transactions) == expected_value
    ), f"Expected {expected_value}, but got 
➥ {len(transactions)} transactions" #2

    assert (
➥    transactions[0] == "deposit $200"
    ), f"Expected {expected_value}, but got 
➥ {len(transactions)} transactions" #3

1 定义我们预期的交易数量

2 一个断言测试,以确保我们有预期的交易数量

3 我们期望传递的第一个值应该被丢弃,所以我们有“deposit $200”。

因此,如果我们想在继续前进之前休息一下,看看自己能否解决这个问题,我们当然可以这样做。然后我们可以回来看看 ChatGPT

给我们的。

欢迎回来!希望我们能够尝试一下,稍微玩一下代码,但让我们直接回到 ChatGPT,输入这个命令。正如我们所见,列表 1.5 中的代码存在逻辑错误:我们期望看到三个交易被列出,但只看到了两个。我们简单地问 ChatGPT,“为什么这个代码不起作用?”这是我们故意做的,只是为了忽略我们之前讨论的一些提示工程和问题制定。我们想看看我们会收到什么样的回应,ChatGPT 的回应非常棒,因为它识别出了代码中它不喜欢的两个问题:

图片 你提供的代码旨在向用户的交易列表中添加新事务,同时确保列表的长度不超过给定的“max”。如果添加新事务会导致“max”超过,则将移除最旧的事务。

然而,有几个问题:

  1. “max”这个名字是 Python 的一个内置函数,它返回输入值中的最大值。最好避免将其用作变量名。让我们将其更改为类似“max_length”的东西。

  2. 弹出事务的条件是“if len(user_transactions) + 1 >= max:”。这意味着如果你有“max_length = 3”并且当前“user_transactions”的长度是 2,它将在添加新事务之前移除第一个事务,因此始终保持长度为 2。这是不正确的。正确的条件应该是“if len(user_transactions) + 1 > max_length:”。

如我们所见,有几个问题被指出来了:首先,我们在代码中使用了max这个名字。我们的 IDE 也标记了这一点,因为max是 Python 的一个内置函数。正如我们之前看到的,由于这个名字的阴影,代码仍然可以工作,但在尝试跟随它时可能会变得有点复杂。其次,另一个建议是将代码中的 if 语句更新为if len(user_transactions) + 1 > max_length:。有了这两种解决方案中的任何一种,我们都可以重新运行代码或重新运行测试来确认代码工作得更好。

我们一直觉得有趣的一件事是任何问题的解决方案数量;有时我们解决了一个问题,后来又回去说,“我们在想什么?!”。我们以为 ChatGPT 只会去掉+ 1并保留现有的逻辑。如果我们觉得这很令人困惑,那么我们可能会问 ChatGPT 关于这个问题。我们继续追问,“如果我们把行更改为if len(user_transactions) >= max_length:?”然后 ChatGPT 给了我们一些额外的信息,并确认“这个更改确实会使代码正确地实现预期目的!”我们有代码和pytests确认了这两种方式。

我们无法真正提供洞察 ChatGPT 为什么选择一种方式而不是另一种方式的原因,但我们把这看作是再次确认,即生成式 AI 是一个可以使用的工具,但不一定应该被视为万能的。我们试图继续追问,“与另一种方式相比,这种方式是否有性能上的优势?”ChatGPT 给出了一个合理的回答。我们发现有趣的一点是,它开始将if len(user_transactions) >= max_length:作为其建议的更改,并倾向于它,因为它直观且提供了更好的可读性。无论如何,我们都希望你能看到从生成式 AI 工具中获得反馈以帮助我们确定代码缺陷和潜在改进的有用性。

有时候,我们遇到的问题可能并不是代码问题,而更多的是“我确信你需要使用等号来分配变量,但它就是不起作用。”当你知道你想做什么但又不记得如何在特定的语言或环境中做时,生成式 AI 可以提供巨大的帮助。我们将在下一节中探讨生成式 AI 如何提供帮助。

1.5.4 帮助!我忘了语法!

生成式 AI 的另一个好处是只需开始学习其语法。有时,作为一名全栈开发者,不断从一个语言切换到另一个语言可能会让我们感到头晕。我们可能会发现自己知道我们想要完成什么,但并不完全清楚如何实现。正如我们在第一个例子中所看到的,只要我们知道如何进行这个过程,我们就可以让我们的生成式 AI 工具为我们开始。例如,在我们的第一个例子中,我们能够定义一个函数,传递参数,并使用类型提示。如果我们不了解这些结构或者不愿意学习它们,那么生成式 AI 对我们来说可能不会有多大用处。

让我们再试一个全栈开发的例子。之前,我们正在使用 Python,但现在让我们转换方向,跳转到使用 HTML5 和 D3.js 的 UI。当处理 HTML 和脚本时,我们喜欢使用带有 Live Server 插件的 VS Code。每个人对 HTML5 和 D3.js 的经验水平各不相同,从从未听说过它们到专家。然而,即使我们的经验只是看到过柱状图或在网站上使用过,我们也可以通过这个问题而不遇到太多问题。我们可以要求 ChatGPT“在 HTML5 页面上创建一个简单的 d3.js 柱状图”,最终得到一些令人尊重的东西,但绝对不是获奖作品(图 1.8)。

图片

图 1.8 简单的柱状图

代码显示了所使用的数据:

        const data = [
            { name: 'A', value: 10 },
            { name: 'B', value: 20 },
            { name: 'C', value: 30 },
            { name: 'D', value: 40 },
            { name: 'E', value: 50 },
        ];

那个图表看起来不错,但我们想给轴添加一些标签。这应该是我们在看到可视化之前已经遇到过的,而且对于我们来说,添加到可视化中不应该是一个很大的跳跃。我们只需简单地输入“你能更新代码以添加轴标签吗?”在提示符中,我们就会看到标签已经成功添加。即使我们可能不确定如何编写代码,但我们知道我们想要代码做什么,这就足够让我们在这个例子中朝着正确的方向前进。请看图 1.9 中带有轴标签的输出。

图片

图 1.9 带有轴和标签的柱状图

现在看起来相当不错了。我们可能不会赢得任何比赛,但这确实有助于通过我们的可视化传达观点。现在,我们可以考虑在鼠标悬停在条形图上时添加一些交互性。我们在提示中输入“代码能否更新以改变条形图颜色,并在鼠标悬停时显示条形图的值?”,然后我们得到了一个交互式图表。当我们在条形图上悬停时,条形图的颜色从蓝色变为橙色,同时值也会在鼠标附近显示。通过玩这个,我们可以看到它工作得相当不错,但我们不满意的一点是,带有值的工具提示出现在鼠标进入条形图的位置,但当鼠标在条形图内部移动时,它不会随着鼠标移动。我们可以通过更新代码在鼠标移动时更新位置来修复这个问题,或者我们可能应该只是在鼠标悬停时在条形图上方显示值,或者也许我们总是应该在条形图上方显示值。请随意尝试这些或实现其他方法!

我们要求工具提示显示在条形图上方。图 10.1 展示了我们最终得到的结果。

图片

图 1.10 在我们的条形图中进行突出显示和悬停

标签并没有完全居中,当它显示在最大的条形图上时,它会超出屏幕。我们可能可以说服 ChatGPT 让它达到我们想要的样子,或者我们可以深入代码并尝试解决它。无论如何,即使我们没有使用 D3.js 或者不熟悉 HTML,我们也应该能够制作出一个不错的条形图。这些代码片段也可以作为我们的训练工具,让我们可以玩转代码并从中学习。也许我们不确定如何使用 d3.js 的appendon方法,回顾这些样本或生成的代码是看到它们实际应用的好方法。

验证

由于我们刚刚处理了一些可能不太熟悉的例子,现在可能是回顾验证的好时机。我们绝不应该让像 ChatGPT 这样的工具为我们编写一大堆我们永远都无法理解的代码。看到事情是如何完成的可能会有所帮助,我们甚至可以用它来为自己生成代码(以下章节将提供一些关于安全性/隐私/所有权问题的额外见解)。但请记住,将代码放入并检查它意味着我们将拥有它。我们曾多次被问及十年前我们做的代码更改,因为我们是在更改该模块的最后一个人。

这里是一个由 ChatGPT 生成的 Perl 单行脚本,用于验证电子邮件地址格式是否正确。请随意将其插入到您现有的软件解决方案中——但请准备好,当支持人员深夜打电话告诉您客户电子邮件导入过程出现问题时,您需要承担责任!

perl -e 'my $email = "user@example.com"; if ($email =~ /^[a-zA-Z0-9._%+-
]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/) { print "$email is a valid email address.\n"; } else { print "$email is an invalid email address.\n"; }'

如果你对正则表达式和 Perl 不熟悉,你会在代码中放入这个脚本的可能性有多大?

工具如 ChatGPT 的另一个特性是能够请求参考资料。也许,在看到验证电子邮件的正则表达式后,你决定你可能想通过做更多研究来验证结果。通过要求 ChatGPT 提供代码的来源,它会将你引向 Perl 文档(perldoc)。它还会提到一般的正则表达式知识,以及电子邮件地址的官方标准(RFC-5322)。这可以为我们想要了解我们不理解的一些代码提供一个良好的起点。

我们看到了生成式 AI 的强大和有用之处,但在下一节中,我们将探讨使用它时可能的一个潜在陷阱。

1.5.5 我是不是泄露了我的数据?

生成式 AI 的下一个好处示例是能够让它为我们处理输入。例如,假设一位银行家给我们提供了一个报告文件,并希望对它进行一些报告处理。他们给我们提供了一个逗号分隔值(CSV)文件,并希望我们将其汇总。为什么他们不能在 Excel 中这样做,我们实在想不通,但我们认为这可能是一个我们获得一些额外 Python 实践和尝试生成式 AI 的好机会。我们得到了一些格式如下

"ssn", "account", "balance"
"000-65-4320", 1, "$1.00"
"000-65-4321", 1, "$2.00"

当我们将这些数据粘贴到提示中时,ChatGPT 回应说:“看起来你提供了一份包含社会保障号码(SSNs)与账户号码和相应余额的列表。我如何帮助您处理这些数据?”这是一个非常棒的回应。将这个回应与如果您只是将类似的打印输出放在他们面前,他们可能会给出的回应进行比较。

因此,在提示中,我们输入“给定这个样本文件,编写一个 Python 程序来打印带有总计的报告”,并在提示下方粘贴我们的数据。它打印出以下列表中的程序。

列表 1.8 样本报告程序
data = """
"ssn", "account", "balance"
"000-65-4320", 1, "$1.00"
"000-65-4321", 1, "$2.00"
"000-65-4322", 1, "$3.00"
"000-65-4323", 1, "$4.00"
"000-65-4324", 1, "$5.00"
"000-65-4325", 1, "$6.00"
"000-65-4326", 1, "$7.00"
"000-65-4327", 1, "$8.00"
"000-65-4328", 1, "$9.00"
"000-65-4329", 1, "$10.00"
"""

lines = data.strip().split("\n")
total = 0.0

# Skip the header row
for line in lines[1:]:
    balance_str = line.split(",")[2].strip().replace("$", "")
    total += float(balance_str)

print(f"Total Balance: ${total:.2f}")

代码看起来是合理的,所以我们继续运行它。然而,代码遇到了一个值错误:ValueError: could not convert string to float: '"1.00"'。所以,ChatGPT 产生的代码中包含了一个错误。我们可以尝试自己修复它,或者我们可以让 ChatGPT 知道它有问题。请随意审查和调试错误,看看是否可以在继续之前修复它!

问题最终被证明是balance_str中的引号。为了纠正它,我们可以添加一个额外的replace来处理双引号,所以这一行变成了balance_str = line.split(",")[2].strip().replace('$', '').replace('"', '').

虽然这可能是使用生成式 AI 的一种非常有帮助的方法,但也有一些潜在的缺点。其中一个缺点是我们刚刚将非公开信息(NPI)输入到了 ChatGPT 中。如果我们使用的工具吸收了用于训练的数据,那么可能,我们已经将客户信息暴露给了不安全的数据源,因为它可能会出现在对其他用户的响应中。我们必须非常小心地对待我们输入到任何工具中的数据,而不仅仅是当我们使用生成式 AI 时,因为我们想确保信息被安全地存储。在我们的案例中,任何以000开头的 SSN 都是无效的,所以我们没有使用真实数据。

所有权

我们已经讨论了将受保护数据输入到生成式 AI 中的潜在担忧,我们也应该意识到将专有信息输入其中的问题。如果我们的专有代码被用来训练系统,它可能很快就不会是专有的了。始终查看服务条款(ToS)和最终用户许可协议(EULA)。

另一个担忧可能是对响应所有权的担忧:如果我们要求模型生成一些将用于我们正在销售的产品中的代码,那么谁拥有这段代码?我们是否需要向他们支付版税?这些类型的问题/担忧是我们应该在我们从学生或个人使用开发过渡到企业环境开发时牢记在心的。公司对发布敏感数据可能会非常挑剔,这是有道理的。他们的业务可能依赖于商业机密,而且不当处理敏感数据可能会面临巨额罚款。

对所有权的担忧使我们产生了对隐私和安全的类似担忧,我们将在下一节中讨论。

1.5.6 隐私和安全

我们需要关注的生成式 AI 的其他领域——这将是贯穿我们职业生涯的一个普遍话题——是隐私和安全领域。可能将我们的代码输入到生成式 AI 工具中正在帮助训练它,我们的专有代码最终可能作为对他人提示的响应输出。例如,GitHub Copilot 明确表示他们不会存储或分享我们的代码。在相同方面,我们还需要小心处理我们输入到模型中的敏感信息,如非公开信息(NPI)或个人身份信息(PII)。

也许我们希望生成式 AI 工具为每个业务单元总结季度收益报告,这样我们就可以创建一个引人注目的信息图表,并认为生成式 AI 可以提供帮助。但是,现在我们可能已经将公司/商业机密与模型共享了!因此,我们应该仔细思考我们输入系统中的内容。如果我们像在上一节中那样使用生成式 AI 来解决问题,创建一个更通用但仍然存在同样问题的较小代码片段可能是有益的。谁知道呢,我们可能在解决问题的过程中就找到了问题!

开放式全球应用程序安全项目(OWASP)在genai.owasp.org/上有关于采用生成式 AI 的具体指导。有趣的是,回顾 OWASP Top 10 针对 LLM 和生成式 AI,看看我们可能会遇到什么样的攻击。了解潜在的危险可能会帮助我们避免它们。

1.5.7 保持更新

最后一个可能的问题可能是模型中的数据是否最新。如果模型基于旧数据,我们可能得不到我们想要的结果。在大多数情况下,当我们向 ChatGPT 等工具询问 Java 或 NPM 的最新版本时,它足够了解自己的回答。在其他情况下,我们的问题可能表明这是一个潜在的版本问题。例如,当我们向 ChatGPT 询问“我如何在 Python 中连接到 PostgreSQL?”时,它提供了一个使用psycopg2的合理示例。然而,有一个名为psycopg3psycopg2的新重写版本。如果我们尝试改进示例并要求 ChatGPT 使用psycopg3而不是psycopg2,它再次说它的数据可能过时:

**图片 看起来你的请求可能有一个拼写错误。截至我 2021 年 9 月的最后知识更新,没有名为psycopg3的库。

当然,如果我们不了解psycopg2或被指示使用psycopg3,我们可能会有些偏离方向,但不必担心。我们可能会认为,如果我们对作业是psycopg2还是psycopg3有疑问,我们可以很容易地询问我们的同事。然而,如果我们是新手并且被投入到了深水区,我们可能不愿意向我们的新导师(无疑非常忙碌)澄清这样的简单疏忽。

有趣的是,我们可以通过在提示符中输入以下内容来让 ChatGPT 将psycopg3视为psycopg2的替代品:“psycopg3psycopg2的重写。你能否在给定的示例中将psycopg2简单地替换为psycopg3?”

然后,ChatGPT 将再次生成一个合理的回应:

**图片 我为之前的混淆道歉。由于截至我 2021 年 9 月的最后更新,我没有关于psycopg3的信息,我将假设它的使用与psycopg2类似。如果psycopg3确实是psycopg2的重写,其使用应该非常相似。

这是我们必须注意的验证的另一个方面——如果工具没有更新并且给我们提供建议,我们可能会错过特定版本或各种改进。再次强调,生成式 AI 为我们提供了一个良好的起点,但确保一切正常仍然取决于我们做一些尽职调查。

摘要

  • 金融科技(FinTech)涵盖了广泛的金融技术,从历史上的现金登记到现代应用,包括众筹、零工服务应用、P2P 支付和加密货币。

  • 本书针对金融科技领域的全栈开发者,涵盖了端到端的应用程序开发,包括 UI 设计、后端处理以及通过实际案例进行故障排除。

  • SAFe 敏捷框架在金融科技公司中得到广泛应用,并在管理软件开发项目的程序增量规划中具有重要意义。通过使用新的工具和框架,如 PostgreSQL、Next.js、Docker 和 Python,正在对遗留系统进行现代化改造。这些变化对用户和企业都产生了重大影响。生成式 AI 被视为橡皮鸭调试方法的演变,它提供积极的反馈和想法。

  • 生成式 AI 使用 LLM(大型语言模型)并将其集成到各个行业以提高效率。

  • 生成式 AI 在编码、调试、语法回忆以及生成跨编程语言的代码片段方面提供帮助。

  • 由于可能存在过时的训练数据,读者在使用 AI 获取最新信息时应谨慎。

第二章:解析支付

本章涵盖

  • 使用生成式 AI 解析 ACH 文件

  • 使用 Python 进行单元测试

  • 敏捷概念

本章解释了由自动清算所(ACH)格式化的文件的基本原理。你将解析一个初始样本文件,以更好地了解一些使用的记录布局,以及单元测试的软件开发实践。你还将扩展你对生成式 AI 的知识,并了解如何将其应用于现实世界的问题。

2.1 现代化我们的遗留软件

现在我们已经完成了 PI 规划并分配了我们的项目,我们将开始着手我们的第一个 spike。在 SAFe 中,spike 是一种故事类型,通常被称为启用故事。可以使用多种启用故事类别(例如,探索、架构、基础设施和合规)。在我们的情况下,我们的故事可能被归类为探索类型或研究 spike。然而,细节并不像我们的活动那样重要,因为具体细节可能在不同敏捷框架之间有所不同。本质上,这个故事旨在为团队提供一些在 Python 中工作以及了解 ACH 文件布局的经验,以便我们更好地理解预期的解决方案。

在这次迭代中分配给团队的故事可以揭示推动现代化努力的商业需求。当前的 ACH 系统运行在主机系统上,并且与现有架构高度耦合。这种耦合使得系统难以维护。业务一直在跟踪各种关键绩效指标(KPIs):

  • 发布节奏——显示开发团队发布新功能和更新的频率

  • 缺陷回溯——显示在开发和发布过程中识别出的缺陷数量

  • 逃逸缺陷——显示软件发布后识别出的缺陷数量

  • 客户满意度——表明客户对我们产品/服务的满意度

这些指标显示,随着系统代码库在规模和复杂性上的增长,进行监管增强和一般性错误修复所需的时间一直在稳步增加。

目前,我们对 ACH 文件没有任何了解,理解和导航 COBOL 代码将需要时间;此外,我们可能需要请求访问权限。团队将需要深入了解 COBOL 代码,以便他们能更好地协助将功能从一个区域转换到另一个区域。在此期间,团队应从项目的基础开始着手。团队还首次获得访问生成式 AI 工具的权限,以帮助评估其使用情况,因为公司正在考虑使用生成式 AI 来提高生产力。我们的经理认为,团队处于一个独特的位置,可以评估这些工具以帮助我们完成这项工作。

公司为我们提供了高级 ChatGPT 和 GitHub Copilot 会员的访问权限。他们还要求我们测试 ChatGPT 的免费版本,因为如果免费版本与高级版本相当,他们更愿意节省一些钱。此外,我们的经理提醒我们不要将任何专有或机密信息粘贴到 ChatGPT 中,因为这可能会违反我们现有的保密协议(NDA)。这意味着我们无法粘贴 COBOL 代码并将其转换为 Python,或者要求 ChatGPT 解释现有代码。相反,我们应该在将代码样本插入 ChatGPT 之前,专注于隔离、净化和泛化代码样本。

我们的计划是研究一下 ACH 究竟是什么,以及我们将要处理哪种类型的格式。我们熟悉 JSON,因为我们之前已经做过相当多的基于 Web 的工作。无论如何,我们都对开始这个项目感到兴奋:进入一些代码并开展工作是我们当初想要这份工作的原因,所以让我们开始吧!

2.2 理解 ACH

ACH 网络允许银行和信用合作社在不建立单独关系的情况下转账。鉴于美国有超过 10,000 家银行和信用合作社,这是一项相当大的任务!那么,它是如何工作的呢?

一种常见的 ACH 交易类型被称为预先安排的支付和存款(PPD)。它通常用于直接存款支付工资(以及定期账单支付、健身房会员费和社会保障支付等)。假设我们的雇主在银行 X 有账户。每两周或每月两次,他们需要向我们支付工资,但我们使用的是另一家银行,银行 Y。为了在发薪日前几天将钱转到你那里,我们的雇主或他们的工资处理器将创建一个包含执行支付所需信息的文件。它包括我们的实发工资金额、我们的账户号码和一个路由转换号。路由转换号是一个唯一数字,用于识别一家银行——在这种情况下,交易最终接收方的银行:银行 Y。工资处理器会将包含员工工资信息的文件传输给银行 X。银行 X 接受该文件,并将其与其他收到的文件合并。接下来,它将一个文件发送到联邦储备银行或一家名为 The Clearing House 的私人公司。这两个实体被称为ACH 运营商清算所。ACH 运营商接受来自数千家银行的文件,根据其路由转换号对交易进行分类,并为每个接收交易的银行创建新文件。然后,这些文件被传输到银行,银行随后接收并处理这些交易。

ACH 文件使用由 Nacha 定义的格式,Nacha 是制定 ACH 网络标准的组织。图 2.1 显示了 ACH 处理流程。

公司图示 描述自动生成

图 2.1 ACH 处理流程

2.3 解析 ACH 文件

因此,我们被分配了一个任务,解析一个 ACH 文件并将其存储在数据库中。这个任务包括对 ACH 文件中发现的每种记录格式的解析子任务,以及将结果存储在数据库中。

ACH 文件标准是在 20 世纪 70 年代创建的,自那时以来经历了各种更新和扩展。这很好,因为它意味着关于标准的很多信息都是可用的。我们从对 ACH 的研究开始,发现 ACH 文件是一个固定宽度的 ASCII 文件,每行 94 个字符长。这些行被称为记录,每个记录由固定位置的字段组成。具有固定位置记录的固定宽度文件应该意味着解析是一个相对简单的任务。

深入挖掘,我们发现文件中可能存在六种类型的 ACH 记录。似乎有一些类型的标题记录,每种都有称为控制记录的尾记录来封装数据。我们还找到了记录类型和文件结构的概述(见图 2.2)。关于记录及其对应字段(如位置、是否必需等)的更多详细信息可在achdevguide.nacha.org/ach-file-details找到。

图片

图 2.2 ACH 文件布局

图 2.2 给我们提供了文件结构的概念;然而,让我们也看看我们将要工作的样本文件,这样我们可以更好地了解文件可能的样子。图 2.3 可能看起来有点令人畏惧,但一旦我们将记录分解为字段,它就相对简单易懂。

图片

图 2.3 示例 ACH 文件

根据结构和文件样本,我们知道我们不得不解析以下记录类型:

  • 类型 1—一个文件头记录,每个文件只有一个记录

  • 类型 5—一个文件头记录,每个文件有多个批次

  • 类型 6—条目记录,每个批次有多个条目

  • 类型 7—附加记录,每个条目记录可以有零个或多个记录

  • 类型 8—一个批次控制记录,包含条目和附加记录

  • 类型 9—一个文件尾记录,包含所有批次记录

尽管我们还没有获得对传统 COBOL 代码的访问权限,但团队成员发现了一个康纳菲斯(由 Atlassian 提供的 wiki)网站,该网站提供了一个流程图,提供了对 ACH 文件处理的一些见解(图 2.4)。

图片

图 2.4 ACH 处理流程

你的第一个任务是检查你的首选搜索引擎以查看有什么可用。你可能会找到一些现有的包(pyNachapy-nachapynacha)。这些似乎是一些探索如何创建和解析 ACH 文件的不同项目。进一步的挖掘也显示,其中一些项目来自 carta-ach,而 carta-ach 又来自 python-ach。这些项目已经被多次分叉,但大多数似乎已经有一段时间没有更新了。由于它们没有得到积极维护,因此基于它们的项目可能不是明智的选择。然而,它们似乎有一个宽松的 MIT 许可证(关于许可问题的更多内容稍后讨论),因此我们可能可以分叉一个项目以开始。我们还可以利用一些我们的生成式 AI 工具来获得一些帮助。

2.3.1 向 ChatGPT 请求解析 ACH 文件

我们可以先向 ChatGPT 提出问题:“你能写一个 Python 程序来解析 ACH 文件吗?”当我们尝试在 ChatGPT 3.5 中这样做时,它给出了一个可以解析文件的 ACH 类的基本结构,如下一个列表所示。

列表 2.1 ChatGPT 3.5 的 ACH 解析器
class ACHParser:     #1
    def __init__(self, ach_file_path):     #2
        self.ach_file_path = ach_file_path #2
        self.records = []     

    def parse(self):
        with open(self.ach_file_path, 'r') as file:
            lines = file.readlines() #3

        record_type = None
        current_record = {}

        for line in lines:
            record_code = line[0]    #4
            if record_code == '1':     #5
                record_type = 'header' #5
            elif record_code == '6':    #5
                record_type = 'batch'   #5
            elif record_code == '7':    #5
                record_type = 'entry'   #5
            elif record_code == '9':    #5
                record_type = 'footer' #5

            if record_type:
                current_record[record_type] = line.strip()
                if record_type == 'footer':
                    self.records.append(current_record)
                    current_record = {}
                    record_type = None

    def get_records(self):
        return self.records

if __name__ == "__main__":
    ach_file_path = "example.ach"
    parser = ACHParser(ach_file_path)
 parser.parse()

  records = parser.get_records()
    for record in records:
        print(record)

1 定义了一个名为 ACHParser 的类

2 __init__ 函数用作我们类的构造函数。

3 读取整个文件

4 确定第一个字符并将其存储在 record_code 中。

5 根据记录代码进行基于记录的处理;注意没有记录代码 5 或 8。

程序看起来还不错;然而,尽管我们对 ACH 的了解有限,但我们可以看出它有一些问题:

  • 缺少记录类型代码 5

  • 缺少记录类型代码 8

  • record_type 标签不正确,因为 6 被列为 batch 而不是 entry,而 7 被列为 entry 而不是 addenda

我们尝试切换到更新的 ChatGPT 4 并向它提出相同的问题。我们看到了以下列表中显示的代码。再次提醒,由于大型语言模型的不确定性,可能会导致不同的结果。

列表 2.2 ChatGPT 4 的 ACH 解析器
def parse_ach(filename):
    records = []   #1

    with open(filename, 'r') as file:
        lines = file.readlines()

        for line in lines:
            record_type = line[0]

            if record_type == '1':  # File Header Record #2
                record = {                                #2
                    'record_type': 'File Header',         #2
                    'priority_code': line[1:3],           #2
                    'immediate_destination': line[3:13],  #2
                    'immediate_origin': line[13:23],      #2
                    # ... additional fields               #2
                }

            elif record_type == '5':  # Batch Header Record
                record = {
                    'record_type': 'Batch Header',
                    'service_class_code': line[1:4],
                    'company_name': line[4:20],
                    # ... additional fields
                }

            elif record_type == '6':  # Entry Detail Record
                record = {
                    'record_type': 'Entry Detail',
                    'transaction_code': line[1:3],
                    'receiving_dfi_id': line[3:11],
                    'account_number': line[12:29],
                    'amount': line[29:39],
                    # ... additional fields
                }

            elif record_type == '7':  # Addenda Record
                record = {
                    'record_type': 'Addenda',
                    # ... additional fields based on the
                    # specific addenda type
                }

            elif record_type == '8':  # Batch Control Record #3
                record = {                                    #3
                    'record_type': 'Batch Control',           #3
                    # ... additional fields                   #3
                }

            elif record_type == '9':  # File Control Record
                record = {
                    'record_type': 'File Control',
                    # ... additional fields
                }

            else:
                print(f"Unknown record type: {record_type} in line: {line}")
                continue

            records.append(record) #4

    return records #5

# Test the function with an ACH file
parsed_records = parse_ach('path_to_ach_file.txt')
for record in parsed_records:#6
    print(record)            

1 一个空数组,用于存储 ACH 记录。

2 每个记录类型都存储为一个字典;其中一些字段是由 ChatGPT 提供的。

3 这次 ChatGPT 4 生成了记录类型 8。

4 将 ACH 记录添加到我们的数组中。

5 返回记录。

6 使用 for-each 循环打印出返回的记录。

这个程序似乎更完善了一些。我们看到这次有批控制记录(记录类型 8),并且一些行还进行了样本解析。这似乎是一个不错的开始;然而,我们希望保持程序的可测试性。如果我们将解析逻辑分离到单独的函数中,我们就可以向函数传递格式良好的记录和无效数据来测试它。让我们看看 ChatGPT 是否能完成这个有点繁琐的过程。

我们只是简单地询问 ChatGPT:

图片 你能更新上述程序,使得每个记录类型的解析都是一个单独的函数吗?

程序更新为将每种记录类型拆分为其自己的函数。相关更改在以下列表中显示。请注意,我们已经提供了解析函数(parse_file_headerparse_batch_header 等),并且这些函数已被 ChatGPT 存根(意味着我们将需要提供实现细节以解析实际数据)。

列表 2.3 使用单独的函数进行解析
def parse_ach(filename):
    records = []

    parser_functions = {          #1
        '1': parse_file_header,    #1
        '5': parse_batch_header,   #1
        '6': parse_entry_detail,   #1
        '7': parse_addenda,        #1
        '8': parse_batch_control,  #1
        '9': parse_file_control    #1
    }                              #1

    with open(filename, 'r') as file:
        lines = file.readlines()

        for line in lines:
            record_type = line[0]
            parser = parser_functions.get(record_type) #2

            if parser:                       #3
                records.append(parser(line)) 
            else:                            
                print(f"Unknown record type: 
   ➥{record_type} in line: {line}") #4
                continue

    return records
…                               #5
def parse_file_control(line):    #5
    return {                     #5
        'record_type': 'File Control',  #5
        # ... additional fields    #5
    }  #5
…                                #5

1 在字典中定义所需的解析函数

2 根据我们存储在 record_type 中的第一个字符检索适当的函数

3 使用适当的 数据调用解析函数

4 使用适当的数据调用解析函数

5 解析函数的样本存根之一

在 tests/data 文件夹中,还有一个样本.ach 文件(如图 2.2 所示),我们可以将其插入到这些样本程序中,以检查它们如何处理文件并玩转结果。现在是一个很好的时候,休息一下,运行样本 ACH 文件通过这些程序,看看它们是如何工作的。如果我们还不完全理解 ACH 文件处理,或者如果我们对 Python 程序中使用的某些结构不熟悉,这将很有帮助。

它是一个强大的工具,可以帮助我们开始。许多开发者都有模板、外壳或骨架程序,他们使用这些程序开始特定的任务。或者他们可能选择一个特定的程序进行复制粘贴。当然,复制/粘贴的问题是你可能会不可避免地遗漏某些内容,并引入编译时问题或错误,然后你需要进行故障排除。当使用模板开始你的程序时,同样的事情也可能发生。

这些模板或由生成式 AI 生成的代码可能很有用,特别是如果我们愿意查看代码并从中学习的话。也许我们会学会一种新的做事方式,或者也许我们会找到一种新的应用技术。尽管如此,也许我们会在生成的代码中找到错误。关键是,只要我们使用它,并且不把输出视为理所当然;生成的代码必须像我们自己的代码一样,这意味着它应该通过我们的单元测试和质量保证(QA)流程进行测试和验证。

2.3.2 使用 Copilot 解析 ACH 文件

在看到 ChatGPT 提供的内容后,让我们使用 Copilot 来看看它是否能让我们更接近目标。我们首先将以下内容输入到一个空的 Python 脚本中:

 # Function to parse an ACH file

它做得还不错,但并不完全符合我们的期望,所以我们再次尝试,使用更新的提示:

 # Write a class to parse an ACH file for each NACHA record type and return a list of transactions.

在我们键入时,“对于每个 NACHA 记录类型并返回一个交易列表”会自动填充。然后我们输入一个扩展的注释:

 # Write a class to parse an ACH file with separate functions for each NACHA record type and return a list of transactions.

这个提示给出了以下输出,完整的列表可以在 copilot_ach_parse_with_functions.py 中找到。

列表 2.4 Copilot ACH 解析器
    def parse_ach_file(self):
        with open(self.ach_file, 'r') as f:
            for line in f:
                if line.startswith('1'): self.transactions
                         .append( 
➥ self.parse_ach_batch_header(line)) #1
                elif line.startswith('5'):
                    self.transactions
                      ➥ .append(
self.parse_ach_batch_control(line))#2
                elif line.startswith('6'):
                    self.transactions
                       .append(self.parse_ach_entry_detail(line))
                elif line.startswith('7'):
                    self.transactions
                       .append(self.parse_ach_addenda_record(line))
                elif line.startswith('8'):                               
                    self.transactions
                      ➥ .append( 
self.parse_ach_file_control(line)) #3
                else: #4
                    raise ValueError(
➥'Invalid ACH record type') #5

1 当这是文件头记录时,错误地调用了 parse_ach_batch_header 例程

2 批头记录被错误地解析为批控制记录。

3 批控制记录被错误地列在文件控制中。

4 缺少文件控制记录(类型 9)的 elif 语句

5 当我们没有有效的 ACH 记录类型时,会引发 ValueError。

我们可以看到它创建了一个类和函数来解析单个记录类型。然而,我们也看到了多个问题:

  • 记录类型 1 正在调用 parse_ach_batch_header 的解析例程,而它本应被解析为文件头。

  • 记录类型 5 正在调用 parse_ach_batch_control 的解析例程,而它本应被解析为批头。

  • 记录类型 8 正在调用 parse_ach_file_control 的解析例程,而它本应被解析为批控制。

  • 文件尾记录(记录类型 9)缺失。

因此,虽然这段代码也给我们提供了一个可行的模板,但它并不一定会直接产生可以直接使用的成果。它提供的一个有用功能是代码,用于引发一个带有 invalid ACH record typeValueError,因此当我们尝试加载一个缺少类型 9 的测试 ACH 文件时,任何格式良好的 ACH 文件都会立即引发错误。

2.3.3 生成式 AI:信任但验证

当然,ChatGPT 和 Copilot 都能帮助我们开始使用有意义的模板,我们可以在以后进行扩展。然而,它们在生成代码时都存在一些问题,这突出了之前提到的验证的重要性。我们必须理解生成的代码,而不仅仅是假设它是正确的。这在我们期望代码将业务规则和逻辑应用到我们想要生成的函数时尤为重要。例如,当使用生成式 AI 帮助创建纯功能代码时——例如提交表单或读取文件的按钮——如果存在语法错误或不符合规格,它将无法编译,这将立即变得明显。然而,如果缺少记录类型或存在其他问题,它可能会引入更难发现的错误,特别是如果我们不了解那些业务规则。我们多次强调这一点,只是为了强调生成式 AI 是一个强大的工具,但仍然只是一个工具。想想制作椅子的过程。我们可以只用一把手锯来制作椅子,或者我们可以有一个装满最新最棒工具的工坊,但如果我们不知道如何使用它们,我们很可能会坐在地上。

那么,我们如何验证生成式 AI 为我们生成的代码呢?通过使用极限编程(XP)开发实践,如测试驱动开发(TDD)。我们之前提到了在金融科技中需要可靠的高质量软件。

TDD(测试驱动开发)的核心概念是在编写代码之前先编写测试,让测试引导我们的开发。其中一个好处是我们拥有高度可测试的代码。在这本书中,我们将使用 TDD 原则,并展示各种自动化测试的方法。无论我们是否决定采用测试驱动的开发实践,思考我们的代码是如何被测试的都有实际的好处。在使用 TDD 方法时,以下问题总是会被考虑:

  • 这段代码是需要测试的吗,还是我们需要对这个代码进行回归测试?

  • 我该如何测试这段代码?

  • 别人会如何理解这段代码?

这些问题的答案通常通过单元测试来找到,当考虑单元测试时,我们应该考虑 TDD。

我们也可以利用我们的集成开发环境(IDE)及其可能附带的各种插件。这些工具可能包括可定制的格式化选项和默认的语法问题高亮显示。一旦我们的开发团队人数超过一个人,强制执行一些代码标准就具有实际的好处。

我们的公司可能会提供给我们使用的一些工具。这些工具可以帮助我们立即识别生成的代码中的问题。例如,在第三章中讨论的 Snyk 这样的工具可以扫描我们的代码以查找漏洞。至少,我们希望确保代码符合我们公司的政策和标准。

通过格式化和代码检查器减少认知负荷

认知负荷可以理解为我们在编码时需要在脑海中保持的信息量。有许多方法可以在代码中减少认知负荷。在代码中提供一致性有助于减少认知负荷。它还有助于扫描工具、入职培训和避免错误。

即使没有关于代码格式、注释和类似问题的担忧,开发者也有足够的事情要担心。如果我们认为缩进一行所需的空格(或制表符)数量不会引起任何人的不满,那么我们可能会得到一个令人震惊的教训。看似微不足道的格式化决策或编码实践可能会引起激烈的辩论。希望,通过采用有偏见的格式化工具,如使用 Python 时的 black 和在使用 SonarLint 时的代码检查器,许多问题已经得到了解决。

我们可能会发现,在金融科技领域用 COBOL 或 RPG 等语言编写的遗留软件受到 80 或 132 个字符行长的限制,在某些版本中,还需要在特定列(特别是 COBOL)中开始代码。类似的限制也存在于 RPG 和其他早期语言中。虽然现代语言在很大程度上已经摆脱了这些类型的限制,但开发者很快意识到可能自由度太大。今天,许多编程语言都有各种格式化工具,这些工具强制我们的代码遵循为该语言开发的标准。无论是 Perl(perltidy)、Go(gofmt)、JavaScript(Prettier)还是 Python(black),格式化工具都是我们在开始学习新语言时应立即调查的工具。

Linters 通过确保我们的代码保持清洁并避免常见的语言陷阱(例如识别未使用的导入、未使用的变量、无效的比较和有问题的类型转换等)来发挥类似的作用。SonarLint 是一种流行的 linters,适用于许多语言和 IDE。它还提供了与 SonarQube 的集成,这是一个帮助识别和管理问题的产品。其他 linters 也可用,例如在处理 JavaScript 或 TypeScript 时使用的 ESLint。

格式化工具和 linters 可以集成到您的 IDE 中,或者通过 IDE 中的插件提供。因此,开始使用它们并不是一件麻烦事。然而,请确保您使用的是团队批准的工具,并且配置正确。这些工具还可以帮助我们成为更好的程序员,因为它们经常告诉我们为什么这是最佳实践以及如何避免它。我们经常发现自己正在阅读为什么 SonarLint 标记了某些内容,或者查找有关最佳实践的额外示例或更多信息,因为 linters 标记了某些内容。

2.4 自动化测试

无论我们是手动编写解析 ACH 文件的初始脚本,还是使用生成式 AI 工具开始,我们都希望确保我们正在使用单元测试。我们更喜欢 TDD 方法,并且经常会发现我们更倾向于这种方法。然而,我们不必严格遵循这种方法来获得好处。我们寻求的是短的开发周期,混合测试和编码。此时,我们应有相当好的想法,即正确解析 ACH 文件将是一项相当复杂的任务。然而,读取文件并确保我们拥有所有行并不是一个无法克服的任务,我们应该能够足够容易地完成它。那么,为什么不从确保我们从文件中收到了所有行的单元测试开始呢?

一个持续的主题是需要高质量的软件和验证我们的结果,尤其是在生成代码的背景下。本节探讨了使用pytest进行单元测试以帮助验证生成的代码和我们自己编写的代码。我们还讨论了在编码时需要更小、更快的反馈循环的需求,并且我们将尝试在我们的代码中应用这一点。

在我们尝试为从记录类型中解析单个字段编写测试之前,我们可能需要问自己一些问题,例如

  • 我能读取文件吗?

  • 文件中有多少条记录?

  • 我能解析类型 1 的记录吗?类型 5 的呢?

我们从初始步骤开始,然后深入到我们代码的实际功能。在接下来的章节中,我们将从一个格式良好的示例文件开始,通过创建测试来逐步处理它。每个部分都将展示如何创建一个小型单元测试来验证我们的格式良好的文件。

2.4.1 测试读取的记录数量

在我们开始解决可能在我们生成的代码中注意到的任何问题之前,我们将测试从我们的 ACH 文件中读取的记录数量。这项测试涵盖了我们在本节开头列出的前两个要点。验证记录数量可能看似微不足道,但它有助于我们进入测试代码的心态,并帮助我们验证我们确实完成了这项关键任务。我们的单元测试需要验证文件中的记录数量。我们可以通过在大多数编辑器中打开文件来获取记录,或者我们可以使用 Unix 中的wc -l或 PowerShell 中的Get-ContentMeasure-Object来获取行数。如果我们不确定如何从文件中获取行数,我们可能想询问生成式 AI 是否有任何想法,也许还能学到一两个新技巧!

根据我们到达这里的方式,我们的测试可能因为缺少某种记录类型或其他错误(如错误的文件路径、权限等)而失败。现在,作为一个练习,在我们继续编写代码之前,最好先确保这个第一个测试能够正常运行。否则,我们也可以继续使用 GitHub 上提供的示例代码。

2.4.2 解析记录

你可以在achdevguide.nacha.org/ach-file-overview找到有关 ACH 布局的信息。让我们回顾一下文件格式的一些主要方面:

  • 它是一个固定宽度的 ASCII 文件。

  • 记录长度为 94 个字符。

  • 每一行都被称为一条记录。

  • 每条记录包含位于固定位置的字段。

  • 记录和字段的顺序很重要。

这种格式使得处理和验证文件更有趣。我们没有可以利用的 XML Schema Definition。我们也没有像 XML 和 JSON 那样提供格式化的自由。

虽然与其他文件格式相比有约束,但它是在其他格式出现之前就已经在使用了。对我们来说,确保正确处理文件并处理日常 ACH 处理中的某些任务将是一个有趣的挑战。我们在 IDE 中创建一个新的 Python 项目,并使用 ChatGPT 4 的响应作为起点。

列表 2.5 ACH 解析器的起点
def parse_file_header(line):
    return {
        'record_type': 'File Header',
        'priority_code': line[1:3],
        'immediate_destination': line[3:13],
        'immediate_origin': line[13:23],
        # ... additional fields
    }

def parse_batch_header(line):
    return {
        'record_type': 'Batch Header',
        'service_class_code': line[1:4],
        'company_name': line[4:20],
        # ... additional fields
    }

def parse_entry_detail(line):
    return {
        'record_type': 'Entry Detail',
        'transaction_code': line[1:3],
        'receiving_dfi_id': line[3:11],
        'account_number': line[12:29],
        'amount': line[29:39],
        # ... additional fields
    }

def parse_addenda(line):
    return {
        'record_type': 'Addenda',
        # ... additional fields based on the specific addenda type
    }

def parse_batch_control(line):
    return {
        'record_type': 'Batch Control',
        # ... additional fields
    }

def parse_file_control(line):
    return {
        'record_type': 'File Control',
        # ... additional fields
    }

def parse_ach(filename):
    records = []

    parser_functions = {
        '1': parse_file_header,
        '5': parse_batch_header,
        '6': parse_entry_detail,
        '7': parse_addenda,
        '8': parse_batch_control,
        '9': parse_file_control
    }

    with open(filename, 'r') as file:
        lines = file.readlines()

        for line in lines:
            record_type = line[0]
            parser = parser_functions.get(record_type)

            if parser:
                records.append(parser(line))
            else:
                print(f"Unknown record type: {record_type} in line: {line}")
                continue

    return records

# Test the function with an ACH file
parsed_records = parse_ach('path_to_ach_file.txt')
for record in parsed_records:
    print(record)

我们为项目创建了一个基本的文件夹结构,包括

  • ach_processor—我们的 Python 模块所在之处

  • docs—存放任何必要文档的地方

  • tests—存放我们的单元测试的地方

  • venv—我们的虚拟环境(用于将项目及其依赖项分离)

  • README.md—一个 Markdown 文档,可以了解更多关于项目和结构的信息

  • requirements.txt—一个 Python 依赖项列表,CI/CD 管道可以使用它来构建项目

列表 2.6 项目文件夹结构
ACH_Parser/
├─ ach_processor/
├─ docs/
├─ tests/
├─ venv/
├─ README.md
├─ requirements.txt

在设置好项目后,我们运行 black 命令来格式化源代码。使用 PyCharm 时,IDE 足够智能,能够看到已安装的包,并提示我们在 IDE 内设置 Black,因为 black 是开箱即用的。

完成这些后,我们可以开始处理 ACH 记录。在这个阶段,通过我们的探索性峰值,我们只是简单地解析记录。输入验证是处理任何类型数据的重要方面,因为它属于防御性编程。

防御性编程

防御性编程是一种积极主动的方法,用于处理用户在使用你的软件时可能出现的未预料到的输入、错误和一般性误用。例如,如果你要求用户输入一个介于 1 和 5 之间的数字,他们很可能会输入除了 1、2、3、4 或 5 之外的任何数字。你可以预料到他们会输入 a、b、%、1231482、不可打印字符以及广泛的其它输入!

输入验证、错误处理、安全默认值、输出清理、日志/监控以及静态分析等实践是防御性编程的一些方面。随着项目的进行,我们将牢记这些原则。

2.4.3 文件头记录(类型 1)

文件头记录包含有关文件来源机构和目的的重要信息。一些文件细节,如创建日期和时间,也可以帮助确定文件是否已被加载过,尽管我们还将依赖于对文件内容的哈希处理。

ChatGPT 的一个强大功能是能够记住对话;如果我们登录了 ChatGPT,我们可以进去要求它扩展 parse_file_header 例程的解析。这将给我们另一个良好的起点,然后我们甚至可以进去要求它为头记录创建单元测试。

然而,当我们尝试这种方法时,我们遇到了一些问题,例如文件创建日期是八位而不是六位(它使用了四位年份)。记录本身不是 94 字节长,这也导致了解析错误。我们使用了 sample.ach 文件和该头记录作为期望结果,然后针对该结果运行了pytest

让我们看看单元测试并将其分解(列表 2.7)。我们首先定义函数名,按照惯例,名称以test_开头,这有助于识别该函数是pytest要拾取并运行的。

接下来,我们有将要传递给解析例程的样本行。我们也使用expected_result,它被定义为一个字典。我们也可以直接将其嵌入到assert语句中,但为了清晰起见,通常更容易将其分开。通过使用字典,我们也利用了我们的集成开发环境(IDE)。例如,如果这个测试失败,PyCharm 提供了一个很好的比较窗口,我们可以看到差异在哪里。然后我们定义解析器,并使用我们的sample_header调用例程。

最后,我们有一个assert语句,这是实际检查发生的地方。如果结果与expected_result不同,我们将看到错误信息。我们可以包含尽可能多的assert语句。例如,我们可能需要在检查我们期望返回的字段之前,断言 HTTP 调用的响应是成功的。

对其他记录的后续解析也将遵循此代码模式,因此无论我们手动编写、复制粘贴还是使用生成式 AI,我们都应该能够为其他记录格式生成类似的测试。

列表 2.7 pytest解析文件头记录
def test_parse_file_header():
    sample_header = "101 267084131 6910 
➥001340402200830A094101DEST NAME 
              ➥ORIGIN NAME            XXXXXXXX" #1

    expected_result = {               #2
        "record_type_code": "1",       #2
        "priority_code": "01",         #2
        …                              #2
        "reference_code": "XXXXXXXX",  #2
    }                                  #2

    parser = AchFileProcessor() #3
    result = parser._parse_file_header(sample_header) #4
    assert result == expected_result, 
➥ f"Expected {expected_result}, but got {result}" #5

1 定义了一个我们想要测试解析器的记录

2 定义了一个包含解析字段的字典

3 创建了我们的 AchFileProcessor 类的实例

4 调用函数来解析文件头并返回结果。这个方法原本是私有的,但为了便于测试,我们直接访问它。

5 将返回的结果与期望的结果进行比较;如果不匹配,pytest 将抛出错误。

我们当然可以告诉 ChatGPT 为我们创建单元测试。如前所述,当我们使用这些工具时,我们寻求“信任但验证”。可能可以同时生成代码和测试,但鉴于我们还在努力更好地理解 ACH,这可能适得其反。例如,如果工具为我们生成了一个解析文件的糟糕函数,它也可能生成错误的单元测试。我们可能会得到看似通过并验证代码的单元测试,但最终发现它们都是错误的。在生成代码时,通过自己编写单元测试(或生成单元测试并编写代码),我们可以更好地理解它。

然而,查看在请求单元测试时可能生成的内容是有好处的。我们向 ChatGPT 提示

图片 给定以下 Python 函数来解析 Nacha ACH 文件头部,请为此提供单元测试。

我们看到了以下内容。

列表 2.8 ChatGPT 生成的 pytests
def test_parse_file_header_with_incorrect_length(self):
line = "101094000123"  # Truncated line
# Assuming your method raises a ValueError for incorrect input
with pytest.raises(ValueError):    #1
   self.parser._parse_file_header(line)

def test_parse_file_header_with_invalid_record_type(self):
line = "201 0940001234567890123200602161645A094101 
➥ Your Bank          Your Company       12345678"
# Assuming your parser validates the record type 
# and raises an exception for invalid types
with pytest.raises(ValueError):
   self.parser._parse_file_header(line)

def test_parse_file_header_with_extra_whitespace(self):
line = "1  094000123   4567890123 200602 1616A 09410 
➥ Your Bank          Your Company       12345678  "
# Assuming your parser can handle extra whitespaces gracefully
   expected = {
   "record_type_code": "1",
   "priority_code": " 0",
      "immediate_destination": "94000123",
   "immediate_origin": "4567890123",
   "file_creation_date": "20060",
   "file_creation_time": "2161",
   "file_id_modifier": "6",
   "record_size": "A 0",       #2
   "blocking_factor": "94",
   "format_code": "1",
      "immediate_destination_name": "Your Bank",
      "immediate_origin_name": "Your Company",
      "reference_code": "12345678",
   }
   # This test might fail based on the specific handling  
   # of spaces in your implementation 
   assert self.parser._parse_file_header(line) == expected

# Add more tests as needed for your implementation specifics

1 可以使用 pytest.raises 来验证是否抛出了特定的错误,这对于测试边缘情况和异常条件非常有用。

2 额外的空白导致了无效值的使用——最明显的是记录大小从 094 切换到“A 0”。

我们可以看到这些单元测试涵盖了行长度不正确、无效类型和额外的空白。无效类型和不正确的长度行可能是有用的测试,但我们不确定额外空白测试发生了什么。看起来它是想添加尾随空格,但它也在记录本身中添加了空格。由于这是一个固定长度的记录,这显然会导致后续的验证错误。尽管如此,它确实为我们提供了在达到那个点时进行额外测试的一些方向。此外,我们还看到了如何使用 pytest.raises 语法来验证预期错误。

当然,目前我们正遵循快乐路径,不是过多关注输入验证。我们只想看看我们是否能在这一阶段解析 ACH 文件。

2.4.4 批次头部记录(类型 5)

正如其名所示,批次头部记录表示特定公司的批次开始。每个文件可以有多个批次,并且批次中包含的所有条目和附加记录都属于该公司。

对于批次头部记录的解析,我们可以回到 IDE,让 Copilot 帮助我们开始,然后也可以要求它帮助我们定义一个 pytest。我们使用的提示如下:

图片 # 定义一个测试函数以测试使用 AchFileProcessor._parse_batch_header() 解析 ACH 批次头部记录。

我们可以在 test_parsing_file_header.py 中查看这一点。

虽然解析批次头部的初始函数看起来合理,但 pytest 本身需要更多的工作,特别是在将 sample_batch_header 传递给 _parse_batch_header 时。这一行有 181 个字符,超过了格式要求的固定 94 字节。看起来每个字段的名称都被放入了测试记录的位置。另外,经过进一步检查,我们发现它并没有包含记录格式中的所有字段。然而,当我们开始输入字段名称时,我们惊讶地看到字段被填充并使用了样本数据,尽管结果参差不齐。

尽管如此,这仍然是一个非常令人印象深刻的结果,足以让我们开始并使用我们一直在使用的 sample.ach 文件完成一个通过测试。在处理解析例程和预期结果后,我们能够通过另一个测试。

希望您能够自己解析这个记录并添加单元测试。如果不能,不要担心——还有更多类型等待尝试,有很多机会去尝试!尽管这可能有些过早,但我们还想看看行为驱动开发(BDD),并展示一些如果我们在考虑这种类型方法时可能会用到的示例测试。

什么是行为驱动开发?

行为驱动开发是一种软件开发方法,我们围绕软件的行为创建测试,然后将其用作确定项目是否完成的验收标准的一部分。这种类型测试的独特之处在于,所有利益相关者都紧密合作来开发这些场景。将这些用户需求正式化有助于确保我们构建的项目符合用户的期望。

每个 BDD 测试都通过一系列given/when/then语句来描述,这些语句直接关联到期望的功能。我们可以像对待单元测试一样,从我们的 IDE 中运行这些测试。区别仅在于测试的设计方法和执行级别。

随着我们项目的进展,我们将寻求扩展我们的 BDD 测试;目前,我们创建一个非常简单的测试来确认我们已经成功解析了记录类型字段。虽然我们已经有了一个确认这一点的单元测试,但这将是一个简单介绍如何设置 BDD 风格测试的好方法。

我们首先定义一个功能文件,它标识了我们正在测试的功能以及各种测试场景。功能文件是用一种可读的语言编写的,以便任何人都能理解它。《batch_header.feature》包含在代码中,将在下一个列表中展示。

列表 2.9 解析 ACH 批次头
Feature: Parsing a Nacha Batch Header

  Scenario: We have a record type of 5
    When We parse the batch header 
➥"5200Company name    DiscretionaryData   Company
 IDARCComp desc 0216232302160471061000010000001"
    ➥ Then The record type should be 5

我们定义了功能,然后是一个简单的场景:“我们有一个记录类型 5。”场景可以命名为任何名称,但显然,我们希望传达这个测试将要做什么。whenthen语句是实际工作发生的地方——在我们的例子中,我们在解析头记录时,然后我们想要确保我们有一个类型为 5 的记录。

一旦我们有了这些信息,下一步就是将功能文件与所谓的步骤定义连接起来。这就是我们将可读文本转换为可以用pytest执行的方法。为了简洁,我们将展示“then”步骤定义的一个示例。其余的代码在test_batch_header.py中可供您浏览。

列表 2.10 Python 中的行为驱动开发测试
@then(parsers.parse("the record type should be {type_5}"))
def then_record_type_code_should_be(record, type_5):
    assert (
        record["record_type_code"] == type_5
    ), f"Expected {type_5}, but got {record['record_type_code']}"

我们使用 @then 装饰器来装饰函数并解析文本字符串。在这个字符串中,我们有 {type_5}。这是一个动态值,将从特征文件中提取。因此,虽然我们使用了“记录类型应该是 5”,但 5 成为函数的参数,我们可以轻松地创建其他场景来测试其他值。然后我们定义函数,传递给它我们解析的值、一个记录(这是一个 Python fixture——稍后会更详细地介绍)以及一个我们之前见过的 assert 语句。然后这个测试可以与其他任何测试一起运行,当这个测试通过时,我们就完成了编码。当然,我们之前已经编写了这段代码,但在后面的章节中,我们将努力在处理功能之前定义这些场景。我们可能为每个字段提出额外的场景,或者简单地扩展测试中的then部分,为每个字段包含多个 then 语句。

2.5 条目详细记录(类型 6)

条目详细记录包含单个交易数据,包括账户号码和需要贷记或借记账户的金额。请注意,根据正在处理的记录类型,记录的解析可能会有所不同。批次标题记录包含批次中存在的条目记录类型,这被称为标准条目类(SEC)代码。

解析条目详细记录对 ChatGPT 来说更具挑战性,并且在我们为其制定的提示中也是如此。最初,我们尝试了扩展提示

image 完全定义 _parse_entry_detail方法并提供一个pytest` 来验证它。

虽然 ChatpGPT 提供了方法和 pytest,但它未能有意义地解析记录。我们再次尝试

image 请使用 NACHA 类型 6 记录的完整布局。

然后,ChatGPT 开始将类型 6 记录称为附加记录,并使用与该记录相关的字段来解析它。我们意识到存在不同类型的条目详细记录(CCD、CTX、PPD 等),因此我们尝试通过

image 请使用 NACHA 类型 6 CCD 记录的完整布局。

虽然系统正确地将类型识别为现金集中支付,但它仍然将条目称为附加记录。

使用 Copilot 产生了更好的结果,我们在键入时填充了正确的字段名称,并允许我们快速创建一个只需要稍作更新即可通过测试的模板。然而,到目前为止,我们看到的潜在主题是,虽然这两个工具都很强大,但它们需要我们验证结果,而不是盲目地插入。因此,我们可以看到,如果我们想能够验证我们的结果,了解领域知识是很重要的。

2.5.1 附加记录(类型 7)

附加记录包含适用于条目详细记录的额外付款相关信息。每个条目详细记录可能有多个附加记录。

当 Copilot 被提示时

图片 定义一个函数来解析 Nacha 附加记录。

它产生了几个代码建议。下面的列表显示了最接近的有效建议。它使用了所有必需的字段,但偏移量并不完全正确。

列表 2.11 附加记录解析函数
def parseAddendaRecord(record):
    # Create a dictionary to hold the parsed record
    parsedRecord = {
        'recordType': record[0:1],
        'typeCode': record[1:2],
        'paymentRelatedInformation': record[2:80],
        'addendaSequenceNumber': record[80:87],
        'entryDetailSequenceNumber': record[87:94]
    }

    return parsedRecord

除了一般的解析不正确外,我们更愿意使用蛇形命名法作为字典键。我们可以通过定义一个函数来再次细化提示,该函数使用字典和蛇形命名法解析 Nacha 附加记录。这为我们提供了我们偏好的键格式,但我们仍然需要更新偏移量。

列表 2.12 使用蛇形命名法的附加记录解析函数
def parseAddendaRecordSnake(record):
    # Create a dictionary to hold the parsed record
    parsedRecord = {
        'record_type': record[0:1],
        'type_code': record[1:2],
        'payment_related_information': record[2:80],
        'addenda_sequence_number': record[80:87],
        'entry_detail_sequence_number': record[87:94]
    }

    return parsedRecord

为了得到正确的解析,我们需要调整type_codepayment_related_informationaddenda_sequence_number。下面的列表显示了可以使用的更新后的返回语句。

列表 2.13 更新后的返回语句,包含已修正的字段
…    
return {
   "record_type_code": record[0],
   "addenda_type_code": record[1:3],
   "payment_related_information": record[3:83],
   "addenda_sequence_number": record[83:87],
   "entry_detail_sequence_number": record[87:94],
}
…

2.5.2 批次控制记录(类型 8)

批次控制记录是每个批次的尾记录,是一个必需的记录。我们使用记录中的字段,如总借记/贷记金额和记录计数,来验证我们是否收到了正确的批次内容。下面的列表显示了 Copilot 在解析此记录时采取了不同的方法。

列表 2.14 Copilot 解析批次控制记录
def parse_ach_batch_control_record(record):
# Define a dictionary to hold the parsed data
    parsed_record = {}

    # Define the fields in the record
    fields = [
        "record_type",
        "service_class_code",
        "entry_count",
        "entry_hash",
        "total_debit_entry_dollar_amount",
        "total_credit_entry_dollar_amount",
        "company_identification",
        "message_authentication_code",
        "reserved",
        "originating_dfi_identification",
        "batch_number"
    ]

    # Define the field lengths
    field_lengths = [
        1, # record_type
        3, # service_class_code
        6, # entry_count
        10, # entry_hash
        12, # total_debit_entry_dollar_amount
        12, # total_credit_entry_dollar_amount
        10, # company_identification
        19, # message_authentication_code
        6, # reserved
        8, # originating_dfi_identification
        7 # batch_number
    ]

    # Define the field start positions
    field_start_positions = [0]
    for field_length in field_lengths:
        field_start_positions.append(field_start_positions[-1] + field_length)

    # Parse the fields
    for i in range(len(fields)):
        field = fields[i]
        start_position = field_start_positions[i]
        end_position = field_start_positions[i + 1]
        parsed_record[field] = record[start_position:end_position]

    # Return the parsed record
    return parsed_record

这段代码看起来有点过度设计,因为这种广泛使用的固定位置文件格式的本质意味着字段位置不会发生变化。就个人喜好而言,我们更喜欢看到实际使用的偏移量,如列表 2.12 所示。偏移量也会使我们在处理解析记录中的错误时工作更轻松。将字段定义为'entry_detail_sequence_number': record[87:94]意味着我们知道字段entry_detail_sequence_number的开始和结束位置。我们也愿意让这些魔法数字存在于代码中,因为它们仅限于这个特定区域,而不是散布在整个代码中。当然,我们也可以创建名为BEGIN_ENTRY_DETAIL_SEQUENCE_NUMBER_POSEND_ENTRY_DETAIL_SEQUENCE_NUMBER_POS的变量,并在找到令人信服的理由时使用它们。现在,让我们来看看解析文件尾记录。

2.5.3 文件尾记录(类型 9)

在 ACH 文件中的最后一个记录是文件尾记录(也称为文件控制记录)。文件尾记录提供了诸如批次计数和条目/附加条目计数等字段,我们使用这些字段来验证文件是否正确接收。请注意,所需的格式要求记录数必须是 10 的倍数。因此,你可能会发现文件或软件会使用全部为 9 的记录来填充创建的 ACH 文件。然而,大多数软件现在不再需要这样做。

在下面的列表中,我们又回到了 Copilot 按照我们预期的样子解析记录。所有字段都被转换为整数。

列表 2.15 解析文件尾记录
def parse_ach_trailer_record(record):
    return {
        "record_type": record[0],
        "batch_count": int(record[1:7]),
        "block_count": int(record[7:13]),
        "entry_count": int(record[13:21]),
        "entry_hash": int(record[21:31]),
        "total_debit_amount": int(record[31:43]),
        "total_credit_amount": int(record[43:55]),
    }

虽然这段代码正确地解析了记录,但我们缺少最后一个字段,该字段被标记为保留。因此,尽管现在可能并不必要,但我们仍然可能想要考虑为了完整性而包括它。

我们还可能想要创建一个示例 BDD 测试,因为,理论上,主题专家(SMEs)可能会为我们提供解析此记录(或任何这些记录)的特定用例。BDD 风格的测试可能看起来像以下列表。

列表 2.16 类型 9 的 BDD 风格测试——文件尾记录
Feature: Parsing a Nacha File Trailer

  Scenario: We have a record type of 9
     Given the File Trailer Record "9000010000010000000740198019800000000007825000114611480"
     When We parse the file trailer
     Then the record type should be 9
     And the batch count should be 10
     And the block count should be 1
     And the entry count should be 74
     And the entry hash should be 0198019800
     And the total debit amount should be 78.25
     And the total credit amount should be 1146114.80

2.5.4 通过了!

哇,我们做到了!那是一阵编码和测试的狂潮。即使有生成式 AI 的帮助,这也需要很多吸收。现在让我们回顾一下我们刚刚完成的事情。我们开始使用小开发周期来构建单元测试,以验证各种 ACH 记录类型的解析。重要的是要理解,我们是将生成式 AI 创建的程序分解成函数开始的(在我们开始之前,我们要求 ChatGPT 为我们做这件事)。让生成式 AI 创建函数使我们能够更容易地为每种记录类型创建单元测试。否则,我们就必须想出一种方法来确定记录是否被正确解析,并在文件加载后进行检查。

每次我们都是从创建一个应该失败的单元测试开始,然后编写足够的代码使其通过。在这个时候,我们应该相对熟悉这个过程,并准备好将其应用到项目的其他部分。

2.6 不太愉快的路径

恭喜!我们刚刚解析了我们很可能的第一个 ACH 文件。我们专注于有时被称为“愉快的路径”的部分。这是所有事情都按预期进行,没有抛出任何错误的地方。我们在整个解析过程中使用了一个格式良好的单个 ACH 文件来展示这个过程。

我们现在也应该考虑不太愉快的路径,这可能是我们在日常编码中更有可能遇到的。我们的不太愉快的路径将涵盖在加载文件时可能发生的一些问题。存在文件或批次可能被拒绝或条目可能触发拒绝的场景。我们将在第九章中进一步探讨异常处理和从 ACH 文件问题中恢复。目前,我们只想简要提及一些可能性。

我们将确定一些可能的拒绝场景,并提供用于编码这些场景的示例 ACH 文件和pytests。当一个文件被拒绝时,我们通常必须回到原始发送者那里请求一个新的文件。如果有任何交易已经过账,它们可能需要在拒绝过程中进行撤销。目前,我们更专注于识别坏文件,而不是从它们中恢复。当然,如果您想跳过,我们会提供一个解决pytests的完整示例。显然,我们会鼓励您通过这些场景,因为我们选择的特定场景不需要对 ACH 处理有广泛的知识。

随着项目的扩展,我们还将添加进一步的验证。目前,我们只是在扩展我们的概念验证。

2.6.1 文件拒绝

当格式不正确时,ACH 文件可以被拒绝。记住记录的顺序很重要。一个 ACH 文件由批次组成,每个批次包含条目和附加记录。一个批次和整个文件都有尾记录。所有记录都应该是 94 个字符长。因此,对于第一个场景,我们希望处理可能不是正确长度的记录的文件。

为什么会产生记录长度小于 94 字节的文件?在 SFTP(安全文件传输协议)变得普遍之前,当 ftp 设置为截断尾部空格时,我们会遇到这种情况。虽然 ftp 不像以前那样普遍,但它仍然可以用于内部文件传输,因此原始用例可能是有效的。此外,文件可能被路由和重新传输,甚至可能在某人的计算机上创建/更新。随着银行的合并和收购,这些问题可能仍然会出现。由于 Nacha 规范要求 94 字节的记录,我们将寻求强制执行这一点。我们已经包含了包含空单元测试的 ACH_Parser_v3,以帮助我们验证我们的更改。

对于这些挑战中的每一个,我们期望解析器累积一个错误列表。这个列表应该希望使解析代码相对简单,因为我们目前并不担心输入错误。这种方法还有一个额外的优点,就是能够在单元测试中验证预期的结果。如果我们知道我们的文件有两行短,我们也应该期待两条错误信息。目前,我们只会在错误信息是一个字符串时记录它,但你可能需要扩展你的项目或选择另一种方法。

代码挑战;优点 1

我们从一个相对简单的程序开始解析我们的 ACH 文件。回到并处理这些更改将帮助我们积累一些实际经验,因为我们很可能会更多地维护和更新现有代码,而不是从头编写代码。

检查记录长度的相对简单更改迫使我们处理异常,并思考我们可能希望如何识别、格式化和存储它们。这也给我们带来了如何编码的一些选择:我们是否继续将这个未解析的记录与其他记录一起存储?如果不是,它去哪里了,用户如何知道哪个记录有错误?

看起来微不足道的选择有时会对未来产生重大影响。所以,这仅仅是我们需要注意和留意的事情,在我们处理程序时。

2.6.2 批量拒绝

单个批次也可能被文件拒绝,其中一个原因是跟踪号不是递增的。跟踪号出现在每个条目详细记录(类型 6)上。正如其名所示,这些跟踪号提供了一种在批次内识别 ACH 交易的方法。跟踪号的前八位是发起存款金融机构(ODFI)的路线号——换句话说,交易来自哪家银行或金融机构——跟踪号的最后七位是递增的(但不一定是连续的)。

唯一的跟踪号提供了一种对 ACH 交易进行核对和报告的手段,以及通过支付系统追踪交易的能力,这对于监管和合规原因都是必要的。我们现在可以扩展 ACH 解析器以考虑这些跟踪号。再次,ACH_Parser_v3 有一些单元测试,我们可以使用它们来验证我们的程序是否正确解析了文件。

代码挑战;好处 2

这个挑战在如何选择处理这些异常的位置和时间上变得更加复杂。我们最初以函数名为 parse_entry_detail 开始。如果我们选择更新代码以在该解析例程中处理验证,我们会更新该函数名,因为它不再只是解析吗?一个例程会调用另一个例程,还是我们会调用两个例程或只使用一个例程?我们是先解析记录然后检查跟踪号,还是先检查跟踪号然后再解析整个记录?

有时,我们可能因为时间和复杂性的原因,希望以手术般的精确度进行更改。其他时候,我们可能选择散弹枪方法,当有多个任务必须完成时。无论哪种方式,你都应该仍然在短周期内工作,确保经常测试。

2.6.3 条目拒绝

如果附加标志与附加记录的存在不一致,则条目可能会被拒绝。条目详细记录包含一个标志,指示下一个记录是否是附加记录。附加记录包括 ACH 交易的其他信息,有时对于特定的 SEC 代码是必需的。由于只有 94 个字节可以工作,有时需要附加记录来传递有关交易的其他附加信息。

代码挑战;好处 3

编写这个挑战需要我们向前或向后看,因为我们需要能够确定是否期望有一个附加记录。这回到了做出决定然后稍后处理它们。

例如,现有的代码已经读取了所有行,然后使用 for-each 循环遍历它们。拥有 for-each 循环很方便,但现在我们可能需要一个方法来索引我们的行列表。如果我们需要节省内存,因为我们预计会有大的 ACH 文件,我们可能不想读取所有行,因此,我们就必须采取另一种方法来找到附加记录。

这都是软件开发过程的一部分。思考和规划我们的变更将帮助我们解决一些这些挑战,并希望使我们的代码面向未来。

当我们遇到我们或同事做出的不良设计选择时,要从中学到东西。即使它令人烦恼并让你重写大量代码,你也能从这次经历中学到一些东西!

2.7 解释代码

根据你对 Python 的经验,你可能会对作为本示例一部分生成的代码有一些疑问。以下章节将更详细地检查代码,以提供对我们所编写的 Python 代码的额外见解,包括 Python 的switch语句、类型提示和安全的编码。

2.7.1 我的switch语句在哪里?

对于 ACH,我们需要解析六种记录类型。如果你有一些编程经验,你很可能遇到过用于流程控制的if/if else/else结构。在 Python 中,我们会看到这作为

if condition1:
elif condition2:
else:

在某个时候,我们可能也编写了相当大的if语句,包含多个条件,在有人向你介绍switch/case语句之前。在 Python 早期(大约 2006 年),对switch/case语句的支持被讨论并最终被拒绝。然而,截至 Python 3.10,支持match语句(peps.python.org/pep-0634/),它提供了创建switch语句的手段,如下一列表所示。

列表 2.17 使用match创建switch语句
parsed_record = match record_type: #1
     case '1': #2
         self._parse_file_header(line)
…
     case '7':
            self._parse_addenda(line)
…
     case _: #3
            print(f"Unknown record type: {record_type} in line: {line}")
            continue

1 使用 match 关键字为 record_type 字段创建情况

2 每个条件都有一个情况语句。

3 默认情况用下划线标识。

match构造可用之前,已经编写了数百万行 Python 代码,生成式 AI 可能会向你展示if/elif或其他常见的创建字典以存储选择的实践,就像在示例程序中所做的那样。

列表 2.18 使用字典
parser_functions = {                       #1
            "1": self._parse_file_header,   #1
            "5": self._parse_batch_header,  #1
            "6": self._parse_entry_detail,  #1
            "7": self._parse_addenda,       #1
            "8": self._parse_batch_control, #1
            "9": self._parse_file_control,  #1
        }                                   #1
…
                parser = parser_functions.get(record_type) #2
…
                    self.records.append(parser(line)) #3

1 定义一个可以调用的函数字典

2 根据 record_type 从字典中检索函数

3 调用给定行的解析器并将结果追加到记录变量中

这段代码声明了每种类型的函数并调用相应的函数。有一件事可能会让我们倾向于另一种方法,那就是我们无法看到参数是否应该传递给函数。我们甚至可能无法确定它们是否是函数。处理这个问题的一种方法就是使用类型提示。

2.7.2 类型提示

类型提示自 Python 3.5 以来一直存在,并在peps.python.org/pep-0484中定义。虽然 Python 使用鸭子类型,但类型提示的添加可以帮助使代码更易于维护。在我们看来,这回到了在编码自由和代码持久性之间做出选择的问题。在编写 Python 代码时,不受静态类型约束是件好事;相反,我们可以更多地关注代码。然而,随着越来越多的 Python 代码出现,或者当我们必须开始查看他人的代码时,我们希望有一些这样的指南。因此,尽管 Python 在运行时不强制执行类型提示,但有一些工具可以根据这些类型提示以及仅基于文档的好处来进行静态类型检查。

我们还想知道 Python 的流行以及开发者从其他语言迁移到 Python 是否也带来了一些他们的负担。我们认为这些语言特性的添加很重要,并且随着它们通过了 Python 增强提案(PEP)流程,社区也同意这一点。当然,我们的背景是在静态类型语言中,所以这些是有意义的。如果我们来自 LISP 或 Scheme,我们会希望看到语言中添加一大堆括号吗?

我们在采用字典方法进行流控制时遇到的原问题是,我们没有一种方法来确定可以传递给我们所调用的函数的参数。我们可以更新parser_functions以使用类型提示:

parser_functions: Dict[str, Callable[[str], Dict[str, str]]] = {

变量parser_functions现在被定义为包含一个字符串(str)作为记录类型和一个表示被调用函数的Callable的字典。Callable接受一系列参数,在这种情况下,我们看到它接受一个用[str]表示的字符串,并且它返回一个字符串字典,这是我们解析的记录。

在看到这个类型提示之后,我们可能决定重写我们的代码以利用那些其他构造之一,因为这看起来很混乱!通过采用类型提示的使用,我们就可以转向静态类型检查器,如 mypy、Pyright 和 Pyre。在我们看来,静态类型检查对于有大型团队或项目长期存在的企业应用来说是必不可少的(我们假设它们将是)。其他第一次或中断项目后进入代码库的开发者会发现它们非常有帮助。当然,Python 将保持动态类型语言(见mng.bz/oKEM),但工具的普及和它们的采用应该表明类型检查至少有一些好处。

2.7.3 安全编码

人们担心生成式 AI 可能不会生成安全或甚至良好的代码。这不仅适用于生成式 AI,也适用于人类:我们都会犯错误。回到我们的工具类比,我们需要记住生成式 AI 只是我们应使用的另一个工具,这就是我们为什么提倡使用格式化和 linters 来帮助我们识别代码中的问题。

企业使用市场上可用的多种工具来扫描其代码,寻找缺陷和安全漏洞。这些工具包括 Fortify on Demand、Veracode、Snyk 和亚马逊的 CodeWhisperer。这些软件的主要目标是寻找不安全的编码模式。他们经常使用 OWASP 10、SANS Top 25 和其他列表作为他们识别问题的指南。

许多时候,这些操作被纳入 CI/CD 管道中;然而,我们也有选项在 IDE 内部处理它们。由于 The Fantastic Fintech Company 使用 JetBrains,我们可以利用他们的一些内置功能。例如,在我们的 Python 项目的要求.txt 文件中,PyCharm 将检测我们的依赖项中的漏洞(见图 2.5)。我们可以看到通用漏洞和暴露(CVE)ID 和漏洞的简要描述。

计算机错误截图  自动生成的描述

图 2.5 一个示例依赖项

通过点击更多操作,我们可以简单地选择更新到较新版本,如图 2.6 所示。当然,这个操作需要进一步的应用程序测试,但在我们的代码甚至进入 CI/CD 管道之前就提醒潜在问题当然是非常好的。

计算机错误截图  自动生成的描述

图 2.6 处理漏洞的选项

很可能大多数公司都已经采用或正在评估静态分析工具。允许开发者直接在 IDE 中查看和调试问题,是提高生产力的另一种方式。

摘要

  • 我们探讨了如何将生成式 AI 纳入我们的开发工作流程,学习如何提问(提示工程)以及如何明确我们的意图(问题制定)。

  • 我们专注于使用如发布节奏和缺陷积压等指标来现代化运行在旧式主机架构上的 ACH 系统。

  • 我们利用 ChatGPT 和 GitHub Copilot 来更好地理解 ACH 文件布局,同时确保不泄露任何机密数据。

  • ACH 网络促进了银行之间无缝的资金转账,它通常用于工资支付和账单支付等交易。

  • ACH 文件是具有特定记录类型的固定宽度 ASCII 文件,需要仔细解析文件头、批次头、条目详情、附加信息、批次控制和文件尾记录。

  • 可以使用单元测试和 TDD 来验证 ACH 解析,以获得可靠、高质量的软件。

  • 可以使用 SonarLint 和 black 等 linters 和 formatters 来强制执行编码标准并减少认知负荷。

  • 对于可能存在错误的 ACH 文件场景,可以使用考虑意外输入和边缘情况的稳健代码来处理。

  • 在 Python 中实现类型提示可以增强代码的清晰度和可维护性,这有助于更轻松的协作和入职。

  • 可以通过使用静态分析工具来识别编码漏洞并确保符合安全标准来解决安全担忧。

  • 本章重申了稳健测试实践、迭代开发和利用 IDE 插件早期发现潜在问题的重要性。

第二部分:通往最小可行产品的道路

可行的产品

现在你已经了解了 ACH 文件的结构,是时候开始构建一个功能性的 ACH 仪表板了。如果听起来很令人畏惧,别担心!我们首先构建各种组件,然后将它们全部组合成一个最小可行产品(MVP)。

第三章从使用 Docker 设置环境开始。在这里,我们探讨了 Docker 的基本知识以及如何在构建项目时使用它。

第四章将使你通过利用 FastAPI 框架来熟悉在 Python 中使用 API。构建基本的 RESTful API 以支持仪表板,并使用 OpenAPI 探索文档将使你忙碌。

第五章讨论了在 Postgres 关系型数据库中存储 ACH 文件的数据库设计。你将有机会设计数据库,并看到不同设计的一些优点和缺点。

第六章探讨了使用 Next.js 创建用户界面,从后端过渡到前端。

第七章将前几章创建的所有组件汇集在一起,组成一个最小可行产品(MVP),你将能够将其交付给利益相关者。

在本部分的结尾,你应该拥有一个你可以引以为傲的 ACH 仪表板。这实际上是项目的起点。项目上线后,你将开始收到来自利益相关者的各种增强请求。

第三章:开始使用 Docker

本章涵盖

  • 使用 Docker 和 Docker Compose 为我们的项目组件创建容器

  • 优化 Docker 容器和镜像以提高安全性和维护性

  • 实施通用的 Docker 维护以确保我们的容器和镜像不会消耗磁盘空间

  • 使用健康检查来确保系统处于运行状态

在上一章(或者如果我们从敏捷的角度思考,是冲刺),我们创建了一个功能性的(尽管是基本的)ACH 解析器。我们还尝试了生成式 AI 工具来帮助我们更快、更有效地工作。我们在探索工具和掌握 ACH 处理方面取得了一些进展。享受这种感觉吧,因为肯定会有一些日子,当我们离开电脑时会感到完全筋疲力尽。

到目前为止,我们已经得到了项目需求和一个关于最终项目应包含内容的总体框架。当然,在处理项目时,我们可能会采取不同的方法,每种方法都有其优缺点。虽然我们可能最终会倾向于一种方法而不是其他方法,但在处理项目时,我们总是需要考虑一些因素,例如

  • 项目的大小和复杂性与我们团队的经验和知识相比如何?

  • 项目需求是否已经明确定义,或者业务/客户还在探索项目?需求与项目分配的时间如何比较?

  • 对于像错误、延误、新技术等类似项目,我们的风险容忍度是多少?

  • 我们是否有现有的项目基础设施,例如测试环境和自动化构建?如果没有,我们是否需要考虑构建该基础设施,或者它会在以后进行?

虽然在担任更多领导角色(技术负责人、架构师等)时,前面的观点可能显得更为重要,但让每个人都考虑这些观点是有益的,因为不同的观点总是有用的。我们只需要确保我们的反馈是建设性的,而不是攻击我们的同事(我们应当注意,在明显任何反馈都会被忽视的情况下要小心)。让我们回到手头的项目上来。

3.1 从哪里开始

首先,你可能没有选择权来决定你能够做什么工作。业务需求可能规定你必须按照这个顺序来工作这些部分。然而,假设你可以在哪里开始有选择权,你会选择什么?你可以从一个你感到舒适的部分开始——也许你是一个强大的 Python 程序员,所以你想完成对 ACH 解析器的工作。

或者,也许你应该选择你最不熟悉的事情。也许你以前从未使用过 Next.js,你想要确保你有足够的时间投入到这部分,因为你觉得其他部分不会花费太多时间。

我们喜欢从整个项目开始!好吧,不是整个项目,但我们想看看我们是否能让所有部件以某种形式相互交流。即使它只是一堆“Hello, World!”程序和组件,至少我们把部件组合在一起了,然后我们可以捡起来,开始构建它们,并让它们发展。这完全取决于你——你同样可以跳到其他章节,让各种组件启动运行。这一章的重点是设置 Docker 环境,然后放置我们的基本项目组件。后续章节将在此基础上构建,但到目前为止,我们想要创建一个类似于图 3.1 所示的环境。

如你所见,这个环境将为我们提供一个运行 Node 和 Next.js 的 UI 容器,一个由 FastAPI 提供动力的 API 容器,以及一个运行 PostgreSQL 的数据库容器。在 Docker 内部运行这些容器还允许引入其他

计算机程序图  自动生成的描述

图 3.1 我们 ACH 系统的概述

我们将在其他章节中探讨的组件。虽然这个项目采用了一种更面向服务的架构方法,但使用 Docker 将我们的软件分解的一般方法为其他架构模式铺平了道路。目标不是让你成为 Docker 专家(如果你想成为专家,可以查看 Jeff Nickoloff 和 Stephen Kuenzli 所著的《Docker in Action》(2019,Manning)),而是向你展示如何使用它来探索不同的技术和软件,而无需在主机机器上安装和配置它。这真的有助于你保持更整洁的秩序。

单体架构是否已经死亡?

由于我们使用容器,你可能已经听说过微服务以及它们是如何成为最新和最伟大的,你可能会想回到公司寻找任何单体软件,并开始坚持认为他们做错了。我们建议你不要这样做。仅仅因为某件事物是新的,并不意味着它就是更好的。单体架构可能有其缺点,但技术领域没有万能的解决方案。为了改变而改变不仅是不必要的——它可能对软件有害。

因此,我们建议,如果它没有坏,就不要去修复它。如果你的公司运行着单体软件,它可能在你离开公司很久之后仍然在运行。只有在你确定了架构的问题之后,你才应该寻找替代方案。

单体架构在开发概念验证时也非常有用,因为你可以将所有内容捆绑并部署在一个简单的包中。我们相信每件事物都有其时间和地点,因此虽然我们鼓励前瞻性思维和工程实践,但请记住,敏捷的一部分是寻找在正确的时间交付正确的产品。可能现在并不是探索迁移到容器的好时机。

3.2 创建 docker-compose.yml 文件

如果你之前从未使用过 Docker,那么你将真正享受到一份大礼。Docker 允许我们创建和分发各种容器。容器可以看作是一种微型虚拟机,你可以以声明的方式构建它,并且可以被他人拉取。如果你是 Java 程序员,可以想想你的 pom.xml 和 Maven,在那里你可以基本上描述你的项目并将该文件提供给他人,以便他们可以使用 Maven 拉取必要的文件。容器类似,但它们工作在更大的规模上。在图 3.1 中,我们展示了具有多个容器运行 Nginx、FastAPI 和 PostgreSQL 的解决方案。当与多容器应用程序一起工作时,我们希望能够从集中位置管理一切,这就是 Docker Compose 发挥作用的地方。我们可以声明一个 docker-compose.yml 文件,并在一个位置配置所有应用程序、容器和服务。

如果你刚开始使用 Docker,你可能想尝试使用 Copilot,但我们的尝试并没有产生任何有意义的成果。我们尝试使用以下命令与 Copilot 一起使用:

	# Create a sample docker compose layout for Nginx, CloudBeaver,

那个提示的结果显示在下述列表中。不幸的是,它并没有生成任何实际的可执行代码。

列表 3.1 Docker 设置尝试失败
# Create a sample docker compose layout for 
➥ nginx, cloudbeaver, postgres, and fastapi
# This is a sample docker compose file for a fastapi project
# It is not meant to be used in production
# It is meant to be used as a starting point for a fastapi project

直接询问 ChatGPT 我们得到了更好的结果,如下一列表所示,因为它提供了一个实际的 compose 文件。

列表 3.2 ChatGPT 生成的 docker-compose.yml
version: '3.7'   #1

services:
  nginx:
    image: nginx:latest
    container_name: nginx_container
    ports:
      - "80:80"
    volumes:
      - ./nginx:/etc/nginx/conf.d
    depends_on:
      - web

  web:
    build:
      context: .
      dockerfile: Dockerfile.fastapi    #2
    container_name: fastapi_container
    volumes:
      - ./app:/app
    expose:
      - "8000"
    depends_on:
      - db

  db:
    image: postgres:latest
    container_name: postgres_container
    environment:
 POSTGRES_USER: user #3
 POSTGRES_PASSWORD: password #3
 POSTGRES_DB: sample_db 
 volumes:
  - postgres_data:/var/lib/postgresql/data

  cloudbeaver:
 image: cloudbeaver:latest
 container_name: cloudbeaver_container
 ports:
  - "8080:8080"
    depends_on:
      - db

volumes:
  postgres_data:

1 版本标签已经过时,但即使 3.7 在 ChatGPT 提供这个答案时也已经过时;这是需要验证响应的另一个例子。

2 ChatGPT 给我们提供了一个非标准的名称 Dockerfile.fastapi;相反,我们应该使用没有扩展名的 Dockerfile 或 fastapi.Dockerfile(见 mng .bz/xKoB);这是需要验证响应的另一个例子。

3 这些环境变量允许为 Postgres 数据库进行配置。

这些是良好的开端,但我们需要退一步,以便我们可以分解代码并得到更短的内容。因为我们过度依赖生成式 AI,所以我们开始得比平时更大,这是一个我们应该吸取的好教训。我们讨论了需要快速反馈循环和短周期的必要性。在一次会话中更改太多代码从来不是一个好主意,因为当我们验证我们的更改时,我们不确定实际上解决了我们的问题。因此,在没有充分了解需要发生什么的情况下,让生成式 AI 构建四个或五个容器是一个糟糕的想法。

现在我们已经吸取了现实的一课并退了一步,让我们选择一个容器来启动并运行。

3.2.1 创建 CloudBeaver 容器

我们将首先构建一个 CloudBeaver 容器。CloudBeaver 是一个数据库管理工具,通过简单的基于网页的用户界面支持多种数据库。数据库管理界面并不少见。如果您正在使用 Postgres 数据库,也可以查看 pgAdmin (www.pgadmin.org/),或者使用 JetBrains 的 DataGrip (www.jetbrains.com/datagrip/),因为我们将在项目中使用其他 JetBrains IDE。我们没有对任何特定产品有投资利益,只是希望分享我们在 FinTech 中看到的使用情况。

我们安装 CloudBeaver 是因为我们想有另一种方式查看和管理我们的数据库,而不仅仅是使用代码。您的公司可能有一个首选的工具,您可以自由使用它。然而,CloudBeaver 非常容易启动和运行。

那么,为什么我们在数据库之前设置数据库管理工具呢?这是一个好问题。因为 CloudBeaver 是一个网页界面,我们可以通过打开浏览器并导航到 URL 来简单地确认容器是否正在运行。因此,CloudBeaver 提供了一种快速简单的方法来开始并验证我们的进度。另一种选择是使用我们的 IDE,因为 IDE 通常提供集成(或插件)来浏览数据库。当然,使用我们的 IDE 会脱离 Docker,而我们正在探索使用 Docker 的不同容器。

考虑到这一点,让我们通过提示 ChatGPT 来开始:

image 在 Docker Compose 中仅创建一个基本的 Cloudbeaver 服务。

我们收到了以下输出。

列表 3.3 ChatGPT 的示例 CloudBeaver 容器输出
version: '3.1'      #1
services:
  cloudbeaver:
    image: dbeaver/cloudbeaver:latest
    container_name: cloudbeaver
    ports:
      - "8080:8978"

1 版本标签已过时,但即使 3.1 版本在 ChatGPT 提供这个答案时也已经过时;这是一个需要验证 ChatGPT 响应的例子。

输出看起来是合理的,但我们看到版本号是 3.1。最新的 Docker Compose 版本是 3.8,但现在 version 标签已被项目标记为过时。虽然我们目前还可以指定它,但使用它将显示警告消息。如果它将来成为错误,这肯定会引起问题。因此,我们告诉 ChatGPT:

image 更新基本设置以删除 Docker Compose 版本,因为它现在已经过时。

这样,我们的文件就更新了。从我们的对话中更新输出的能力是生成式 AI 的一个强大方面,我们应该记住。当然,我们也可以简单地从生成的文件中删除它;然而,我们希望我们的更新提示能够反馈到系统中,我们最终将不再在生成的文件中获得 version 标签。

我们可以将列表 3.3 的输出复制粘贴到 docker-compose.yml 文件中,导航到文件夹,然后执行 docker-compose up 命令。在我们的机器上,由于以下错误,它未能完成此操作:

Bind for 0.0.0.0:8080 failed: port is already allocated 

这个错误相当直接,但记住你正在运行可能使用该端口的程序并不总是那么直接。我们也可以更新我们的 docker-compose 文件以使用不同的端口,但在这个时候,我们可以通过在 Windows PowerShell 中使用netstat -aon | findstr "8080"然后tasklist | findstr PID(PID 是我们刚刚遇到的进程 ID,它们在右侧)来找到那个引起问题的进程。一旦你这样做,你可以决定是否要关闭/停止你正在运行的任何程序,或者调整正在使用的端口。在我们的情况下,另一个 Docker 容器正在使用,所以可以安全地关闭。在这个时候,运行docker-compose up是有效的,我们可以导航到 http://localhost:8080/并看到图 3.2 所示的 Web 界面。

计算机截图  自动生成的描述

图 3.2 CloudBeaver 欢迎屏幕

3.2.2 创建一个 PostgreSQL 容器

现在事情会变得更有趣,因为我们有机会添加一个数据库容器。最终,数据库将存储我们的 ACH 文件和相关数据,但现在我们将保持简单。我们还可以在容器启动时填充数据库,这样我们就可以使用 CloudBeaver 连接到数据库并查看我们的数据。我们可以要求 ChatGPT 简单地

图片 更新 docker-compose 文件,以包括一个postgres服务。

再次强调,这提供了一个有利的起点,如下一个列表所示。

列表 3.4 ChatGPT 生成的多个容器
version: '3.8'
services:
  cloudbeaver:
    image: dbeaver/cloudbeaver:latest
    container_name: cloudbeaver
    depends_on:
      - postgres
    ports:
      - "8080:8978"
    environment:
      - CB_DATABASE_URL=jdbc:postgresql://postgres:5432/postgres
      - CB_DATABASE_USERNAME=postgres
      - CB_DATABASE_PASSWORD=postgrespwd

  postgres:
 image: postgres:latest
 container_name: postgres
 environment:
 POSTGRES_DB: postgres
 POSTGRES_USER: postgres
  POSTGRES_PASSWORD: postgrespwd
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"

volumes:
  postgres_data

然而,让我们退一步,构建一个更简单的文件,这将在这个时候更好地为我们服务,因为我们仍在尽可能遵守 YAGNI(你不需要它)的原则。一些纯粹主义者可能会争论,甚至创建这些 Docker 容器也违反了这一原则。目前,我们可能并不需要我们项目的任何这些——我们甚至可能不需要数据库。也许,我们可以通过只写入文件或保持数据在内存中而逃脱!这当然可能是真的,但我们可以将这视为现有代码,因为我们的应用程序是基于 PostgreSQL 构建的,所以不管我们是否真的需要它,这都将是一个要求。

什么是 YAGNI?

YAGNI 是“你不需要它”(You aren’t going to need it)的缩写。它是极限编程的一个原则,并且与我们的敏捷原则(如最小可行产品 MVP)相联系,通过试图强制执行避免不必要的工作和复杂性的想法。有时候,在编码时,很难抗拒添加那些你很确定将来会需要的额外功能,所以你可能会选择现在就编写它。然后,那段代码从未被使用,但被静态代码分析标记为需要修复,并在更新某些对象时需要更改。

所以,至少要记住这个原则,以避免额外的复杂性。

让我们回到构建我们的容器。我们添加一个简单的容器,如下所示(你可以查看 GitHub 上提供的 v2/docker 文件夹中本章的整个文件):

  Postgres:
 image: postgres:latest
 environment:
  POSTGRES_USER: admin
      POSTGRES_PASSWORD: secret

通过这种方式,我们可以导航到 CloudBeaver,使用 http://localhost:8080 并设置连接;然而,我们没有数据可以查看。我们希望能够在容器构建时加载数据,所以我们将现在查看如何实现这一点。注意,我们已从生成的 Dockerfile 中删除了网络和卷字段,因为我们将依赖于 Docker 设置的网络。默认情况下,所有在 docker-compose 文件下的服务中定义的容器都能够相互通信。此外,我们在这个项目中不感兴趣持久化数据(至少目前是这样),所以我们删除了卷标签。这为我们提供了一个简单的起点。

要添加一些数据,我们将创建一个 SQL 脚本,该脚本在容器首次构建时执行。我们首先创建一个health_check表。这个表可以用来确定我们的应用程序是否处于有效状态。对我们来说,这更像是一个“Hello, World!”场景,而不是任何有意义的内容。

健康检查

由于我们的应用程序将分布在多个容器中,创建一个健康检查通常很有用。在使用微服务时,这是一个常见的模式。尽管我们最初创建了一个名为health_check的数据库表,但这只是开始。

Docker 允许我们在设置中为容器创建一个健康检查,我们将在稍后探讨。这个测试可以确定容器是否“不健康”。当使用 Kubernetes 之类的容器编排工具时,这些健康状态检查可以触发管理员警报,并自动重启容器。

Kubernetes 之类的工具还扩展了这些检查,以区分应用程序因初始化而不可用和因崩溃而不可用的情况。这些分别被称为“就绪”和“存活”。

现在我们有一个更健壮的容器,我们将使用四个docker-compose命令:

  • docker-compose build—当我们需要根据 Dockerfile 构建容器时,我们使用这个命令。对我们包含在容器中的文件/脚本所做的任何更新,都需要我们发出这个命令。

  • docker-compose up—当我们需要启动我们的容器时,使用这个命令。

  • docker-compose stop—当我们想要停止我们的容器并保留数据时,使用这个命令。虽然很重要,但我们将通常使用down参数,因为我们将依赖于我们的初始化脚本来填充数据库,并且每次都从一个全新的数据库开始。

  • docker-compose down—这个命令用于在需要停止并删除容器时使用。我们用它来更改任何初始化脚本,因为当数据库存在时,初始化将不会发生。

当我们修改 Dockerfile 时,我们需要这些命令。通常,对于开发,我们只是将它们链在一起作为 docker-compose down && docker-compose build && docker-compose up。你甚至可以将命令简化为 docker-compose down && docker-compose up --build

初始创建我们的容器可能需要一些时间,但在进行小幅度增量更改时,这个过程将会显著加快。现在我们应该能够通过 CloudBeaver 查看我们的表格及其数据。

3.2.3 API 容器

我们下一个容器通过构建我们的 API 容器来扩展我们的容器经验。很快,我们将开始构建处理 ACH 文件的具体 API,但到目前为止,我们将保持非常基础。我们希望利用 FastAPI 框架在 Python 中运行我们的 API。

我们将从标准的 Docker Python 镜像(Docker 中的镜像是一个标准的只读模板,用于创建容器)创建一个 Python 容器,并在其上安装 FastAPI 和一些依赖项。在我们之前的容器中,我们直接在 docker-compose 文件中创建容器。使用这个容器,我们将创建一个 Dockerfile,该文件将被 docker-compose 文件引用。为了存储镜像的依赖项和配置,我们需要引入一些新的命令:

  • FROM—指定一个基础镜像来从它开始构建镜像。

  • COPY—从本地机器复制目录/文件到镜像。在接下来的章节中,我们将创建一个 requirements.txt 文件,该文件需要被复制到包含 FastAPI 的镜像中,以及其他一些内容。

  • RUN—在构建过程中在镜像中执行命令。我们将执行 pip 来安装所需的 Python 包。

  • CMD—指定容器启动时运行的默认命令。我们将使用此命令来运行 Uvicorn,这是一个异步服务器网关接口(ASGI),我们需要运行 FastAPI。

如前所述,我们使用 FROM 命令来声明一个基础镜像来构建。如果你不指定任何内容,Docker 将默认使用最新版本,但你也可以指定一个特定的镜像标签。指定标签可能有各种原因,特别是如果你想将镜像锁定到特定版本。我们将使用 latest 作为我们的镜像,因为我们认为这是一个开发项目,不会立即推送到生产环境。

我们所说的开发项目是什么意思?我们只是意味着在生产环境中,更改软件版本时需要更加小心。你可能会有过时的功能,可能会有你需要注意的错误或漏洞。这些都是你在更改版本之前需要权衡的考虑因素。在这种情况下,我们在构建容器时选择 Python 的最新版本。在生产环境中,你将通过运行测试套件来控制升级,并确保不存在任何不兼容性。

这些关于容器版本的问题也扩展到应用程序本身。此容器在 Python 容器之上安装了 FastAPI,因此我们有一个 requirements.txt 文件来处理这个问题。我们选择在这个示例中只安装最新的包;然而,我们鼓励您考虑锁定特定版本和其他要求。

Python requirements.txt

我们的示例使用不带任何类型版本控制的包名。然而,您可以利用更好的安全措施,例如

  • 指定确切版本package==1.0.0

  • 版本范围package>=1.0.0,<2.0.0

  • 排除版本package!=1.0.5

  • 版本与 Python 版本的组合package==1.1.0;python_version<'3.10'

您也可以从版本控制系统、URL 和本地文件中进行安装。

最后,您可以通过使用pip freeze > requirements.txt为您的项目创建一个 requirements.txt 文件。

您应该能够使用标准的docker-compose builddocker-compose up命令来构建并使容器上线。根据您使用的端口(对我们来说是8000),您应该能够通过 http://localhost:8000/导航到 API,然后看到输出{"message":"Hello World!"},尽管它没有做太多事情,但仍然相当酷。

3.2.4 Web 服务器容器

我们正在研究的容器拼图中最后一部分是一个 Web 界面。计划是提供一个仪表板,我们可以通过它上传和查看 ACH 文件。同样,目前我们只是构建一个简单的页面来确保一切正常工作。对于这个容器,我们将在 Nginx 之上构建我们的应用程序。虽然大量现有基础设施使用 Apache 作为 Web 服务器,但 Nginx 在过去几年中拥有略大的市场份额。

此容器基于我们之前探索的一些概念,例如使用基础镜像构建容器。此外,您可能还希望从 docker-compose 文件中管理端口。之前,我们指定了如8000:8000这样的端口,分别给出了主机和容器的端口。默认情况下,Nginx 将监听端口``80,虽然我们可以更新其配置文件以定义不同的监听端口,但我们可以利用这个机会来介绍在 Docker 中配置端口。假设我们希望能够保留 Nginx 的默认端口,同时只为网页浏览器提供一个不同的端口。例如,让我们假设我们希望能够导航到 http://localhost:3000/hello.xhtml 来访问我们的示例网页。我们可以通过指定3000:80然后启动我们的容器来实现这一点。

到目前为止,我们已经设置了我们将需要的所有容器,但我们还没有确保它们正在相互通信。

3.3 连接我们的容器

现在,让我们回到让我们的容器进行通信的问题。我们希望确保我们的容器可以交互。我们的用例一开始很简单,但为扩展我们的项目奠定了基础。我们希望能够访问一个使用 API 层访问我们的health_check数据库表的网页。这个表在这个阶段可能不会改变,但我们可以手动调整它并查看结果。这将是一个确保一切按预期运行的很好的第一步。

如前所述,港口可能会带来问题,不仅从安全角度考虑,而且仅仅记住你选择了哪些端口。因此,让我们列出我们的容器和正在使用的端口。

表 3.1 容器港口列表
容器名称 主机端口 内部端口
CloudBeaver 8080 8080
Postgres - 5432
Api 8000 8000
Web 3000 80

3.3.1 连接到数据库

我们的第一步是让 API 容器连接到数据库并从表中获取结果。在这个阶段,我们只是想要整理好管道,所以我们不会考虑编写单元测试。随着我们构建示例,我们将能够轻松地测试这一点。首先,我们将专注于更新我们的 API 层以连接到数据库并返回我们的给定状态。

我们首先更新我们的 requirements.txt,包括psycopg[c]以支持连接到数据库。

列表 3.5 我们更新的 requirements.txt
psycopg[c]
fastapi
pydantic
uvicorn

psycopg[c]是一个适合生产的方案,但也需要更新我们的 Dockerfile 以包括额外的依赖项。你现在应该能够访问 http://localhost:8000/health/status 来查看状态。如果一切顺利,你应该会看到[{"id":1,"status":"OK"}]。如果不这样,你可以跳到 3.2.2 节,该节讨论了你可能遇到的一些常见问题。

在更新了这段代码之后,我们现在有几件事情需要考虑。我们的health_check表目前是按照顺序 ID 和状态构建的,并且我们决定在这个例子中返回所有记录。你可以通过进入 CloudBeaver(http://localhost:8080),点击SQL命令,并使用INSERT INTO health_check VALUES(DEFAULT, 'testing')插入另一条记录来实验。不需要将 SQL 语句大写,但我们发现这样做有助于在处理与其他代码混合的 SQL 时使代码更易读。插入记录后,你会发现导航回健康/状态端点将返回所有记录。所以,你应该会看到类似[{"id":1,"status":"OK"},{"id":2,"status":"testing"}]的内容。稍后,我们可以回顾并探索解决这一问题的方法。记住,我们想要提供当前系统的健康状况,但我们目前并没有做任何事情来更新这个表。

暴露的端口和内部网络

在指定我们的连接字符串时,我们选择了 端口 5432。你还可以看到 postgres 容器正在监听 5432,因为应该有一个日志消息说 监听 IPv4 地址 0.0.0.0, 端口 5432。然而,我们的端口列表显示我们没有指定它,那么问题出在哪里呢?

由于我们使用 Docker Compose,其中一个好处是创建了一个内部网络。这允许容器像在同一网络中一样相互通信。在我们的情况下,上述端口仅用于主机系统。如果我们想使用安装在桌面上的工具编写脚本或浏览数据库,那么我们可以暴露 端口 5432

为什么我们可以使用 CloudBeaver 来查看 SQL 数据?嗯,那是因为我们为 CloudBeaver 暴露了 8080 端口,我们可以用我们的网页浏览器来浏览它。因此,我们与 CloudBeaver 进行外部通信,但 CloudBeaver 可以使用内部网络来访问 端口 5432

3.3.2 PostgreSQL 和 FastAPI 故障排除

在设置容器时,事情可能没有按预期进行,或者在其他容器设置时可能不会按预期进行。当使用 docker-compose up 启动容器时,务必注意日志,因为你可能会频繁地看到有意义的错误信息。还要注意,你可能需要向上滚动,因为之前的容器失败了,其他容器随后启动,或者错误/警告信息在混乱中丢失了。以下是在容器无法启动的情况下可能会遇到的更常见的一些错误。

我们试图从一个容器连接到另一个容器,所以你可能在控制台上看到类似的消息 Is the server running on that host and accepting TCP/IP connections? 这条消息可能意味着容器启动失败,并且由于它是从我们的 FastAPI 代码连接到数据库,我们的代码中可能存在语法错误,阻止了容器的启动。这个错误可能是代码中的拼写错误,也可能是由于我们未能包含的依赖项导致的。如果代码在语法上是正确的,请检查 requirements.txt 和 Dockerfile,以确保所有要求都被安装。

错误 连接 服务器 at "localhost" (::1), 端口 5432 失败: 无法 分配 请求的 地址 是我们可能会遇到的其他错误之一。这个错误很常见,因为许多示例可能引用了 localhost,并且可能会错误地使用 localhost 而不是容器的名称/地址。你也可能认为,既然我们在自己的电脑上运行,就可以使用 localhost。但是记住,我们是在容器中运行。我们应该在我们的连接字符串中指定容器的名称,而不是 localhost

当你指定了无效的用户名或密码时,可能会发生psycopg.OperationalError: connection failed: FATAL: password authentication failed for user "postgres"这个错误。查看 Dockerfile 和 docker-compose.yml 文件,确保你有正确的用户名和密码。记住,在这个阶段,我们在设置数据库容器时已经在我们的 docker-compose 文件中定义了用户名和密码,但我们还必须在 Python 代码中硬编码它。

我们可能遇到的其他错误,尤其是在生成式 AI 的帮助下,是NameError。当本地或全局名称未找到时,会引发NameError,这意味着我们尝试使用尚未定义或当前作用域中不可访问的变量、函数或模块。当我们自己处理代码时遇到了NameError: name 'status' is not defined这个错误,因为我们没有仔细注意生成式 AI 建议的代码。尽管这不是我们使用的变量名,Copilot 还是填充了status变量。因此,这只是一个简单的语法错误,我们必须返回正确的值。

3.4 调用我们的 API

到目前为止,我们应该能够确认 API 可以访问数据库容器。我们还想进行一次跳跃——我们希望能够创建一个可以访问 API 的网页。尽管我们已经看到我们可以在浏览器中访问 API,但我们能够通过这种方式进行测试,因为我们的 REST API 使用的是GET请求,这与检索网页使用的请求相同。稍后,当使用其他 HTTP 动词,如POSTPUTDELETE时,我们无法以这种方式进行测试。此外,我们将构建 UI,因此我们需要确保 Web 和 API 容器之间的连接性。

我们将创建一个简单的网页,结合 d3.js 来显示我们的结果。在我们的例子中,我们不需要任何花哨的东西,所以我们只创建一个结果列表。如果你不熟悉 HTML 或 d3.js,这将是一个很好的时候看看我们的生成式 AI 工具是否可以帮助你开始。

我们用 ChatGPT(GPT-40)提示

图片 创建一个简单的 d3.js 网页,调用 API。

它生成了一个在 chat_gpt_example.xhtml 中可见的示例。

列表 3.6 ChatGPT 生成的示例页面
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>D3.js API Call</title>
    <script src="https://d3js.org/d3.v6.min.js"></script>
</head>
<body>
    <h2>Users from JSONPlaceholder API</h2>
    <ul id="users"></ul>

    <script>
        // The URL for the API endpoint
        const apiUrl = 'https://jsonplaceholder.typicode.com/users';

        // Use D3 to fetch the data from the API
        d3.json(apiUrl).then(data => {
            // Once data is fetched, populate the list
            d3.select("#users")
                .selectAll("li")
                .data(data)
 .enter()
 .append("li")
 .text(d => `${d.name} (${d.email})`);
  });
    </script>
</body>
</html>

这个响应为我们提供了一个很好的起点,经过一些调整,我们就有了一个满足我们初始需求的可行解决方案。

列表 3.7 更新的示例页面
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8"> 
    <meta name="viewport" content="width=device-width, initial-scale=1.0"> 
    <title>Health Check</title> #1
    <script src="https://d3js.org/d3.v7.min.js"></script> 
</head>

<body>
    <h2>Health Check</h2> #2
    <ul id="healthStatus"></ul>  
    <script>
        // The URL for the API endpoint
        const apiUrl = 'http://localhost:8000/health/status'; #3

        // Use D3 to fetch the data from the API
        d3.json(apiUrl).then(data => {
            // Once data is fetched, populate the list
            d3.select("#healthStatus") #4
                .selectAll("li")  #4
                .data(data)  #4
 .enter() #4
 .append("li") #4
  .text(d => `${d.id} (${d.status})`);  #4
  });
    </script>
</body>
</html>

1 我们首先给我们的页面一个标题并导入 d3.js。

2 创建一个标题并更新 ID,使其对我们更有意义

3 访问状态检查的 API 端点

4 更新选择语句和文本字段(ID 和文本),以便我们写入预期的数据

除了定制输出以满足我们的特定需求外,我们还需要将 d3.js 的版本提升到当前水平。当与生成式 AI 一起工作时,这是一个经常出现的问题;根据模型和训练数据,最新的软件可能并不总是包含在内。快速检查所有软件发布级别以确认你正在运行适当的版本是一个好主意。

我们必须做出的最重要的定制是将页面指向正确的 API URL。你可以参考我们之前制作的端口列表;你有什么想法这个 URL 会是什么样子吗?你可能被之前连接数据库和 API 容器的例子所困扰,在那个例子中,我们必须指定容器名称。在那个例子中,我们是在 Docker 内部。现在,我们的网页是外部的,因此我们想要从外部访问它,所以使用 localhost 和端口8000

测试访问时,我们得到了一个 CORS(跨源资源共享)错误:

Access to fetch at 'http://localhost:8000/health/status' from origin 
'http://localhost:3000' has been blocked by CORS policy: No 'Access-
Control-Allow-Origin' header is present on the requested resource.
CORS

CORS,更广为人知的是快速完成任务的障碍,是你在工作中会遇到的问题之一。CORS 代表跨源资源共享,是一种安全机制,允许服务器指定对与外部调用者共享的资源进行控制的规则。

CORS 提供了简单请求和预请求的概念。我们并不真正关心简单请求,因为预请求是触发 CORS 异常的请求。为了绕过这些异常,我们需要配置我们的服务器以确保发送回处理 CORS 的一些特定头部信息。

需要使用以下头部信息:

  • Access-Control-Allow-Origin—指定哪些网站允许访问资源。我们可以使用通配符*,但这可能存在潜在的安全风险。记住,最小权限原则?从一个更具体的源开始,并在必要时才扩展。

  • Access-Control-Allow-Methods—指定哪些 HTTP 方法是被允许的。同样,你可以使用通配符,但应仅使用必要的。在我们的例子中,我们只使用GET,因此这是我们真正需要的唯一一个。稍后,我们将使用更多的请求,但我们建议目前保持为GET,不要更新为使用尚未定义的 HTTP 方法,这样 CORS 会在稍后再次提出抱怨,这提供了良好的故障排除实践。

  • Access-Control-Allow-Headers—允许服务器指定可以用于请求的头部信息。

  • Access-Control-Allow-Credentials—指示浏览器是否应在请求中包含凭据。如果你有经过身份验证的请求或需要使用 cookie 维护状态,或者有包含敏感信息的请求,请将其设置为true

为了纠正这个错误,我们的 API 层需要更新以下内容。

列表 3.8 将 CORS 集成到我们的 API 中
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware #1
import psycopg

DATABASE_URL = "dbname=postgres user=postgres 
➥ password=secret host=postgres port=5432"

app = FastAPI()

origins = [ #2
 "http://localhost:3000", #2
] 

app.add_middleware( #3
 CORSMiddleware, #3
 allow_origins=origins, #3
 allow_credentials=False, #3
 allow_methods=["GET"], #3
 allow_headers=["*"], #3
)  #3

def get_db():
    conn = psycopg.connect(DATABASE_URL)
    return conn

1 我们从 fastapi 导入 CORSMiddleware。

2 原始地址是我们期望接收请求的地方;我们将其作为一个单独的变量创建,因为我们预计会更新它几次。

3 我们在这里传递所有参数;allow_methods 和 allow_headers 是我们保护 API 的一个不可或缺的部分。

注意我们如何尝试保持原始地址和方法尽可能窄。换句话说,我们限制了允许的原始地址和方法,仅限于我们现在需要的。抵制使用通配符和比您目前拥有/需要的更多方法的诱惑,以使代码具有未来性。这是因为当涉及到安全时,我们希望将其锁定。我们本想说“少即是多”,但不想有人回来告诉我们,因为他们有更少的保护而被黑客攻击!我们的意思是,更少的访问权限更好!

在 CORS 更新到位后,您应该能够构建容器并重新启动 Docker。希望您现在可以导航到 http://localhost:3000/health.xhtml 并看到一些结果。您应该能够再次进入 CloudBeaver 并在health_check表中输入额外的记录,这些记录将在您刷新网页时显示。

3.4.1 故障排除我们的网站

您可能会遇到的主要问题是之前讨论过的错误——CORS 错误:

Access to fetch at 'http://localhost:8000/health/status' from origin 
'http://localhost:3000' has been blocked by CORS policy: No 'Access-
Control-Allow-Origin' header is present on the requested resource. 

如前所述,如果您选择允许一切,那么这个与安全相关的错误很容易解决。但我们警告您不要采取这种方法,因为您应该保持一定的安全性。通常,您的 API 可能内置了一定级别的重定向,因为它们可能通过其他工具,例如 WSO2 API Manager(我们在第四章中讨论过)。在这种情况下,您可能只想在您的 CORS 配置中指定那层中间层。或者,您可能想限制 HTTP 请求。再次强调,如果不需要,那么就不应该有。

如果您使用 HTTPS 并且 API 只监听 HTTP,可能会发生ERR_SSL_PROTOCOL_ERROR错误。您可能在浏览器控制台看到错误,如Failed to load resource: net::ERR_SSL_PROTOCOL_ERROR。目前,我们使用 HTTP 而不是 HTTPS,因为我们不想引入与证书相关的额外复杂性。然而,我们将在项目后期扩展这一点。

您可能会遇到来自 d3.js 的404错误,例如d3.v7.min.js:2 Uncaught (in promise) Error: 404 Not Found。这通常是因为 API 端点有拼写错误。另一个原因可能是使用了错误的 HTTP 方法(当您需要POST时使用GET),尽管更常见的是看到405 Method Not Allowed。我们见过一些框架也使用404错误代码。其他问题也存在,但当我们开始涉及服务器和网络配置时,它们开始超出本书的范围。因此,如果您排除了这些基本原因,您将需要更多的帮助。

我们在这里提到这一点,尽管它可能随时发生。如果 Docker 没有运行,那么在构建和启动项目时可能会出现错误。看到像 error during connect: this error may indicate that the docker daemon is not running 这样的错误是一个明显的消息,表明您没有运行 Docker。然而,我们可能会犯这个错误至少几次,所以最好现在就解决这个问题。

3.5 容器安全

希望您已经对使用容器的工作方式有了概念。是的,我们的应用程序继承了一些复杂性,但这是为了使用这种类型架构的一些好处所做的权衡。

我们也继承了部分安全风险作为这种便利的一部分。当我们使用镜像构建容器时,即使是官方镜像也可能存在与之相关的风险。这些风险以包含在软件包中的安全漏洞的形式出现。了解这些问题很重要,因为它们可能是您使用的镜像或软件包版本中的严重漏洞。一些镜像很复杂,可能不明显知道特定镜像中实际包含的内容。这就是 Snyk 和 Docker Scout 等工具发挥作用的地方。它们可以扫描您的容器,并报告任何镜像或包含的软件包中的漏洞。

Snyk 提供了一个命令行扫描工具,可以创建我们的 Docker 容器清单(图 3.3)。然后我们可以使用他们的网站来查看扫描结果并修复潜在问题。当我们的容器中发现了新的问题时,我们会自动收到警报,以便我们可以立即开始修复它们(如果需要的话)。

计算机截图  自动生成的描述

图 3.3 Snyk 生成的示例结果

另一个需要注意的方面是容器内和暴露给世界的端口号。我们可以使用 docker ps 来查看可用的端口。在配置容器时,允许端口动态设置可能会有所帮助。例如,我们的 api host port 可以设置为 ${API_PORT:-8080}:80,这将允许我们默认使用端口 8080,除非 API_PORT 已经定义,在这种情况下,它将优先使用。

Docker 中有许多选项可以帮助保护容器,我们将简要介绍一些及其应用:

  • --read-only — 它将容器标记为只读,这有助于在攻击者访问容器时限制恶意活动。

  • --memory–cpu — 在内存和 CPU 方面有多个配置选项。这些选项可以配置容器从主机系统可用的资源数量。防止容器消耗过多资源对于系统的整体健康至关重要。

  • --no-new-privileges——它阻止应用程序获得任何新的权限。这意味着 Docker 将阻止你执行那些会给你 root 权限的脚本和命令。这种想法再次是为了保持恶意代码和用户的隔离。

最小权限原则和深度防御

在安全领域,有一个常见的概念被称为最小权限原则(PoLP)。我们试图使用最小的访问权限或仅必要的权限来完成我们的工作,只有在必要时才提升权限,然后恢复到原始状态。理想情况下,即使是管理员或具有 root 访问权限的人也不应该将其作为默认的登录方式。虽然授予用户一切或更高的权限更容易,但如果他们的账户被入侵,攻击者将拥有相同的访问权限。也许更常见的是,用户只是输入了一个错误或者没有注意到他们所在的系统或目录,通过删除未预期的文件造成了损害。我们听说过不止一个脚本以 root 权限运行导致系统崩溃的故事。或者有人可能在错误的目录中执行rm *命令,导致系统崩溃!

“深度防御”或“分层安全”是另一种安全实践,无论你在组织中的角色如何,你都将实施。它简单指的是为了保护组织免受物理安全到网络安全再到应用安全而实施的广泛安全实践。作为开发者,你很可能会参与到应用层的安全。这也会扩展到数据安全和访问管理,因为你需要加密和隐藏数据,以及验证和授权用户访问系统。

如果你属于一个大型的组织,容器管理和安全可能不是你的领域,尤其是如果你是软件开发者角色的话。在较小的公司,你可能需要戴多顶帽子,对这些概念有一些了解将会很有用。在我们的案例中,我们使用容器来玩弄我们的开发项目,我们只需要了解 CliffsNotes。然而,安全是所有组织的一个主要考虑因素,了解一些基础知识不仅可以帮助你成为一个更好的开发者,而且当安全团队提出下一个令人烦恼的要求时,也会给你提供一个不同的视角。如果你想进一步探索 Docker 的安全性,可以查看 OWASP Docker 安全备忘录(mng.bz/nR8K)。

3.6 优化 Docker

Docker 很棒,尤其是如果你是一名开发者。有大量的 Docker 图像可供使用,你可以探索不同的技术和工具,而无需在你的机器上安装它们(除了使用 Docker 图像)。因此,你不再需要用一大堆你后来会忘记的软件来弄乱你的个人机器。当然,这种便利性并非没有缺点。例如,如果你已经启动了本章附带的所有 Docker 项目,你可能有大量的浪费空间,因为 Docker 为每个项目创建了容器、图像和卷,这就是为什么我们将稍微深入一下管理和优化 Docker。

那么,为什么要优化呢?当我们谈论优化 Docker 时,我们主要是在说移除不必要的组件,也许是从不同的图像中构建它们。通过减少组件,我们通常可以实现更好的安全性、更快的部署,以及可能的运行时改进。作为开发者,我们可能随意拉取图像,而对它们基于什么构建并不关心。毕竟,我们通常试图完成我们的工作,但现在,让我们看看内部是什么样子。

3.6.1 通用维护

首先,我们运行命令 docker image ls。它将显示图像及其大小。以下是一个示例输出:

REPOSITORY           TAG       IMAGE ID       CREATED          SIZE
dockercompose7-web   latest    9d0257a0f9b0   56 minutes ago   187MB
postgres             latest    e872544a957a   56 minutes ago   417MB
<none>               <none>    056294c7ea5d   58 minutes ago   187MB
<none>               <none>    531ebd9cf3db   58 minutes ago   417MB
dockercompose7-api   latest    5a2bb8fc1b02   24 hours ago     1.09GB
<none>               <none>    9552a83973a9   24 hours ago     417MB
<none>               <none>    606976f59240   24 hours ago     1.09GB
<none>               <none>    237b5dd9e45d   24 hours ago     417MB
<none>               <none>    20ec806118d6   24 hours ago     187MB
<none>               <none>    361454300c20   24 hours ago     1.09GB

你应该注意的第一件事是我们有一堆带有 <none> 的仓库。这些被称为悬挂的图像,在我们构建/重建项目时可能会出现。我们可能只是通过在开发环境中工作,并在修改项目时不断构建容器,自己引入了一些这些图像。另一个常见的原因是使用 latest 标签。我们之前选择使用 latest 标签是因为我们总是想要最新的项目。现在,我们可能正在为那个决定付出代价。在我们的当前环境中,我们个人并不特别关心这一点,但在一个生产环境中,如果我们正在构建一个特定的项目,我们会确保我们的项目被标记到特定的级别。

为了清理这些悬挂的图像,我们可以发出以下 prune 命令。在所有关于谨慎删除东西的先决条件警告之后,我们发出 docker image prune 并被提示确认我们想要继续。然后,删除了几个图像,我们接着执行 docker image ls 来查看剩下什么:

REPOSITORY           TAG       IMAGE ID       CREATED             SIZE
dockercompose7-web   latest    9d0257a0f9b0   About an hour ago   187MB
postgres             latest    e872544a957a   About an hour ago   417MB
dockercompose7-api   latest    5a2bb8fc1b02   24 hours ago        1.09GB
dockercompose6-api   latest    d53ec48f5fec   3 days ago          1.09GB
dockercompose6-web   latest    ef65a4305bf7   3 days ago          187MB
cloudbeaver          latest    9b672f480fc7   2 weeks ago         250MB

然后,我们看看剩下什么,我们看到我们有一些之前的图像(dockercompose6-apidockercompose6-web),我们可能并不关心。我们可以通过发出 docker ps -a --filter ancestor=dockercompose6-api:latest 来确认它们没有被任何(无论是运行还是未运行)的容器使用。此代码将返回容器的状态。当没有返回任何内容时,它可以安全地被删除。

3.6.2 优化图像大小

现在我们已经进行了一些基本的清理,审查我们的镜像可能稍微容易一些。我们可以再次从查看当前镜像开始,使用docker image ls

dockercompose7-web   latest    9d0257a0f9b0   About an hour ago   187MB
postgres             latest    e872544a957a   About an hour ago   417MB
dockercompose7-api   latest    5a2bb8fc1b02   24 hours ago        1.09GB
cloudbeaver          latest    9b672f480fc7   2 weeks ago         250MB

我们最大的镜像是在 1.09 GB 的dockercompose7-api镜像。

我们可以减少图像大小的一种方法就是检查特定的 Docker 镜像是否支持阿尔卑斯 Linux。阿尔卑斯 Linux 是 Linux 的一个轻量级版本,专注于安全性,这使得它非常适合在 Docker 中使用。

通过切换镜像,Postgres 从 417 MB 降至 239 MB,而 Web 从 187 MB 降至 42.6 MB。不幸的是,我们不能简单地切换到阿尔卑斯 Linux,因为我们的最大容器,当前位于 1.09 GB 的 API 层,不支持阿尔卑斯 Linux。这是因为我们在 Dockerfile 中使用apt-get,而阿尔卑斯 Linux 不是基于 Debian 的,因此不支持它。我们可以采取捷径,使用slim标签。通过指定slim而不是alpine,我们不需要更改我们的 Dockerfile,最终得到的镜像大小大约是 557 MB。

瘦身版与阿尔卑斯版

那么,slimalpine标签之间究竟有什么区别?两者通常都比标准镜像小,而alpine镜像通常比slim镜像更小。

如其名所示,slim镜像是最小化的标准镜像版本,移除了非必要组件,如文档和开发库,但保留了核心功能。记住,这些容器是专门为运行放入其中的内容而设计的。它们不是供人们登录并四处浏览的,所以我们不需要像手册页这样的文档。

相比之下,阿尔卑斯 Linux 专注于安全性,它基于musl libc库和 BusyBox。这些可能会与其他软件和库产生问题。正如我们所见,我们必须更新我们的 Dockerfile,通过一些小的改动来利用阿尔卑斯 Linux。当然,对于更大的项目,需要进行更广泛的测试以确保一切正常工作。

如果我们想要追求在 API 层使用阿尔卑斯 Linux,我们可以进一步减少镜像的大小。我们需要更新 Dockerfile,使用阿尔卑斯包管理器(apk)而不是apt-get来安装所需的软件包。具体效果可能因项目不同和需要安装的具体内容而异。在这种情况下,我们需要移除RUN apt-get行,并用以下内容替换:

RUN apk -U upgrade
RUN apk add –no-cache libpq-dev python3-dev gcc musl-dev

使我们能够成功地将项目重新启动并运行。这减少了图像大小 200 MB,从 557 MB 降至 344 MB——与原始的 1GB 相比,差距相当大!

在 Dockerfile 中选择较小的基础镜像是减少镜像大小最直接的方法之一。开发中的一个格言是“让它工作,让它正确,让它快速。”这种方法也适用于容器。较小的镜像大小有一些好处,但在项目开始时你不需要担心这一点。当与一个大公司合作时,可能有一个整个团队会为你处理这些事情,所以你在构建和运行容器时可能没有太多灵活性。

3.6.3 优化构建时间

与优化镜像大小可以并行进行的一项任务也是实际构建时间。有几种工具可用于分析 Docker 镜像。其中两个流行的工具是 Docker Scout 和 dive,这是一个命令行工具。让我们看看如何运行 Docker Scout 实用工具,因为它在 Docker Desktop 中很容易获得。这次讨论也提供了使用 Docker 时经常提到的某些指标和术语的良好介绍。图 3.4 展示了分析我们的 API 镜像的结果。这个屏幕来自使用 Docker 和 Docker Scout 的个人(免费)级别。付费级别提供了更多功能(www.docker.com/pricing/)。

当考虑优化构建的方法时,我们主要关注层。在 Dockerfile 中,指令的顺序很重要,每条指令都转换为一个层,尽管并不总是存在一对一的对应关系。我们可以看到每个层的尺寸和正在执行的命令。我们还可以看到每个命令如何影响文件系统。

优化构建的一种方法是通过确保我们适当地管理层。层应保持最小化,任何添加/删除文件的操作都应在同一层中完成。例如,可能下载临时文件的命令也应该被清理。

我们还提到,Dockerfile 中指令的顺序很重要,因为 Docker 使用缓存机制来确定是否需要重新运行命令。因此,经常变化的项目,如你的应用程序代码,通常位于指令的底部,而较少变化或可能引起大量下载的变化则执行在顶部。以相反的顺序做这件事被称为

计算机屏幕截图  自动生成的描述

图 3.4 Docker Scout 分析

缓存失效,因为它破坏了 Docker 的缓存机制,并可能导致 Docker 在每次都重建整个镜像。

Docker squash 是另一个可以帮助移除层和其他未使用文件的工具。在互联网上可以找到这个实用工具的几个不同版本,以及 Docker 本身为了这个功能提供的实验性命令。

此外,还有一个.dockerignore文件,其功能类似于.gitignore文件(如果你熟悉 Git)。否则,概念很简单:我们可以将这些文件或目录放入这些忽略文件中,Docker 将忽略它们。所以,也许你的 IDE 中的不必要配置文件、其他与应用程序相关的信息以及如 READMEs 之类的文档都是这些文件的良好候选者。

最后,一般的应用程序维护可以走得很远,确保依赖项是最新的,并且你已经最小化了 CSS、JavaScript 等,以保持图像大小。

3.7 移除硬编码的用户名/密码

现在我们已经涵盖了与 Docker 和 Docker 镜像一起工作的某些维护方面,让我们借鉴我们之前的建议,并处理应用程序。我们必须做的一件事是为 PostgreSQL 实例指定用户名和密码(记得 docker 中的POSTGRES_USERPOSTGRES_PASSWORD)。

-compose 文件)。

我们能够通过环境变量做到这一点;然而,当涉及到 API 容器时,我们硬编码了值,这是我们总是想尽量避免的事情。

避免使用魔法数字和硬编码的值

魔法数字具有未解释的含义,例如当我们定义日志级别为 1、2、3、4 或 5 时。哪一个是最高的严重性?1 级日志级别与 4 级日志级别有什么区别?这就是一个魔法数字的例子。

我们认为硬编码的值是魔法数字的超集,因为它们带来了类似的一系列问题。任何开始使用硬编码值,如用户名/密码、端口、服务器等的情况,都会引发问题。这不仅可能因为它们在源代码控制中不安全地存储而成为安全问题,而且当它们散布在代码中,而服务器在 10 年后移动时,也可能成为维护的噩梦。找到所有这些地方并测试是否正确更改了一切可能真的非常痛苦。

尽管我们尚未检查应用程序,但我们将通过移除硬编码的值来培养良好的习惯。

幸运的是,Docker 提供了一种方法,我们可以通过应用一个env_file指令来利用环境变量,该指令指定了一个环境文件。作为额外的好处,我们可以引用gitignore命令来防止此文件被上传到源代码控制。或者可能还有另一层重定向,其中更敏感的信息(数据库凭证)被引用并存储在别处。如果你不在源代码控制中存储环境变量,确保它们在某处有良好的文档,以便新实例或新员工知道如何设置它们。我们经常发现自己有人通过电子邮件发送随机的配置文件,因为没有人确定如何设置系统以及确切还需要什么!

首要任务是创建一个文件来存放我们的环境变量。我们需要创建一个名为 .sql_server.conf 的文件。文件的实际名称并不重要,但应该是有意义的。前导点(.)通常会导致操作系统隐藏文件,而“conf”扩展名是配置文件的行业标准。另一个常见的命名标准是使用 .env。我们经常在公司看到使用 .env 文件,尤其是在有源代码控制工具如 Git 的环境中,这些工具通常被设置为忽略 .env 文件。Docker 文档中的一个例子,可在 mng.bz/vK5M 找到,展示了在定义他们的 webapp 服务时使用名为 webapp.env 的环境文件。

需要记住的主要事项是文件的命名是灵活的,文件能够正常工作并不需要特定的名称或前导点。然而,我们只是在建立一个约定,这可能与其他公司现有的标准和约定略有不同(记住那句俗语,“入乡随俗……”)。

文件创建后,我们将把用硬编码的 POSTGRES_USERPOSTGRESS_PASSWORD 表示的用户名和密码移动到文件中,分别以 POSTGRES_USERPOSTGRESS_PASSWORD 结尾

POSTGRES_USER=postgres
POSTGRES_PASSWORD=secret

我们还需要更新 docker-compose.yml 文件以使用这个新创建的文件。请注意,我们移除了环境变量,并用 env_file 代替:

  postgres:
    build: 
      context: ./db
      dockerfile: Dockerfile
    env_file:
      - ./.sql_server.conf

同样,我们也对 API 容器进行了更改,在 env_file 指令中使用了相同的 .sql_server.conf。在继续之前,我们应该构建并启动我们的容器以确保一切仍然正常工作。最后,我们还需要更新我们的 API 容器以也引入这些变量。我们导入 os 模块,然后使用 getenv 来检索在 .sql_server.conf 文件中设置的环境变量:

import os
…
POSTGRES_USER = os.getenv("POSTGRES_USER")
POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD")
…
DATABASE_URL = f"dbname={POSTGRES_USER} user={POSTGRES_USER}
➥ password={POSTGRES_PASSWORD} host=postgres port=5432"

经过这些更改后,我们可以移除、构建并启动容器进行测试。你可能还想要更改环境中的值,以确保更改确实生效。此外,别忘了登录 CloudBeaver,以确保它也能正常工作。

os.getenv 命令还允许你在找不到环境变量时指定一个默认值。因此,我们可以使用 os.getenv("POSTGRES_USER", "postgres"),但在本例中我们避免了默认值,因为我们想确保如果设置有误(因为 Postgres 用户仍然以某种方式有效),我们的容器不会意外地工作。你的用例可能会有所不同,所以只需记住这个功能即可。

我们还更新了DATABASE_URL。在连接字符串中将POSTGRES_USER替换为dbnameuser。这只是 PostgreSQL 数据库中的一个约定。dbnameuser是相互独立的,但我们保持了我们的 SQL 脚本简单,因此表是在相同的用户名下创建的。你也会注意到连接字符串中包含了硬编码的主机和端口值。我们将是否用环境变量替换这些值留给你决定,这样做不会影响任何东西,也许也是你尝试之前提到的os.getenv中默认值的机会。

3.8 健康检查

在我们的示例中,我们构建了一个简单的健康检查端点。这个端点是应用程序可以用来获取系统或应用程序状态的东西。它可以细粒度到你想的程度,不仅检查可能正在运行的服务,还包括连接到其他服务的潜在问题。

在这里,我们想花一点时间从纯粹 Docker 的角度来看健康检查,而不是在应用层面可能做的事情。这些健康检查可以用来确定容器是否正在正确运行(或健康)。容器可以处于多种状态,如启动、健康或不健康,了解容器状态的重要性是使用容器的好处之一。不健康的容器会对你系统产生不利影响,可能是糟糕的应用程序代码、网络问题,或者可能是容器中使用的某个组件的更新有问题。Docker 和 Docker Compose 可以自动重启容器以尝试解决潜在问题,或者你可能构建一个更健壮的日志和通知系统来监控你的容器。

我们可以使用docker ps命令确定我们容器的健康状况。你现在可以运行这个命令;然而,除非你使用的镜像包含健康检查,否则你不会在状态旁边看到启动/健康/不健康;你只会看到运行时间。为了节省空间,我们将使用format参数格式化docker ps命令的输出。从命令行运行docker ps --format '{{.Names}}' - {{.Status}}'``会显示

dockercompose9-web-1 - Up 5 minutes
dockercompose9-api-1 - Up 5 minutes
dockercompose9-postgres-1 - Up 5 minutes
dockercompose9-cloudbeaver-1 - Up 5 minutes

首先,让我们为 Postgres 容器添加一个健康检查。我们将更新 Dockerfile,如下

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD pg_isready -U ${POSTGRES_USER} || exit 1

注意到该命令有几个参数,实际工作是由CMD完成的。pg_isready是 Postgres 命令,用于确认数据库正在运行。它需要一个用户,所以我们引用了我们定义的环境变量。

如果我们按照常规程序移除容器、重建并重新启动它们,然后运行那个docker ps命令,我们会得到

dockercompose9-web-1 - Up 11 minutes
dockercompose9-api-1 - Up 11 minutes
dockercompose9-postgres-1 - Up 11 minutes (healthy)
dockercompose9-cloudbeaver-1 - Up 11 minutes

我们可以看到 Postgres 容器现在显示为健康状态。包括健康检查有助于我们为生产环境做准备,在这种环境中,容器编排工具如 Docker Swarm 或 Kubernetes 将使我们的容器能够自我修复,这意味着如果容器在这些检查失败时,它们将能够重新启动或替换。但这超出了本书的范围。目前,请记住健康检查提供的力量以及与应用程序级别健康检查相比从 Docker 视角进行健康检查的潜在差异。

摘要

  • Docker 和 Docker Compose 可以管理我们的环境。通过将项目的小部分构建成独立的容器,我们可以独立扩展项目的每个组件。

  • 在确定项目最佳方法时,评估团队经验、项目规模和风险因素至关重要。

  • 生成式 AI 工具可以提高生产力,但存在局限性并需要验证。

  • Docker 为高效管理多个服务和技术创建了隔离环境。

  • 向微服务架构的过渡应基于需求,而非趋势,因为单体架构仍然可能有效。

  • 将项目划分为可管理的组件并确保初始连通性对于有效开发至关重要。

  • 容器中的健康检查提供系统状态监控并使生产环境中的自我修复成为可能。

  • 通过应用最小权限原则和使用漏洞扫描工具实现安全和高效的管理。

  • 容器优化对于减少镜像大小和构建时间很重要。

  • 应使用环境变量来避免硬编码敏感信息,如用户名和密码。

  • 遵循存储、应用优化和依赖管理方面的最佳实践可以增强 Docker 的有效性。

第四章:个 API:我们组件之间的管道

本章涵盖

  • FastAPI 框架简介

  • 使用 OpenAPI 标准记录 API

  • 使用 Postman 和 Locust 测试我们的 API

  • 使用 WSO2 进行 API 管理

在这个冲刺中,我们致力于另一个研究激增,其中我们探索设计和测试应用程序程序接口(API)。API 简单来说就是与软件组件交互的一种方式。无论我们是在处理微服务和分布式系统,还是使用 npm/pip 的包,我们都利用 API。

4.1 API 驱动设计

通常,当我们在这本书中提到 API 时,我们是在谈论 REST(表示状态传输)API 标准。然而,还有其他标准,例如 GraphQL 和较老的 SOAP(简单对象访问协议)。我们可能还会遇到消息队列,如 IBM Websphere MQ 和 RabbitMQ。表 4.1 展示了一些常见实现 API 方法的好处和坏处。

表 4.1 不同 API 的优缺点
REST SOAP GraphQL
优点 简单;基于标准 HTTP 方法支持多种数据格式,尽管 JSON 是最常用的。 依赖于基于 XML 的消息企业级安全 高效单一端点强类型
缺点 版本控制和破坏性变更可能难以管理。端点数量可能变得不便。 实施复杂基于 XML 的消息 学习曲线陡峭

API 作为各方同意遵守的合同。API 的消费者同意遵守任何使用条款和许可协议,并遵循提供者建立的任何文档和标准。反过来,API 提供者提供了一个稳定且可扩展的端点以访问。API 订阅还提供了工具,使成本、使用情况和服务水平协议(SLA)透明。

随着分布式计算的应用,许多开发者和架构师对 API 驱动的架构产生了兴趣。通常,我们可以预期我们的软件将被部署在云端或容器化环境中。在这些情况下,我们想要确保我们考虑了 API。与大多数设计目标一样,我们想要记住以下几点:

  • *清晰的用例**—API 不应作为瑞士军刀,能够处理客户、账户和交易。相反,设计应保持简单、直接和一致。

  • *版本控制**—预测版本控制并弃用旧版 API。需要维护多个版本的 API 可能会导致每次更改都增加技术债务。WSO2 API 管理器和 Mulesoft 的 Anypoint 平台等工具可以帮助进行版本控制、治理以及许多其他与 API 相关的任务。

  • *文档**—全面的最新文档确保我们的 API 容易被消费。例如,OpenAPI 等标准允许我们的 API 生成可以通过 Swagger 和 Redoc 等工具在线提供的文档。

  • 性能和扩展性—我们的 API 应该能够扩展到更大的数据集,这意味着我们可能需要为非常大的数据集引入分页。我们还需要确定 API 请求的可接受等待时间以及如何处理长时间运行的请求。

  • 安全和验证—记住,一个验证用户输入的 UI 只是战斗的一半。我们需要确保服务器也在验证输入,以防止黑客和不良行为者利用我们的 API。

  • 监控和分析—确保我们的 API 在用户访问时正常工作并且有足够的容量,这只能通过适当的监控和日志记录来实现。我们可能需要考虑 OpenTelemetry 以及 Prometheus 和 Grafana 等工具来支持我们 API 的可观察性和监控。

还有其他一些考虑因素,API 是一个有很多书籍讨论的主题。在这个阶段,我们只想提供足够的知识,使你在软件开发角色中更加熟练。如果你对 RESTful API 有更多兴趣,请访问restfulapi.net/。如果你想要对 API 有更深入的理解和更多的实践练习,Arnaud Lauret 的《Web API 设计》(2019 年,Manning 出版社)是一本极好的资源。

4.2 我们的 API

在开发我们的 API 端点之前,我们会考虑需要执行的任务。在这个阶段,我们不会处理很多复杂任务,而只是上传文件、查看文件和查看记录。表 4.2 显示了我们的 API 可能的样子。请注意,:fileId:recordId是其他查询将返回的标识符的占位符。

表 4.2 API 概述
HTTP 方法 统一资源标识符 描述
POST /files 将文件上传到系统中
GET /files 返回已上传文件的列表
GET /files/:fileId 返回特定文件的详细信息
GET /files/:fileId/records 返回特定文件的记录
GET /files/:fileId/records/:recordId 返回特定记录的详细信息

虽然表格提供了我们 API 的良好概述,但考虑这些 API 的流程以及如何获取这些标识符可能会有所帮助。图 4.1 显示了用户与 API 之间的交互。

PlantUML

PlantUML (plantuml.com/) 是一个创建各种 UML 图(如序列图、用例图、状态图等)以及 UI 原型、甘特图和 WBS 图的出色工具。

PlantUML 是使用这些图的一个很好的工具,因为它是一个基于文本的界面。我们只需在编辑器中输入文本就能生成图表!这意味着它们易于更新,易于存储在版本控制中,并且可以与之前的版本进行比较。我们的 IDE 也有可用的插件,我们经常在 VS Code 中使用这个工具。

在整本书中,我们使用 PlantUML 生成的图表。

计算机程序截图  自动生成的描述

图 4.1 由 PlantUML 生成的序列图

希望你现在能更好地理解用户如何使用这些初始 API。这些 API 应该为我们提供项目的良好起点,因为它们满足上传和查看数据的要求。我们可以通过包括查询参数来扩展这些 API,以帮助检索特定数据。例如,我们可能想按日期/时间选择文件,或从特定文件中选择所有批次标题记录。

4.3 使用 FastAPI 构建我们的 API

之前,我们使用 FastAPI 构建了一个简单的健康检查 API,但现在我们将更深入地研究 FastAPI,并开始构建我们的 API 来处理 ACH 处理。让我们首先看看构建我们的GET API,因为它们应该相对简单,可以提供占位符。换句话说,我们目前不需要数据库。我们可以简单地返回一个硬编码的值,以确保一切按预期工作。

虽然这种方法会给你一些关于 FastAPI 如何工作的基本理解,但这并不意味着我们不会担心诸如分页和异步处理等主题,这些主题在一个完整的生产系统中是必要的,以便拥有一个功能性的 API。目前,请记住,返回大量结果,如数千条 ACH 记录,不仅耗时(导致用户等待数据),而且在 UI 上呈现数据也变得困难。这就是允许分页的概念变得有用的地方。虽然 UI 组件可以处理大量数据集,但也将有必要允许 API 处理数据的一部分请求。同样,上传大量的 ACH 文件可能需要时间来处理,用户将不确定过程是否成功;此外,请求可能从用户端超时。使用异步处理,其中发送响应以让用户知道文件已成功上传,并且进行进一步处理以加载文件,变得至关重要。我们将在第十二章再次涉及这一点。

目前,我们从以下文件夹结构的一个简单项目开始:

ApiSample/
├─ docs/
├─ app/
│  ├─ routers/
│  │  ├─ files.py
│  ├─ __init__.py
│  ├─ main.py
├─ tests/

我们确保为我们的 IDE 安装以下要求,这些要求应像使用 Docker 时一样维护在 requirements.txt 中。然而,如果我们只是直接跳入并开始安装 Python 包,那么像 JetBrains Pycharm 这样的 IDE 允许我们根据项目需求创建一个 requirements.txt 文件。在这个冲刺中,我们专注于获得 API 和 FastAPI 的经验,所以现在将包安装到 IDE 中是可行的:

  • pip install fastapi

  • pip install pytest

  • pip install httpx

我们将路由器保存在一个单独的文件夹中,这使得随着应用程序的增长更容易管理。我们也可以参考 YAGNI 原则,通过不使用路由器来保持简单,但鉴于时间和空间限制,我们将稍微提前一点。routers/files.py 包含我们在接下来的章节中将要构建的 API 端点。现在,我们只关注以下列表所示的基本内容。

列表 4.1 基本 FastAPI 路由器
from fastapi import APIRouter  #1

router = APIRouter()  #2

1 从 fastapi 导入 APIRouter,用于定义应用程序的 URI

2 创建一个用于定义特定 URI 的路由器

然后我们有 main.py,它将引入路由器。接下来的列表显示了如何将路由器集成到应用程序中。

列表 4.2 FastAPI 入口点
from fastapi import FastAPI  #1
from .routers import files  

app = FastAPI() #2

app.include_router(files.router) #3

1 导入创建应用程序和之前定义的路由所需的 fastapi 类

2 定义应用程序并将其分配给 app

3 在应用程序中包含我们的路由

使用这个基本结构,我们现在可以看看添加一些路由!

4.3.1 使用 GET /files 获取文件

我们的第一个路由应该返回文件列表(以及最终从文件头记录中获取的一些信息)。现在,我们将硬编码一个响应。因为我们已经定义了路由器,所以我们只需要添加以下代码。

列表 4.3 我们的第一个 API 路由
@router.get("/files/")  #1
async def read_files():  #2
    return [{"file": "File_1"}, {"file": "File_2"}] 

1 一个定义 /files/ 路由的装饰器

2 一个异步函数,用于测试返回硬编码的响应

在这里,我们使用 @router.get("/files") 定义了路由,并调用了一个名为 read_files 的异步函数,该函数返回一个硬编码的文件 JSON 数组。

为了检查这段代码是否工作,让我们编写一个 pytest,如下所示。

列表 4.4 测试我们的第一个端点
from fastapi import FastAPI     #1
from fastapi.testclient import TestClient #1
from app.main import app  

client = TestClient(app)  #2

def test_read_files(): #3
    response = client.get("/files") #4
 assert response.status_code == 200 #5
 assert response.json() == #6
➥[{"file": "File_1"}, {"file": "File_2"}] 

1 测试所需的必要导入

2 定义 TestClient

3 定义一个测试来验证 /files 端点

4 向 /files 端点发送请求

5 断言响应代码是 200,并且响应符合预期

我们导入 TestClient 和我们的应用程序。接下来,我们定义一个客户端,我们可以用它来发送请求。通过调用 get("/files"),我们向 URL /files 发送了一个 GET 请求,这应该是一个有效的端点。我们断言我们收到了 HTTP 状态码 200。然后我们有我们的硬编码 JSON 字符串。这应该与我们在 main.py 的端点返回的内容相匹配。即使运行这个测试完美无缺,我们仍然可能怀疑它是否正确地运行或验证我们的数据。当我们刚开始使用单元测试时,这有时可能是一个常见的担忧。我们可以通过更改我们正在检查的状态码(使其不是 200 的值)、JSON 字符串、从 "files" 的端点或请求类型从 get 来玩弄这些值。更改这些值应该导致测试失败,并验证测试是否正在验证我们的逻辑。

测试类型

能够运行单元测试对我们的开发周期有很大的好处。单元测试为我们提供了一个起点,执行代码并完成整个过程——从开始到结束。但这是什么类型的测试:这是一个单元测试、集成测试,还是其他类型的测试?

我们希望您同意这样一个观点:开发者应该测试他们的代码。不同的系统、条件和代码库可能会使测试变得困难,但在极其罕见的情况下,他们才应该在没有某种测试的情况下发布。

在大多数情况下,开发者将主要关注单元测试。以下图显示单元测试构成了测试金字塔的基础,这是我们将在其中花费大量时间创建测试的地方,尤其是如果我们遵循测试驱动开发方法。

图片

典型的测试金字塔

组件测试可能是之前测试适合的地方。它通常将组件(如 API)隔离,以便我们可以单独评估它,这有助于我们捕捉到更多异常情况,如格式不正确的请求或其他异常条件。

集成测试通常测试多个组件,比单元测试或组件测试涉及更多的依赖项。因此,它们主要关注确保组件可以协同工作。

UI 测试是下一层。使用自动化工具爬取我们的 UI 验证功能来验证 UI,这类似于用户在应用程序中完成各种任务,这并不令人惊讶。

在金字塔的顶部,我们有手动和探索性测试。这是我们在尝试找到破坏软件或查找界面和行为不一致的方法的地方。

我们在这里只是简要地提到了一些主要的测试类别。还有其他类型的测试和分解方法,我们将在稍后介绍。

最后要注意的一点是,随着我们向上移动金字塔,测试变得越来越不独立、越来越慢、越来越脆弱。我们使用“脆弱”一词来表示测试变得越来越难以自动化,并且更有可能因为其不断变化的本性而崩溃。

测试类别的重要性仅在于设置量和测试运行所需的时间。金字塔底部的测试通常比较独立且运行速度快。因此,目前,由于 API 返回的是硬编码的值,运行这个测试作为单元测试是很容易的。一旦我们进一步发展,需要从数据库或其他 API 检索数据,组件开始发挥作用,这个测试可能不适合作为我们的开发测试套件的一部分运行,而应该作为持续集成和持续交付(CI/CD)管道的一部分运行。

虽然我们认为这些测试作为单元测试是有用的,但现实情况是,我们可能希望以 BDD 风格的测试来处理这个问题,因为它们可以被认为是更高级别的测试。

在确保pytest-bdd已安装(pip install pytest-bdd)后,我们在测试目录下创建一个features文件夹和一个step_defs文件夹。我们的测试文件夹的目录结构现在应该看起来像这样。

tests/
├─ features/
│  ├─ files.feature
├─ step_defs/
│  ├─ test_files_feature.py

files.feature将存储我们的场景,以便我们可以处理/files 端点。记住,我们在这个文件中使用Given/When/Then格式定义了特性和各种场景。以下列表是一个确保状态码的简单示例。

列表 4.5 BDD 测试的特性文件
Feature: API /files

  Scenario: I get a 200 status code when we call /files
    When I make a GET request to the endpoint /files
    Then the status code should be 200

接下来,为了激活特性文件,我们在 test_files_feature.py 中创建相应的步骤定义。

列表 4.6 BDD 测试的步骤定义
import pytest    #1
from pytest_bdd import scenarios, when, then, parsers #1
from fastapi.testclient import TestClient #1
from app.main import app #1

client = TestClient(app)

scenarios("../features/files.feature") #2

@pytest.fixture #3
def api_response():  #3
    return {}  #3

@when(parsers.parse('I {http_request_type} the endpoint {endpoint}')) #4
def when_http_request_to_endpoint( #5
    api_response,  #5
 http_request_type, #5
  endpoint #5
):  #5
    if http_request_type == "GET":  #5
        response = client.get(endpoint)  #5
    else:  #5
        raise NotImplementedError #5
    api_response["data"] = response #5

@then(parsers.parse("the status code should be {status_code:d}")) #6
def then_the_status_code_is(api_response, status_code): #7
    assert (  #8
        api_response["data"].status_code == status_code #8
    ), f"Expected {status_code}, but got  
{when_http_request_to_endpoint.status_code}"  #8

1 导入测试所需的模块

2 加载我们在列表 4.5 中定义的特性

3 创建一个固定值来存储 API 的响应;哈希将为空。

4 使用“when”装饰器来处理特性文件中的条件。注意,端点是从特性文件中提取的,将是/files。

5 目前,我们只处理 GET 请求,对于其他任何请求都将抛出异常。我们将重新访问此行以添加所需的功能。注意,API 的响应存储在我们的 api_response 固定值中。

6 使用“then”装饰器来处理特性文件中的“then”短语。注意,我们在特性文件中指定的状态码被替换为 status_code。

7 检查状态码是否是我们预期的,即特性文件指定的应该是 200

在这里,我们加载了files.feature并定义了处理特性文件中的whenthen的函数。此外,我们有一个固定值来存储 API 响应,以便在调用之间使用。注意,尽管我们编写了when以接受多种类型的 HTTP 请求,但我们目前只支持 GET 请求。随着我们创建一个更健壮的 API,它包含POSTPUTDELETE请求,我们需要扩展它,否则我们将收到NotImplementedError

再次,我们可能想要尝试将GET请求改为其他请求的场景,只是为了看到NotImplementedError,然后更改状态码或端点,以了解我们可能在编码时遇到的不同场景。

4.3.2 使用 POST /files 添加文件

我们现在通过向同一/files 端点发送POST请求来扩展我们的示例。这种方法可能被用来上传我们的 ACH 文件。由于我们已经有了一个现有的端点和测试,让我们看看我们可能如何从更行为驱动的开发方法来处理这个任务。当处理POST请求时,我们通常看到201状态码,这意味着“已创建”。由于我们正在上传文件并期望它在服务器上存储,因此201 CREATED响应对我们来说是合适的。因此,我们可以更新特性文件,如下列所示的场景。

列表 4.7 POST请求的 BDD 测试
  Scenario: I get a 201 status code when we post to /files #1
    When I make a POST request to the endpoint /files #2
    Then the status code should be 201 #2

1 我们在这里重用了相同的特性文件,因此我们不需要调整任何“when”或“then”装饰器。

当我们运行这个测试时,我们首先应该收到 NotImplementedError,因为我们没有处理客户端发出的 POST 请求,我们现在用以下代码解决这个问题。

列表 4.8 更新我们的步骤以支持 POST 请求
    if http_request_type == "GET": #1
        response = client.get(endpoint) 
    elif http_request_type == "POST": #2
        response = client.post(endpoint, json={"file": "File_4"}) 
    else: #3
        raise NotImplementedError
    api_response["data"] = response

1 对之前请求的处理保持不变。

2 现在我们添加一种处理 POST 请求的方法。

3 当开发者尝试执行我们尚未编写的功能时,会继续提醒开发者

现在我们正在发送 POST 请求,我们应该从我们的服务器收到一个错误,因为我们实际上没有处理 POST 请求。请注意,在之前的代码中,我们使用了一个硬编码的 post 值用于请求。最终,我们将更新测试以使其动态化,但就目前而言,我们只想得到基本设置。再次运行我们的测试会导致测试失败,因为我们收到了 405 METHOD NOT ALLOWED 状态码:

Expected :201
Actual   :405

这完全合理,因为我们实际上并没有编写服务器代码来处理我们的 POST 请求。让我们跳转到 app/routers/files.py 并实现它(在继续之前,你可以尝试一下)。

列表 4.9 定义我们的 POST 端点
from fastapi import APIRouter, Request #1
... #2
@router.post("/files/") #3
async def create_file(request: Request):  #3
    return {"file": "File_3"}  #3

1 更新导入以包括请求对象

2 之前的端点实现

3 创建一个用 POST 动词和 URI 装饰的函数,现在将返回一个硬编码的值

重新运行测试仍然失败,但这次是因为我们返回了一个 200 状态码。

Expected :201
Actual   :200

从技术上讲,这是可以的,因为我们没有创建任何东西。然而,让我们看看我们如何更新状态码,因为我们将在未来创建数据。我们将更新 files.py 以包含对 status 的导入,并更新 @router.post,如下所示。

列表 4.10 支持状态 201
from fastapi import APIRouter, Request, status #1
… #2
@router.post("/files/", status_code=status.HTTP_201_CREATED) #3

1 我们从 fastapi 导入那个状态对象。

2 之前的代码

3 我们更新装饰器以指定状态码和 20 1 。

设置 status_code 将在所有情况下返回 201,现在我们可以重新运行我们的测试,并看到它通过了。请注意,以这种方式设置状态码将导致此端点始终返回 201。稍后,当我们扩展我们的 API 时,我们将探索其他设置状态码的方法,以便我们可以更精细地控制它们。这种更精细的控制将允许我们在 API 中遇到错误时指定额外的状态码。

4.3.3 完成其余的 API

在上一节中,我们创建了两个 API 来支持获取硬编码的文件列表和上传文件。你应该有足够的关于基础知识的了解来创建剩余的端点和单元测试。花几分钟时间为剩余的端点创建一些单元测试,然后编写剩余的 API 代码,以使这些测试通过。

4.4 将我们的 API 添加到 Docker

太棒了!现在我们已经构建了端点,我们可以在 Docker 容器中运行这些 API。在第二章中,我们获得了大量的 Docker 经验,所以我们可以在这里使用它。本质上,我们只需要将上一章中的 API 容器更新为我们这里所做的代码。让我们看看如何将这段代码在 Docker 中运行起来。

4.4.1 准备项目

我们已经测试了我们的代码,应该对部署感到舒适。对测试套件的信心是持续集成和交付所必需的。如果你在所有测试通过后对部署代码感到不舒服,问问自己为什么。然后找出你需要采取的额外步骤和安全措施。我们可能需要额外的测试来覆盖边缘情况、负载测试、更好的覆盖率、静态代码分析等。自动化测试的想法是帮助提高软件交付时间和质量。

因此,我们应该准备好部署代码。然而,我们之前使用 Uvicorn 来运行 FastAPI 应用程序。记住,Uvicorn 是 ASGI(异步服务器网关接口)兼容的,并且与 FastAPI 相辅相成。为了完整性,我们需要在我们的当前项目中包含它。通过在我们的项目中安装它,我们将能够生成项目的要求列表。所以,确保使用pip install uvicorn来安装它。

在添加了额外的依赖项之后,我们希望创建一个列表,列出将在容器上安装的要求。记住,我们之前试图将 Docker 镜像的大小保持最小;此外,我们永远不应该只假设我们的项目所需的包存在于服务器上。一些 IDE 将提供生成和管理 requirements.txt(Python 要求的标准命名约定)的选项。然而,如果我们的 IDE 没有提供,我们总是可以从命令行使用pip freeze > requirements.txt来生成它。一旦我们这样做,我们就可以审查要求,确保我们有我们想要的包。例如,我们可能想确保uvicornfastapi存在。请注意,我们可能会看到由于我们安装的其他依赖项而出现的额外要求。

拥有 requirements.txt 和应用程序文件夹,我们应该有在 Docker 中运行此代码所需的一切。

4.4.2 Docker 设置

使用我们之前的 Docker 经验(我们可以复制我们已有的设置或从头开始),我们最终得到一个类似于以下结构的文件夹:

api/
├─ app/
├─ Dockerfile
├─ requirements.txt
docker-compose.yml

应用程序代码将存放在 app 文件夹中。目前,我们只需要几个源文件:main.py、files.py 以及相关的 init.py。尽管很可能存在一些特定于 IDE 的额外文件,但无需复制这些文件。

Dockerfile 是所有重头戏发生的地方,但由于我们的结构相同,我们不需要做任何更改。

列表 4.11 我们的 Dockerfile
FROM python:alpine #1

WORKDIR /code #2

COPY ./requirements.txt /code/requirements.txt #3

RUN apk -U upgrade   #4
RUN apk add --no-cache libpq-dev python3-dev gcc musl-dev
RUN pip install --upgrade pip #5#6
RUN pip install --no-cache-dir --upgrade -r requirements.txt

COPY ./app /code/app #7

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] #8

1 使用我们的 Python 镜像的 alpine 版本,因此镜像的大小减小

2 确保我们为容器设置了工作目录

3 将需求复制到镜像中

4 更新镜像并添加我们的额外依赖项

5 更新 pip 并安装需求

6

7 将应用程序代码复制到镜像中

8 使用我们的应用程序启动 uvicorn 并将其绑定到端口 8000

接下来,我们希望 docker-compose.yml 文件能为我们管理服务的构建。目前我们只有一个服务:

services:
  api:
    build: 
      context: ./api
      dockerfile: Dockerfile  
    ports:
      - 8000:8000

一旦这些设置到位,我们应该能够运行常规的 docker-compose builddocker-compose up 命令来启动我们的容器。在将浏览器导航到 http://localhost:8000/files/ 后,我们应该看到返回的硬编码值。在我们的例子中,它是 [{"file":"File_1"},{"file":"File_2"}]

4.5 记录我们的 API

太好了!我们正在稳步前进。虽然我们的大部分 API 都是硬编码的,但我们将继续在前进的过程中对其进行改进。然而,硬编码的值只是使我们的 API 无法使用的一部分。我们主要的问题是没有人知道这一点,如果他们知道了,他们将如何使用它?我们需要文档,而 OpenAPI (www.openapis.org/) 就是我们救星。这是一个允许我们创建交互式文档的标准,并让其他人发现 API 是如何工作的,以及它是如何生成客户端代码和服务器端代码的,以及其他事情。

记得章节开头提到的 API 驱动设计吗?嗯,我们可以提供一个 OpenAPI 规范来生成不仅会消费该 API 的客户端代码,还包括处理这些请求的服务器端代码的起点。随着我们的 Docker 容器启动并运行,我们应该能够直接支持基于 OpenAPI 规范的两种文档格式。我们将在接下来的几节中介绍这两个文档框架。

4.5.1 Swagger

Swagger 是一组用于实现 OpenAPI 定义的工具,但由于历史原因,许多人可能仍然将 OpenAPI 定义称为 Swagger 规范、Swagger 定义或仅仅是 Swagger。你可能会发现自己使用的一个最有用的工具是 Swagger Editor (editor.swagger.io/),它提供了 OpenAPI 定义和结果输出的并排视图。我们可以通过导航到 http://localhost:8000/docs 来浏览 FastAPI 生成的 Swagger 文档。从那里,我们可以查看文档并尝试使用 API。然而,在这种情况下,我们想要点击链接,该链接应带我们到 http://localhost:8000/openapi.json 并显示 OpenAPI JSON。我们可以保存或直接复制粘贴到在线 Swagger 编辑器中。图 4.2 是我们看到的示例截图。

计算机屏幕截图,描述自动生成

图 4.2 在线 Swagger 编辑器

你可以尝试编辑 API 来加深理解。注意,在线编辑器会提示我们将 JSON 转换为 YAML。我们不必这样做,但这确实使就地编辑变得容易一些。

FastAPI 框架支持多种方式来自定义此文档,我们将在稍后探讨这一点。

4.5.2 Redoc

FastAPI 还支持 Redoc,这是一个免费的开源工具,用于创建文档。它提供了一个响应式的三个面板视图,这在处理大型 API 时特别受欢迎。同样,FastAPI 自带提供此文档,我们可以通过访问 http://localhost:8000/redoc 来查看它。图 4.3 显示了我们的示例 API 文档。

计算机屏幕截图  自动生成描述

图 4.3 使用 Redoc 的 FastAPI 文档

流行支付服务 Stripe 使用类似的三个面板视图来展示他们的 API。然而,他们创建了一个名为 Markdoc([markdoc.dev/](https://markdoc.dev/))的工具,帮助生成他们的文档。还有更多工具用于处理 API 文档和一般文档。未来主义金融科技可能已经为这个目的提供了多个工具,因此我们希望遵循我们公司的当前标准,并在需要时建议新的工具。

4.5.3 API 文档有什么好处?

文档是那些可能很难做对的事情之一,这就是为什么 FastAPI 提供 OpenAPI 文档的能力如此出色。为什么?因为我们能够解决主要文档痛点之一,即它很快就会过时。使用 FastAPI,我们可以装饰我们的函数,使它们生成文档,这意味着我们不太可能使文档显示的内容与代码执行的内容不同步。

但这还不是全部。我们实际上只是触及了可用的基本内容。除了能够交互式地查看 API 并尝试它们之外,这里还有一些其他酷炫的功能:

  • 提供以 URL 或电子邮件地址形式存在的联系信息,供最终用户使用

  • 关于你的 API 的许可信息

  • 指向不同的服务器(例如,测试服务器、沙盒或生产服务器)

  • 提供请求/响应示例

  • 生成服务器端代码以处理对 API 的请求

  • 生成客户端代码以与 API 交互

这些只是符合 OpenAPI 规范设计的 API 的一些有趣特性。要了解更多信息,请参阅 Joshua S. Ponelat 和 Lukas L. Rosenstock 合著的《使用 Swagger 和 OpenAPI 设计 API》(2022 年,Manning)。

4.6 管理我们的 API

到目前为止,我们的 API 很小,只包含一个POST请求和四个GET请求。随着我们的 API 的增长,我们需要制定一个管理策略。这最好在早期确定,因为一旦 API 投入生产并被客户使用,就变得难以控制。之前,我们提到了管理 API 的挑战,如版本控制、文档、监控等。我们引入 API 管理软件的使用,以便您对其功能有所了解。我们将 API 管理作为一个总称,涵盖工具中发现的组件。

我们将把 WSO2 的 API 管理器(https://wso2.com/)集成到我们的解决方案中,以突出使用 API 管理器的某些好处。有许多 API 管理解决方案,但我们已经在金融科技行业直接与 WSO2 和 Mulesoft Anypoint(mng.bz/4a2V)合作。

WSO2 解决方案很容易集成到我们的解决方案中。此外,WSO2 的文档和教程信息量很大。正如之前提到的,使用 Docker 使得更换组件变得容易。请随意将其他 API 管理器集成到您的解决方案中,这些 API 管理器可能更适合您的环境。与 API 管理器合作的基础机制和好处应该是普遍适用的。

4.6.1 API 管理的优势

API 管理器可以提供更集中的方法来管理您的 API。如果您的业务有多个应用程序,它们可能运行在不同的服务器上,需要不同的身份验证,并且从网络角度来看,向外界暴露多个点。API 管理器提供了一种集中管理 API 的方法。它们提供帮助货币化 API、通过速率限制(这也可以通过提供不同层来用于货币化)来防范潜在攻击以及提供身份验证/授权的功能。

在大型企业中管理 API 的另一个方面是为我们的用户提供一个中心位置进行访问。如果我们公司有明确的服务和业务单元,它们很可能有不同服务器处理它们的 API,但仍然希望为用户提供单一体验。我们可以将所有 API 发送到单个端点,然后 API 管理器可以将请求定向到您网络中的适当服务器。

一旦我们的请求通过一个集中位置流动,我们就可以为用户提供额外的便利。其中之一是为最终用户提供一个通用的界面来查看和管理 API。如果您有使用像 Google API 这样的 API 的经验,您应该知道它们提供了一个控制台来订阅各种 API。有时,随着公司成长并收购其他公司或合并,多个产品可能属于同一公司,但提供的是不连贯的体验。通过使用 API 管理器,客户可以在一个位置查看所有 API 产品。

除了更好的用户体验外,API 管理器还为我们的 API 提供了更多的分析。我们可以找出调用的次数、正在使用的 API 版本以及大量其他见解。这些分析可以带来额外的收益,如速率限制、负载均衡和货币化。许多 API 提供按使用付费的模式,我们根据 API 使用情况付费。API 管理器有助于促进这一货币化过程。

最后,生命周期管理是使用第三方工具如 WSO2 的另一个好处。拥有一个帮助执行良好生命周期管理实践的工具有助于为您的客户和开发者提供更好的体验。我们可以看到 API 的不同阶段,并在不同状态之间移动 API。如图 4.4 所示,我们有一个当前处于发布状态的 API,可以选择将其恢复到创建或弃用状态。

4.6.2 将 WSO2 API 管理器添加到我们的项目中

首先,我们将 WSO2 添加到我们的 Docker 设置中,创建一个名为 wso2 的新文件夹,并将以下 Dockerfile 放在那里:

FROM wso2/wso2am:latest

我们的 Dockerfile 中没有太多内容,但为了保持一致性,我们继续使用与之前设置相同的布局。真正的工作发生在我们的 docker-compose.yml 文件中,我们在其中添加了 WSO2 服务:

  wso2:
    build: 
      context: ./wso2
      dockerfile: Dockerfile  
    ports:
      - 9443:9443 # Management Console
      - 8243:8243 # HTTPS Requests
      - 8280:8280 # HTTP Requests

port 9443 是管理控制台,我们将在这里进行大部分工作,其他端口(8243/8280)是确保为相应请求类型公开的默认端口。一旦我们完成常规的 docker-compose builddocker-compose up 命令,我们应该能够使用默认用户名和密码 admin/admin 登录。

计算机截图  自动生成的描述

图 4.4 WSO2 的生命周期管理

4.6.3 OpenAPI 版本

在定义我们的 API 到 WSO2 时,我们遇到了一个问题:OpenAPI content validation failed! 我们将 OpenAPI 规范加载到在线 Swagger 编辑器中,并收到错误 Structural error at openapi should match pattern "³\.0\.\d(-.+)?$" pattern: ³\.0\.\d(-.+)?$

在线 Swagger 编辑器提供了更多信息,尽管对于那些没有正则表达式经验的用户来说可能有点晦涩,但它指出版本号不正确。似乎我们的 WSO2 Docker 镜像和 Swagger 编辑器目前都不支持最新版本,但我们的 FastAPI 容器支持。为了确保我们使用的是正确版本,我们可以再次检查 OpenAPI 网站。

ChatGPT 知道最新版本,当我们发布规范并询问它“这是一个有效的 OpenAPI 规范吗?”时,我们收到了以下回复:

图片 您发布的 OpenAPI 规范相当广泛,乍一看,似乎是根据 OpenAPI 3.1.0 规范正确构建的。然而,这样的规范中存在一些细微之处和细节,必须正确才能使其有效。以下是一些关键点和在如此详细的规范中需要检查的常见问题。

出于好奇,我们还尝试输入了一个无效的 OpenAPI 版本(一个更高的数字),ChatGPT 说这不是有效的。

我们可以以几种方式解决这个问题。可能有一个更新的 WSO2 版本支持 3.1.0 版本。然而,在生产环境中,这可能并不总是实际或快速。我们可以将 FastAPI 降级到之前的版本,但让我们假设我们需要在当前版本中的功能或错误修复。在这种情况下,我们可以更新我们的 API 以报告 3.0.0 版本。我们也可以在将其上传到 WSO2 之前手动更新生成的文档。然而,WSO2 内部也有一个选项可以直接访问 URL,我们希望能够使用它。

我们更新了 main.py,添加了以下内容。

列表 4.12 调整 OpenAPI 版本
from fastapi.openapi.utils import get_openapi #1
… 
def custom_openapi(): #2
    if app.openapi_schema: 
        return app.openapi_schema
    openapi_schema = get_openapi(  #3
        title="File API",  #3
        version="0.0.1",  #3
        routes=app.routes,  #3
        openapi_version="3.0.0" #4
    )
 app.openapi_schema = openapi_schema
 return app.openapi_schema

app.openapi = custom_openapi

1 导入 get_openapi 以允许我们自定义 OpenAPI 文档;我们的代码保持不变。

2 定义一个自定义方法

3 在调用 get_openapi 时,这些字段是必需的。

4 这是更新 OpenAPI 版本的方法,并且我们必须修复 WSO2 API 管理器的问题。

这样一来,如果我们重新构建并重启 Docker,在查看 JSON 时应该看到"openapi":"3.0.0"而不是"openapi":"3.1.0"。我们还应该在在线 Swagger 编辑器中看到错误已解决。现在,我们可以继续在 WSO2 中创建 API。

4.6.4 在 WSO2 中创建一个 API

我们可以通过以下步骤创建一个 API:

  1. 点击创建 API 按钮。

  2. 在 REST API 下,点击导入 Open API。

  3. 对于 OpenAPI URL,输入 http://api:8000/openapi.json。我们应该看到系统已验证该 URL。

  4. 输入一个名称(这是 API 在 WSO2 中将被识别的名称):

    1. 上下文是区分 WSO2 中 API URL 的一种方式。

    2. API 的版本。

    3. 端点应该是 http://api:8000

  5. 点击创建。

  6. 在左侧面板下,在部署下点击部署。

  7. 点击部署。

  8. 在左侧面板下,在测试下点击试用:

    1. 我们将被引导到一个与之前看到的类似的 Swagger 文档屏幕。

    2. 点击生成密钥。

  9. 尝试运行你的 API。

在此 API 就绪后,需要注意的事情是,我们不再直接向我们的后端 http://api:8000 发送请求。相反,我们将向 https://localhost:8243/sample/0.0.1/files 发送请求,这是 API 管理器,我们选择的环境,版本和我们的 API。如果我们保留 API 管理器,我们将停止在 Docker 中暴露端口 8000,这样就可以强制任何有兴趣使用 API 的人通过单一入口点。

注意,根据我们目前设置的配置,我们不会在管理器中永久保存 API。所以,记住如果我们花费大量时间设置和配置 API,我们所做的一切都不是永久的。我们只是想设置这个沙盒,因为我们相信,对于你来说,有一些 API 管理工具的经验是有益的,因为你很可能会遇到它们,尤其是在大公司中。

4.6.5 故障排除

当与 API 管理器一起尝试 API 时,最常见的错误是

  "code": "900902",
  "message": "Missing Credentials",
  "description": "Invalid Credentials. Make sure your API invocation call 
has a header: 'Authorization : Bearer ACCESS_TOKEN' or 'Authorization : 
Basic ACCESS_TOKEN' or 'apikey: API_KEY'"
}

“缺少凭证”错误是由于在测试 API 之前没有选择生成密钥而引起的。在生产环境中与 API 一起工作时,我们必须通过仪表板或另一个 API 生成密钥来使用 API。在尝试我们的 API 时,我们只是简单地生成一个临时令牌,用于短时间内使用。

通过 API 管理器或通过 OpenAPI 文档页面进行工作是一种进行初步评估和探索性测试的有帮助的方式。然而,一旦我们确定了将要使用的 API,我们就需要开始使用更多专业化和提供更强大功能的额外工具。以下部分展示了我们将要使用的一些额外工具。

4.7 使用 Postman 进行测试

Postman API 平台是一个流行的工具,用于协助 API 生命周期管理,以及开发人员与其他团队之间的协作,以及 API 治理。我们主要将其用于测试功能,尽管该平台提供了许多其他功能。

我们最喜欢的一个功能是能够导入/导出命令的curl版本。curl是一个无处不在的命令行实用程序,它允许我们使用各种协议获取或发送数据。许多系统管理员在解决网络/路由问题时,会要求你提供RESTful API调用的curl等效命令,因此能够快速轻松地生成一个对于整个团队来说可能是有帮助的。

与团队成员之间的协作在 Postman 中也同样容易,我们已经与我们的 QA 部门紧密合作,帮助开发可用于组件和集成测试的测试脚本。让我们看看我们如何使用 Postman 验证从我们的 API 返回的数据。这与我们在开发时可能进行的单元测试类似。然而,视角是不同的,因为这些可能作为更大型的 CI/CD 管道的一部分或作为 QA 测试过程的一部分运行。

我们将继续使用 http://localhost:8000/files 作为我们的示例。当然,我们也可以接受挑战,使用 API 管理层构建一个 Postman 测试。在 Postman 中,我们可以创建一个用于分组各种请求的集合。然后我们可以创建一个新的GET请求,指向 http://localhost:8000/files 并点击发送。我们应该看到一个格式良好的 JSON 响应,我们在这里进行了简化:[{"file":"File_1"},{"file":"File_2"}]

一旦我们确认我们已成功连接并从端点接收响应,我们也可以添加一些测试:点击“测试”标签页,你可以添加一些简单的测试来了解语法是如何工作的。首先,让我们使用以下列表来测试我们是否有一个有效的200响应代码。

列表 4.13 样本 Postman 测试
pm.test("Response should be JSON and have a 200 status code", function () {
    pm.response.to.be.json; #1
    pm.response.to.have.status(200); #2
});

1 检查响应格式是否为 json

2 检查响应状态是否为 200

在发送我们的请求时,我们应该看到测试结果标签页已填充,并且我们有一个通过测试。我们添加了更多测试,如以下列表所示。

列表 4.14 更多 Postman 测试
pm.test("Response should have a file attribute", function () {
    pm.expect(jsonData[0]).to.have.property('file');
});

pm.test("file attribute should be 'File_1'", function () {
    pm.expect(jsonData[0].file).to.eql('File_1');
});

pm.test("Response has at least 2 items", function () {
    pm.expect(jsonData).to.be.an('array').that.has.lengthOf(2);
});

其中一些测试是脆弱的——这意味着它们可能很容易被数据或顺序破坏——因此我们预计此代码将在已知数据上运行。让我们使用查找File_1的测试来使其更加健壮。因为我们的数据在数组中,我们应该保证在解析时我们总是按该顺序解析它们,而不是对象,其中key/value对可能不会总是以我们看到的方式返回。然而,JSON 的顺序取决于我们无法控制的因素。我们可能保证存在一个File_1,但它可能并不总是首先返回。我们如何编写一个更健壮的测试?如果我们使用for-each循环遍历数组会怎样?

列表 4.15 在 Postman 中使用for-each循环
pm.test("There should be one instance of File_1", () => {
    let found = false;
    jsonData.forEach(function(item, index) {
        pm.expect(item).to.have.property("file");
        if ( item.file === 'File_1' ) {
            found = true;
        }
    })
    pm.expect(found).to.be.true;
})

现在我们定义一个变量来指示我们是否找到了我们想要的项目,这样无论它在数组中的位置如何,我们都能找到它。这是一个更灵活的测试,并且根据你对语言的熟悉程度,这可能是你的第一次尝试,并且由于它有效,这可能就足够了。然而,我们可以使用some方法稍微澄清测试。

列表 4.16 清理我们的测试
pm.test("There should be one instance of File_1 using some", function () {
    const expectedValue = 'File_1';
    const result = jsonData.some(function (item) {
        return item.file === expectedValue;
    });
    pm.expect(result).to.be.true;
})

注意,some方法是 ECMAScript 5+的一部分,并且可以在 Postman 之外使用。我们发现使用 Postman 和这些测试脚本是一种介绍新 QA 资源到某种自动化方式的绝佳方式。我们只是触及了 Postman 所能提供的表面;它是一个我们在各种 FinTech 公司以及其他行业中看到使用的优秀工具。

4.8 使用 Locust 进行负载测试

之前,我们介绍了测试金字塔,它主要包含我们会考虑的功能性测试类型。在这里,我们介绍负载测试,它属于非功能性测试类别。图 4.5 概述了一些测试类型。

计算机屏幕截图  自动生成的描述

图 4.5 软件测试类别

我们想确保我们的 API 将是响应的,看到性能瓶颈可能在哪里,并确定系统的容量以供未来规划。为此,我们可以参考一个名为 Locust 的框架。Locust 允许我们使用我们正在发展的 Python 技能来编写用于负载测试的测试脚本。

我们可以通过告诉 ChatGPT “为 http://localhost:8000/files 端点创建一个 Locust 测试示例” 来轻松开始,这提供了一些易于遵循的步骤。首先,我们想要使用 pip install locust 安装 Locust。同时,我们创建一个 locustfile.py 文件,如下所示。

列表 4.17 简单的 Locust 文件
from locust import HttpUser, task, between

class FileUser(HttpUser):
    wait_time = between(1, 2.5)

    host = "http://localhost:8000"

    @task
    def get_files(self):
        self.client.get("/files")

在 locustfile.py 目录下运行命令 locust。这将提示你打开浏览器到默认位置 http://localhost:8089。在这里,我们将输入用户数量、孵化率、主机以及它应该运行多长时间(我们点击了“高级选项”)以获取(见图 4.6)。

计算机屏幕截图,描述自动生成

图 4.6 使用 Locust 启动新的测试

从那里,我们可以看到测试运行并获得关于我们的 API 性能的指标。显然,在这种情况下,我们正在处理硬编码的值,所以我们将在稍后有机会回来探索我们的 API 的负载测试。然而,我们现在可以继续使用 Locust 和负载测试来获得一些经验。我们能否为其他端点添加测试?如果我们需要提醒它们是什么或者它们应该返回什么,不要忘记我们的实时文档在 http://localhost:8000/docs。通过添加额外的测试,我们的代码应该看起来像下面的列表。

列表 4.18 Locust 测试
from locust import HttpUser, task, between

class FileUser(HttpUser): #1
    wait_time = between(1, 2.5) #2

    host = "http://localhost:8000" #3

    @task #4
    def get_files(self): #5
        self.client.get("/files")  #6

    @task
    def get_file(self):
        self.client.get("/files/1")

    @task
    def post_file(self):
        self.client.post("/files", json={"file_id": 5})

    @task
    def get_records(self):
        self.client.get("/files/1/records")

    @task
    def get_record(self):
        self.client.get("/files/1/records/1")

1 创建一个继承自 HttpUser 的 FileUser 类

2 在请求之间设置一个 1 到 2.5 秒的随机等待时间,以模拟用户在动作之间的暂停

3 定义 API 请求将发送到的主机

4 任务装饰器定义了一个用户模拟的动作。

5 对

/files URI

现在,我们可以重新运行 Locust 来查看它正在击中各种端点。虽然我们的端点目前是硬编码的,但它们将返回使用的路径参数。所以,为了使事情更有趣,让我们为这些测试中的每一个添加一些代码,使得参数是唯一的,这样我们就可以在 Locust 控制台中看到更多的端点被击中。花几分钟时间看看你是否可以用随机数替换硬编码的 file_idsrecord_ids,然后重新运行测试。下面的列表显示了我们所提出的示例。

列表 4.19 Locust
import random
from locust import HttpUser, task, between

class FileUser(HttpUser):
    wait_time = between(1, 2.5)
    host = "http://localhost:8000"

    @task
    def get_files(self):
        self.client.get("/files")

    @task
    def get_file(self):
        file_id = random.randrange(1, 10) #1
        self.client.get(f"/files/{file_id}")

    @task
    def post_file(self):
        file_id = random.randrange(1, 10)
        # Sending POST request to the "/files" endpoint
        self.client.post("/files", json={"file_id": file_id})

    @task
    def get_records(self):
        file_id = random.randrange(1, 10)
        self.client.get(f"/files/{file_id}/records")

    @task
    def get_record(self):
        file_id = random.randrange(1, 10)
 record_id = random.randrange(1, 20)
  self.client.get(f"/files/{file_id}/records/{record_id}")

1 在 1 到 9 的范围内生成一个随机的 file_id

重新运行我们的测试后,我们可以看到访问端点的更多多样性。在这个时候,我们可能已经注意到了在 Locust 控制台中执行测试时出现的问题。如果没有,请查看 Locust 的“失败”选项卡。你应该会看到 /filesPOST 方法的多个失败,错误信息为 HTTPError('405 Client Error: Method Not Allowed for url: /files')。看起来我们在我们的 API 代码中遗漏了一些东西。但在检查它之前,让我们看看 Locust 测试的另一个大问题——没有 assert 语句。

与我们所有的其他测试一样,我们想要确保我们进行了一些 assert 语句。让我们先确保每次都得到一个有效的响应。在继续阅读之前,不妨试一试。

列表 4.20 Locust 测试
…  #1
@task #2
def get_files(self): #3
   response = self.client.get("/files")  #3
   assert response.status_code == 200,  #3
      f"Unexpected status code: {response.status_code}"  #3

@task
def get_file(self):
   response = self.client.get(f"/files/{file_id}")
   assert response.status_code == 200, 
      f"Unexpected status code: {response.status_code}"

@task
def post_file(self):
   response = self.client.post("/files", json={"file_id": file_id})
   assert response.status_code == 200, 
      f"Unexpected status code: {response.status_code}"

@task
def get_records(self):
   file_id = random.randrange(1, 10) #4
   response = self.client.get(f"/files/{file_id}/records")
   assert response.status_code == 200, 
      f"Unexpected status code: {response.status_code}"

@task
def get_record(self):
   file_id = random.randrange(1, 10) #5
 record_id = random.randrange(1, 20) 
 response = self.client.get(f"/files/{file_id}/records/{record_id}")
 assert response.status_code == 200, 
      f"Unexpected status code: {response.status_code}"

1 列表 4.1 9 中的先前代码设置

2 使用 Locust 任务装饰器定义 Locust 应该执行的内容

3 定义了一个 get_files 方法,它对 /files URI 执行 GET 请求并检查 HTTP 状态码为 200

4 使用 1 到 9 范围内的随机 file_id 发起请求

5 使用 1 到 9 范围内的随机 file_id 和 1 到 19 范围内的随机 record_id 发起请求

现在我们有一些 assert 语句,我们可以在异常标签页中看到 Unexpected status code: 405,除了失败标签页。让我们检查 API 容器的日志,看看有什么。日志显示两个有趣的问题:

"GET /files/3 HTTP/1.1" 200 OK
"POST /files HTTP/1.1" 405 Method Not Allowed
"POST /files HTTP/1.1" 405 Method Not Allowed
"GET /files/6/records HTTP/1.1" 307 Temporary Redirect
"GET /files HTTP/1.1" 200 OK

正如我们所见,POST 请求得到了 405 Method Not Allowed 的响应,我们还看到了一个 307 Temporary Redirect。当我们回顾定义端点的代码时,我们发现有些端点后面有斜杠,而有些则没有:

@router.get("/files")
@router.get("/files/{file_id}")
@router.post("/files/", status_code=status.HTTP_201_CREATED)
@router.get("/files/{file_id}/records/")
@router.get("/files/{file_id}/records/{record_id}")

所有的 Locust 请求都没有在请求中包含尾部斜杠,所以让我们确保服务器和客户端都是一致的。在我们的例子中,我们将从服务器部分移除尾部斜杠。重新构建我们的容器并重新运行测试,给我们带来了更一致的经历,没有错误日志或 Locust 中的失败。我们确实看到 Locust 中仍然有异常。这是因为我们编写了所有的 assert 语句,期望返回 200 状态码。然而,当我们参考文档(或查看代码)时,我们被提醒我们的 POST 请求响应的是 201 而不是 200。处理完这个问题后,我们看到测试正常运行,有审查失败、异常以及如图 4.7 所示的相关分析。

一个图表的截图  自动生成的描述

图 4.7 连续的 Locust 运行视图

4.9 改进我们的代码

现在我们有了单元测试和负载测试,你应该可以在代码中工作以进行改进感到舒适。尽管代码很简单,我们仍然可以利用 FastAPI 的一些好处。本节展示了版本控制、使用前缀、文档和验证的好处。

4.9.1 使用前缀和版本控制

你可能已经注意到,我们路由器中的每个端点都包括/files。虽然这没问题,但我们还想包括一个版本号,并将端点放在 URL 中的api端点下。我们希望最终得到类似/api/v1/files的初始标准。使用/api有助于将我们的 API 组织在一个区域。这可以帮助进行 Web 服务器配置、防火墙规则、监控等。v1表示 API 版本,我们可以用它来支持多个 API 版本。记住,一旦最终用户开始使用 API,就不应该对 API 进行破坏性更改,这会迫使他们升级他们的应用程序。在这些情况下,你应该在/v2下提供新的 API,并在可能的情况下逐步淘汰旧版本。记住,/api/v1只是一个建议。你可以命名端点,并根据自己的需求使用任何版本控制方案。

我们通过使用APIRouter中可用的prefix参数重构我们的代码,为端点添加额外的/api/v1。我们更新了files.py

router = APIRouter(prefix="/api/v1/files")

然后,我们从我们定义的每个端点中移除/files。表 4.3 显示了差异。

表 4.3 使用prefix参数对路由器的影响
之前 之后
@router.get("/files") @router.get("")
@router.get("/files /{file_id}") @router.get("/{file_id}")
@router.post("/files ", status_code=status.HTTP_201_CREATED) @router.post("", status_code= status.HTTP_201_CREATED)
@router.get("/files /{file_id}/records") @router.get("/{file_id}/records")
@router.get("/files /{file_id}/records/{record_id}") @router.get("/{file_id}/records/{record_id}")

我们可以看到,通过使用前缀,我们现在在每个端点中需要输入的字符更少了。我们还需要重新运行我们的单元测试,看看是否仍然正常工作,正如我们可能预料的那样,我们的测试都失败了,显示404错误,因为它们不再指向正确的 URL。我们需要继续更新我们的单元测试,包括/api/v1,并重新运行它们。

4.9.2 添加 Pydantic

Pydantic 是一个广泛使用的数据验证库,它捆绑在 FastAPI 中。我们将在这里使用它来摆脱我们的硬编码字符串(尽管我们仍然会返回硬编码的值)。通过添加 Pydantic,我们使我们的代码更容易在未来扩展,并能够利用其一些功能进行验证和文档化。

之后,我们将致力于构建模拟实际 ACH 文件和记录的对象。目前,让我们看看 Pydantic 将如何增强我们当前的 API。我们的第一个端点在/api/v1/files/{file_id}上执行GET操作,我们之前返回的是一个 JSON 字符串。它看起来像这样(带有新定义的前缀)。

列表 4.21 我们之前的硬编码函数
@router.get("/{file_id}")
async def read_file(file_id: str):
    return {"file_id": file_id}

现在,我们创建一个AchFile类,它将模拟我们在上一个列表中返回的硬编码值。

列表 4.22 我们第一个Pydantic
from pydantic import BaseModel, Field

class AchFile(BaseModel):
    file_id: str

在这个简单的类设置到位后,我们可以更新路由器以返回该类的实例。

列表 4.23 更新我们的路由以使用我们新创建的Pydantic
…
from ach.AchFile import AchFile
…
@router.get("/{file_id}")
async def read_file(file_id: str) -> AchFile:
    return AchFile(file_id=file_id)

我们的单元测试仍然应该通过,所以我们没有破坏任何东西。如果我们开始在 Docker 中启动,我们会遇到一个ModuleNotFoundError: No module named 'ach'的错误。这是因为 Python 不知道在哪里找到我们的类,甚至有可能我们忘记复制它了!在启动之前,请向您的 Dockerfile 中添加另一个COPY语句并设置PYTHONPATH。这应该能让我们的代码正常运行。

列表 4.24 更新我们的 Dockerfile
COPY ./app /code/app
COPY ./ach /code/ach

ENV PYTHONPATH /code

现在我们应该能够关闭 Docker,构建,并再次启动我们的容器。如果我们查看文档,我们将在模式部分看到一个AchFile。让我们通过添加一个Field方法来提供额外的文档来继续扩展文档。

列表 4.25 扩展我们的AchFile类的文档
from pydantic import BaseModel, Field #1
…
    file_id: str = Field(examples=["1", "2"], title="ACH File ID", #2
description="The ACH file ID used to identify the file in the system") 

1 导入 Field 函数

2 添加有助于 OpenAPI 文档的参数

通过添加一些示例、标题和描述,模式变得更加有意义。图 4.8 显示了 OpenAPI 文档是如何更新的。

计算机代码屏幕截图  自动生成的描述

图 4.8 我们更新的AchFile模式

这只是使我们的 API 用户友好的开始。随着我们构建更多的 API,我们将扩展这个概念。

4.9.3 使用 Pydantic 进行验证

Pydantic 的另一个优点是其内置的验证功能。在任何生产系统中,验证进入环境中的数据是必须的(除非我们正在测试我们系统的弹性)。我们不应该依赖于 UI 或另一个系统来验证数据。如果最终用户被允许在字段中输入任何类型的数据,他们将会这样做,而且它是否有意义并不重要。通过将 Pydantic 与 FastAPI 结合使用,我们可以免费获得很多验证,而以前我们可能需要大量的模板代码。

例如,我们可以强制file_id至少有一个字符,并且只包含小写字母、下划线和数字。有多种方法可以强制执行此类验证。例如,在 Pydantic 中,我们可以使用正则表达式。通过简单地向字段添加一个模式参数,我们可以强制执行这个约束:

pattern=r"^[a-z0-9_]+$"

既然我们现在正在执行验证,让我们回到 main.py 并在发生验证错误时添加一个异常处理程序。在这种情况下,我们将返回一个 400 状态,以及一些详细的错误信息。我们需要小心,确保我们的异常消息不会泄露敏感信息。在这种情况下,这是一个由缺失或格式不正确的字段触发的验证错误。尽管如此,这可能更适合开发环境;在生产环境中,我们可能希望有一种更健壮的方法将错误转换为已知消息,以降低泄露任何敏感数据的风险。我们的异常处理程序应如下所示。

列表 4.26 Pydantic 中的验证
from fastapi import FastAPI, Request, status, HTTPException
from fastapi.responses import JSONResponse
…
@app.exception_handler(ValidationError)  #1
async def validation_exception_handler(request: Request, exc: ValidationError): #2
    return JSONResponse(  #2
        status_code=status.HTTP_400_BAD_REQUEST,  #2
        content={"detail": exc.errors()}  #2
    ) 

1 为 ValidationError 异常注册自定义异常处理器的装饰器

2 确保返回 HTTP 状态码 400 以及错误详情

从这里,我们可以添加额外的单元测试来确认我们的验证行为。

列表 4.27 验证我们的异常行为
def test_read_file_lower_case(): #1
    response = client.get("/api/v1/files/lower_case_file_id")   #1
    assert response.status_code == 200 #1
    assert response.json() == {"file_id": "lower_case_file_id"}  #1

def test_read_file_validation_error(): #2
    response = client.get("/api/v1/files/this_IS_invalid")  #3
    assert response.status_code == 400 #3
    assert response.json()["detail"][0]["msg"] ==  #3
➥"String should match pattern '^[a-z0-9_]+$'"  #3

1 一个通过验证的测试

2 测试我们收到异常条件时的情况。注意状态码为 400,以及一个详细的消息解释了模式是什么。

我们添加了积极案例来显示我们接受小写 file_id,并且当使用大写值时,我们看到错误告诉我们应该是什么模式。

事实上,默认情况下,我们得到了很多有用的信息。是否适合将这些信息全部展示给最终用户可能又是另一回事。在大多数情况下,API 使用返回详细信息是正常的,但我们想警告不要泄露太多信息。例如,你可能注意到大多数网站不会告诉你是否输入了错误的用户名或密码。他们只是简单地说你有一个或两个是不正确的。这是一个有意识的设计决策,旨在防止恶意用户试图确定有效用户列表。因此,我们有时会向用户返回更通用的信息,并在系统日志中记录详细信息。在这种情况下,我们是可以的,因为它充当了 API 的交互式指南。

列表 4.28 从 Pydantic 收到的详细错误
{
  "detail": [
    {
      "type": "string_pattern_mismatch",
      "loc": [
        "file_id"
      ],
      "msg": "String should match pattern '^[a-z0-9_]+$'",
      "input": "DFSDF",
      "ctx": {
        "pattern": "^[a-z0-9_]+$"
      },
 "url": "https://errors.pydantic.dev/2.4/v/string_pattern_mismatch"
  }
  ]
}

我们刚刚简要介绍了 Pydantic 和 FastAPI 中验证的基础知识。除了正则表达式之外,还有广泛的验证选项,以及创建自定义验证器的功能。我们将在后面的章节中探讨这一点。

4.9.4 Pydantic 故障排除

如果我们没有复制我们的更新 API 代码,我们可能会看到 500 内部 服务器 错误。在我们的自定义处理程序中,我们正在拦截抛出的验证错误,并返回 400 错误代码。如果我们看到以下列表中显示的错误类似情况,我们可能已经犯了那个疏忽,或者我们的处理程序没有正常工作。

列表 4.29 FastAPI 中的 Docker 错误
docker-api-1   | INFO:     192.168.0.1:37630 
 ➥- "GET /api/v1/files/DFSDF HTTP/1.1" 500 Internal Server Error
docker-api-1   | ERROR:    Exception in ASGI application
docker-api-1   | Traceback (most recent call last):
docker-api-1   |   File "/usr/local/lib/python3.12/
➥site-packages/uvicorn/protocols/http/h11_impl.py",
➥line 408, in run_asgi
docker-api-1   |     result = 
➥await app(  # type: ignore[func-returns-value]

摘要

  • API 驱动的开发促进了一种以 API 为首的开发方法,并确保你的软件满足当今的分布式特性。

  • 探索 API 的设计和测试对于与微服务、分布式系统和软件包中的软件组件交互至关重要。

  • REST 是讨论的主要 API 标准,但 GraphQL 和 SOAP 也是其他重要标准,各有其独特的优缺点。

  • API 作为合同,要求遵守使用条款,并为消费者提供具有 SLA 的稳定、可扩展的端点。

  • API 驱动的设计强调清晰的使用案例、版本控制、全面文档以及强大的安全和验证。

  • 有效的 API 监控和分析对于容量评估至关重要,使用 OpenTelemetry、Prometheus 和 Grafana 等工具。

  • 初始的 API 任务很简单,主要关注上传和查看文件及记录,具有清晰的交互流程。

  • FastAPI 用于构建 API,从基本的 GET 端点开始,并支持分页和异步处理以提高性能。

  • 单元测试在开发过程中验证 API 端点,而如 BDD 等附加测试可以评估 API 的行为和性能。

  • 通过 OpenAPI 标准,API 文档确保了实现与用户理解之间的对齐,提供了 Swagger 和 Redoc 等工具。

  • API 管理工具如 WSO2 集中管理,提供生命周期控制,并促进 API 监控、货币化和用户体验。

第五章:存储我们的 ACH 文件

本章涵盖

  • 在我们的 PostgreSQL 数据库中创建表

  • 设计一个能够存储 ACH 文件的数据库

  • 使用 Python 和 Pydantic 验证 ACH 记录并将它们存储在我们的数据库中

  • 确保通过实现使用 pytest 的单元测试来正确解析和存储我们的记录

在这个冲刺中,我们使用另一个研究尖峰来探索如何定义我们的数据库。数据库在应用程序实例之间存储和持久化我们的数据,同时提供一种查询和确保数据完整性的方式。在这里,我们检查如何在数据库中存储我们的 ACH 文件。经过初步分析后,我们扩展了我们的 API 以在数据库中存储 ACH 文件。沿着这个方向继续,我们还扩展了我们的 ACH 解析器以存储单个字段。最后,我们通过检查存储 ACH 数据如何影响我们的单元和负载测试来结束本章。

在我们的项目中引入数据库是必要的,因为 ACH 文件是一个平面文件。Futuristic FinTech 目前使用的 ACH 系统依赖于平面文件,它们在许多领域都存在挑战,包括性能、查询和数据完整性。例如,如果客户质疑交易何时被加载,必须加载并解析所有 ACH 文件以执行搜索,这非常耗时。此外,考虑到处理的记录数量,将解析后的 ACH 文件保留在内存中变得不可行。

5.1 设计我们的数据库

当用户将文件上传到我们的 ACH 仪表板时,我们显然需要保存它们的能力,否则我们的系统将不会很有用。Futuristic FinTech 当前的 ACH 仪表板不使用关系型数据库。相反,一旦上传,文件就会被解析并存储在平面文件中(即非结构化文本文件),这使得实现更复杂的功能变得繁琐。我们正在替换的 ACH 仪表板仅使用文件系统来存储文件。为了提供更高级的处理,我们希望我们的 ACH 仪表板由关系型数据库支持,并且我们通过初步审查和实施各种数据库设计和概念来支持我们的仪表板。通常,我们需要在我们的冲刺中包含这些类型的研究故事,以检查我们在实现所需功能时可能采取的不同方式。

至少有十几种不同的关系型数据库,FinTech 使用了许多种。我们选择的数据库通常已经由我们公司使用的数据库决定。我们见过 FinTech 使用 Oracle、MySQL/MariaDB 和 PostgreSQL——仅举几个例子。在我们的案例中,我们已经设置了一个环境,使得 PostgreSQL 可以在 Docker 容器中运行,我们也看到了如何在启动时构建/初始化表以及如何通过 CloudBeaver 查看我们的数据。现在我们可以开始扩展我们的数据库以容纳存储 ACH 文件。

数据库不仅仅只是存储我们的数据——它们可以帮助确保数据的可靠性和一致性,这个概念被称为引用完整性。在我们的数据库中,引用完整性是一个复杂的说法,意味着我们将确保我们的表适当地相关联,字段定义正确。例如,回想一下 ACH 是一个固定长度格式,这意味着单个字段是固定长度的。我们可能将文件头记录中的文件 ID 修改者存储为VARCHAR(1),因为它只能是一个单个大写字母(或 0 到 9)。同样,我们可能希望将文件控制(即文件尾记录)中的总借方条目金额存储为MONEYNUMERIC(12,2)NUMERIC(12,2)定义了一个精度为 12 位有效数字和 2 位小数位的字段,这是小数位数。你将使用MONEY还是NUMERIC取决于你,但我们更喜欢NUMERIC(12,2)表示,因为它与字段定义非常相似。

另一个引用完整性的方面是防止孤立记录。记住,在 ACH 文件中存在记录的层次结构,即文件控制 → 批次头 → 条目记录 → 等。例如,如果我们没有仔细定义我们的数据库,删除一个批次头记录可能会导致孤立记录。一旦我们删除了批次头,属于该批次的全部条目和附加记录(以及批次控制记录)就不再有效,我们应该删除它们。同样,删除文件控制记录应该删除与该文件关联的所有记录。在我们的关系数据库中,我们可以通过创建一个FOREIGN KEY(它引用其他表条目)并使用ON DELETE CASCADE来实现这一点。

我们最初的数据库将利用关系数据库的固有优势,通过定义以下内容:

  • 主键**s—表中每条记录的唯一标识符

  • 外键**s—两个表之间的链接,其中一个表中的字段(或字段)引用另一个表中的唯一数据(如主键)

  • *字段s的约束—例如,NOT NULL(确保数据存在),UNIQUE(确保数据在所有行中是唯一的),以及DEFAULT(如果没有提供,则分配默认值)

  • 数据完整性—通过定义适当的数据类型和字段大小来获得

我们首先将查看仅存储预安排支付和存款(PPD)数据的 ACH 文件,以使事情更简单。PPD 代码通常用于直接存款和定期支付,如工资和养老金,因此它是一个广泛使用的代码,可能会经常影响你(而你却不知道)。为了了解我们的数据库将看起来像什么,我们再次依赖 PlantUML 来渲染一个建议的数据库结构。

列表 5.1 我们数据库的 PlantUML 定义
@startuml #1

object ach_file_uploads { #2
    ach_files_id: UUID   #3
    filename: VARCHAR(255)  #3
    file_hash: VARCHAR(32)  #3
    credit_total: NUMERIC(12,2)  #3
    debit_total: NUMERIC(12,2) 
}

object ach_files {
    ach_files_id: UUID
    record_type: VARCHAR(1)
    record_id: UUID
    parsed: BOOLEAN    
    sequence: NUMERIC
    unparsed_record: VARCHAR(94)
}

object ach_file_header_records {
    record_id: UUID
    fields for record type
}

object ach_batch_header_records {
    record_id: UUID
    file_header_id: UUID
    fields for record type  
}

object ach_entry_detail_ppd_records {
    record_id: UUID
    batch_header_id: UUID
    fields for record type
}

object ach_addenda_ppd_records {
    record_id: UUID
    entry_detail_id: UUID
    fields for record type
}

object ach_batch_control_records {
    record_id: UUID
    batch_header_id: UUID
    fields for record type
}

object ach_file_control_records {
    record_id: UUID
    file_header_id: UUID
    fields for record type
}

ach_file_uploads::ach_files_id <-- ach_files::ach_files_id  #4
 #4
ach_files::record_id <-- ach_file_header_records::record_id #4
ach_files::record_id <-- ach_batch_header_records::record_id #4
ach_files::record_id <-- ach_entry_detail_ppd_records::record_id #4
ach_files::record_id <-- ach_addenda_ppd_records::record_id #4
ach_files::record_id <-- ach_batch_control_records::record_id #4
ach_files::record_id <-- ach_file_control_records::record_id #4
 #4
ach_batch_header_records::file_header_id -> 
ach_file_header_records::record_id #4
ach_entry_detail_ppd_records::batch_header_id -> 
ach_batch_header_records::record_id #4
ach_addenda_ppd_records::entry_detail_id -> 
ach_entry_detail_ppd_records::record_id #4
ach_batch_control_records::batch_header_id --> 
ach_batch_header_records::record_id #4
ach_file_control_records::file_header_id --> 
ach_file_header_records::record_id #4
 #4
@enduml  #4#5

1 开始一个 PlantUML 定义

2 定义我们的图中的表

3 定义表中的字段以供我们的图使用

4 显示表中键之间的关系

5 结束 PlantUML 定义

前面的定义在图 5.1 中展示,它显示了我们可以如何定义我们的字段以及我们表格之间的关系。这不是表格中字段的详尽列表,但它给了我们一个关于我们的表格将如何关联的想法。箭头表示表中存在的外键约束。例如,我们可以看到 ach_files 表中的 ach_files_id 字段被定义为通用唯一标识符(UUID),并引用了 ach_file_uploads 中的 ach_files_id

计算机图示 自动生成描述

图 5.1 显示了我们表格之间关系的图

图 5.1 也传达了我们希望实现以下目标:

  • 维护我们文件中记录的顺序

  • 假设记录将无法解析,并为此准备无法解析的记录

  • 通过使解析记录引用未解析的记录来维护引用完整性

虽然数据库结构看似满足了这些目标,且图表为我们提供了开始工作的视觉指南,但总有改进的空间。无论这个结构是由主题专家(SME)提供还是由数据库分析师(DBA)的解释,我们在项目进行过程中都可能有机会改进我们的工作。手握我们的图表,我们应该有一个关于数据库外观的想法,并可以开始着手工作。然而,我们需要遵循定义测试、构建表/字段,最后构建 API 的一般模式。当与 SQL 数据库一起工作时,重要的是要理解公司肯定会有不同的方法来管理他们的数据。一些公司可能会将 SQL 部分从开发者那里提取出来,无论是通过使用 SQLAlchemy 这样的对象关系映射器(ORM)还是通过自己开发。ORM 通过抽象数据库并提供如数据库无关性、优化和提升生产力等好处来简化代码。

其他公司可能要求你自己编写 SQL,因为他们喜欢直接 SQL 提供的控制级别。ORM 可能会使复杂查询变得困难或低效。此外,调试查询和性能问题也可能更难追踪。首先,我们在这里展示如何使用我们一直在使用的直接 SQL 命令,然后转向 SQLAlchemy,这样你就可以熟悉这两种方法。无论我们使用哪种方法,或者将它们结合起来,总有几个因素会被添加。通常,现有软件将决定我们的方法,所以请注意不要一开始就偏离现有软件标准太远,因为这可能会造成维护噩梦。

5.2 直接使用 SQL

第三章探讨了解析 ACH 文件并创建相应的单元测试。我们还创建了一个简单的 API,该 API 访问数据库并返回结果。因此,我们拥有了将 ACH 文件存储在数据库中的所有必要组件。现在我们只需要将它们组合起来。在我们的第一个方法中,我们更新了解析器以将 ACH 文件存储在数据库中。我们做出以下假设:

  • 数据库始终处于运行状态。

  • 我们没有想要保留的数据。

  • 我们正在从 IDE 中运行我们的代码,而不是在 Docker 内部。

换句话说,我们只是在处理能够解析和存储数据库中记录的过程。我们扩展了之前的AchFileProcessor以允许它存储 ACH 文件。下面的列表显示了添加必要的代码以获取数据库连接。由于我们处于研究冲刺阶段,我们硬编码了数据库连接参数,如用户名和密码。稍后,当我们确定这是正确的方法时,我们可以开始抽象一些这些硬编码的值,以便通过使用环境变量或秘密管理工具(如 AWS Secrets Manager aws.amazon.com/secrets-manager/)或 HashiCorp Vault (www.hashicorp.com/products/vault)来获得更灵活的配置。

列表 5.2 添加数据库字段
class AchFileProcessor:
    records = []
    exceptions = []
    last_trace_number = None
    expected_record_types = ["1"]
 POSTGRES_USER = "someuser" #1
 POSTGRES_PASSWORD = "supersecret" 
 DATABASE_URL = f"dbname={POSTGRES_USER} user={POSTGRES_USER}
➥ password={POSTGRES_PASSWORD} host=postgres port=5432" #2
…    
 def get_db(): #3
 conn = psycopg.connect(self.DATABASE_URL)
 return conn

1 暂时硬编码用户名和密码

2 使用硬编码的主机和端口,但我们也应该考虑将这些参数参数化。

3 一个新函数,用于返回数据库连接

这应该为我们提供连接到数据库的能力;然而,我们将需要使用此代码。在我们开始解析 ACH 文件之前,我们只想将未解析的记录存储在数据库中,这可能是一个我们可能想要考虑的提取

–加载-转换(ELT)方法而不是直接通过提取-转换-加载(ETL)方法解析记录。

ELT 与 ETL

在处理数据时,有两种处理数据的方法。它们通常与数据仓库和商业智能一起讨论,但 ACH 处理具有独特的挑战。ETL(提取-转换-加载)是一种传统方法,在处理数据时可能会首先出现在我们的脑海中。例如,我们知道我们想要将每个 ACH 记录解析到它们各自的字段中,并将它们存储在数据库中。然而,处理可能不是始终正确格式化的数据是 ACH 处理中的一个挑战。使用 ETL 方法,这些无效数据可能会完全阻止处理。

在 ELT(提取-加载-转换)方法中,数据被加载然后在使用前进行转换。通常,我们在处理非常大的数据和使用如 Snowflake 或 Redshift 这样的数据仓库时看到 ELT,这些数据仓库有足够的处理能力来按请求执行转换。

那么,我们为什么关心这些方法呢?通常,金融机构会对数据进行一些宽容,并且如果错误被认为是可恢复的,它们不会总是拒绝一个文件。这些条件可能因金融机构而异,以及它们的客户。例如,如果控制文件上的记录计数不正确,可能会更新而不是简单地拒绝文件或批次。或者,字段中的无效数据在加载之前可能只是更新为空格。虽然我们可以依赖日志和异常来跟踪更改(我们应该记录任何此类更改),但我们仍然想保留原始文件的记录,以便银行家有审查数据的机会。

5.2.1 将记录添加到 ach_files 表

我们将在解析任何记录之前,将未修改的 ACH 文件加载到数据库中。图 5.2 阐述了本节的基本流程。

图 5.2 5.2 节的流程图

列表 5.3 显示了一个 ach_files 表。因此,在解析任何记录之前,我们只需将所有记录添加到该数据库表中。

列表 5.3 简单的 ach_files
-- Create the uuid extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; #1

-- Create the ach_files table
CREATE TABLE ach_files (
    ach_files_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), #2
    file_name VARCHAR(255) NOT NULL, 
    unparsed_record VARCHAR(94) NOT NULL, #3
    sequence_number INTEGER NOT NULL #4
);

1 允许 PostgreSQL 创建 UUIDs

2 主要 UUID 键

3 以原样存储未解析的 ACH 记录

4 用于在检索数据时保持排序顺序的序列号

使用此代码,运行 docker-compose downdocker-compose builddocker-compose up 应该允许我们的表被创建,现在我们只需更新代码以写入一些记录!

在我们写出任何记录之前,我们必须确保基本功能正常。因此,我们只需在现有的解析例程开头添加以下代码。此代码仅获取数据库连接,获取一个可以用来执行 SQL 语句的光标,然后关闭光标和连接。

列表 5.4 测试连接
    def parse(self, filename) -> [List, List]:

 conn = self.get_db() #1
 cursor = conn.cursor() #2
 cursor.close() #3
 conn.close() #4

        with open(filename, "r") as file:
            lines = file.readlines()

1 调用我们的新函数 get_db() 连接到数据库

2 创建一个用于执行 SQL 命令的光标

3 关闭光标

4 关闭连接

如果我们现在运行代码,我们可能会遇到几个问题。首先,我们硬编码的 DATABASE_URL 字符串 有一个 postgres 主机和一个 5432 端口。我们正在 IDE 中工作,而不是在 Docker 中,所以 postgres 不是应该使用的正确名称。实际上,如果我们运行程序,我们应该看到一个错误 psycopg.OperationalError: connection is bad: Unknown host。相反,我们想使用 localhost,因为我们是从托管 Docker 的系统调用。

此外,我们还需要公开我们容器的端口。我们的 docker-compose.yml 应该看起来像列表 5.5 中的那样。如果不公开端口,我们会看到一个类似于 psycopg.OperationalError: connection failed: :1), port 5432 failed: could not receive data from server: Socket is not connected 的错误,这可能会让我们意识到我们的端口有问题。记住,这些类型的问题总是会出现在我们的开发中,我们只需要回溯我们的步骤,看看我们错过了什么。

列表 5.5 更新的 docker-compose.yml 文件
  postgres:
    build: 
      context: ./db
      dockerfile: Dockerfile
 ports:
 - 5432:5432 #1
    env_file:
      - ./.sql_server.conf

1 揭示了标准的 PostgreSQL 端口

一旦我们有一个基本的连接工作,我们就可以开始编写记录。我们可以使用类似于列表 5.4 中的方法来手动处理打开和关闭连接;然而,记住要关闭连接可能会出错,因为我们可能会忘记关闭它们(我们已经在生产环境中看到文件多年未关闭,直到迁移到新版本的软件或另一个供应商时才出现)。我们将使用 Python 的 with 语句,因为它在退出特定代码块时会自动处理各种资源的关闭。实际上,我们已经在使用它来读取我们的文件了,所以我们可以简单地在此基础上扩展。

列表 5.6 更新的解析函数
    def parse(self, filename) -> [List, List]:

        with open(filename, "r") as file, self.get_db() as conn: #1
            lines = file.readlines()
            sequence_number = 0  #2

            for line in lines:
                sequence_number += 1 #3
                line = line.replace("\n", "")

                with conn.cursor() as cursor: #4
 cursor.execute(f"INSERT INTO ach_files #4
➥ (file_name, unparsed_record, sequence_number) #4
➥ VALUES (%s, %s, %s)", (filename, line, sequence_number)) #4
 conn.commit() 

1 作为现有 with 语句的一部分创建连接

2 将我们的 sequence_number 初始化为零

3 为每条记录递增 sequence_number

4 使用 with 语句创建游标,插入并提交记录

我们可以重新运行我们的单元测试 test_parsing_ach_file.py,这将运行我们的样本文件通过我们的代码,然后检查 CloudBeaver 以验证记录是否已添加。这是一个好的开始:我们可以将记录存储在我们的数据库中,并且使用类似的方法进入我们的单个解析函数并存储数据应该不会太困难。

我们需要做的一件事是更新我们的单元测试,从数据库中获取数据而不是依赖于返回的数据,因为我们的目标是存储所有数据在数据库中,而不是返回任何其他状态。现在,让我们看看如何更新 pytest 来从数据库中获取记录数。

列表 5.7 更新的 pytest
import os
import psycopg
import pytest

from ach_processor.AchFileProcessor import AchFileProcessor

POSTGRES_USER = "someuser" #1
POSTGRES_PASSWORD = "supersecret"             #1
 #1
DATABASE_URL = f"dbname={POSTGRES_USER} user={POSTGRES_USER}  #1
➥password={POSTGRES_PASSWORD} host=localhost port=5432"  #1
 #1
def get_db():                              #1
 conn = psycopg.connect(DATABASE_URL) #1
 return conn #1

def test_record_count():
    dir_path = os.path.dirname(os.path.realpath(__file__))
    file_path = os.path.join(dir_path, "data", "sample.ach")
    expected_result = 41
    parser = AchFileProcessor()
    records, exceptions = parser.parse(file_path)
 with get_db() as conn, conn.cursor() as cursor: #2
 cursor.execute("SELECT COUNT(*) FROM ach_files") 
 record_count = cursor.fetchone()[0]
    assert (
        record_count == expected_result
    ), f"Expected {expected_result}, but got {record_count}"

1 我们从连接到数据库的 AchFileProcessor 中提取代码。这是一个临时解决方案,当需要更多测试连接到数据库时,我们最终需要再次重构代码。

2 简单查询以获取存储在 ach_files 中的记录数

这个测试在 Docker 首次启动时应能正常工作,但随后的测试将失败,因为记录正在被添加。所以,我们的第二次迭代失败了

Expected :41
Actual   :82

我们的第三次迭代失败了

Expected :41
Actual   :123

我们需要在每次测试之前清除数据库。理想情况下,我们希望有一个仅在测试长度内存在的数据库,但首先,让我们看看我们如何在每次测试后清除表。我们可以使用一个 pytest.fixture,它将为我们的单个测试执行任何设置/拆卸操作。

列表 5.8 pytest.fixture 用于设置和拆卸我们的单元测试
@pytest.fixture
def setup_teardown_method():
    print("\nsetup test\n")       #1
    yield                         #2
    print("\nteardown test\n")    #3
    with get_db() as conn, conn.cursor() as cursor: #4
        cursor.execute("TRUNCATE ach_files")        
…
def test_record_count(setup_teardown_method):       #5

1 在 yield 之前执行任何内容。

2 yield 允许测试执行

3 在 yield 之后执行任何内容。

4 获取连接和游标,然后截断表以清除它

5 在单元测试中包含我们的 fixture

使用这段代码,我们的测试将反复通过,因为每次运行后都会清除表。我们已经解决了数据库在运行之间保留记录的问题。当然,我们需要小心。如果我们的单元测试指向的不是开发服务器上的数据库,我们就有可能清除所需的数据。因此,我们可能想要考虑其他选项,例如内存数据库、模拟或 pytest-postgresql

处理数据库

在单元测试中与数据库交互的一种常见方式是使用内存数据库,例如 SQLite 或 H2。这使我们能够运行一个完全存在于内存中的数据库,因此可以将其隔离到我们的单元测试中。通常,这些好处包括快速执行和数据隔离。缺点是,很多时候,我们的生产数据库和这些内存数据库之间可能存在不存在的功能,这可能导致在尝试创建单元测试时出现问题。例如,SQLite 有五种数据类型来定义数据,而 PostgreSQL 有超过 40 种类型。这并不是说一个本质上比另一个更好——我们只是强调,如果我们的表使用不受支持的数据类型,我们可能会面临哪些挑战。我们可能最终会陷入不必要的战斗,以使我们的单元测试运行。这就是为什么我们应该有额外的工具和技术可以使用。

使用像 pgmock 这样的工具进行模拟也可以消除在测试中需要数据库的需求。在我们的场景中,我们实际上是在测试数据是否已到达数据库,所以模拟并不真正提供可行的解决方案,但可以留待以后考虑。

Pytest-postgresql 是一个帮助我们管理 pytestpostgresql 连接的包,它通过允许我们连接到测试数据库或创建/清除/修改测试中的表,提供了两全其美的解决方案。

随着我们的项目进展,我们会发现管理测试数据并保持测试隔离变得越来越困难。在第十章中,我们最终开始引入 Testcontainers 包来隔离我们的测试。当项目基础设施成熟,我们开始将测试作为构建管道的一部分运行时,这种方法也将是有益的。

在大多数应用程序中,存储数据是必要的,但正如我们所看到的,它也增加了我们设计和编码的复杂性。在本节中,我们从小处着手,确保我们能够连接到数据库,并通过存储未解析的记录来最小化所需的代码更改。随着我们继续前进,我们应用程序的复杂性将逐渐增加。在我们处理解析和存储我们的 ACH 文件时,我们还应该记住,我们将从数据库中检索和汇总数据以用于我们的 ACH 仪表板。一种轻松存储我们的 ACH 文件的方法可能适合一种数据库结构,而 ACH 仪表板可能更适合另一种结构。我们的任务是在这两个目标之间找到一个可接受的平衡。

5.3 存储文件头记录

ACH 文件头记录应该是我们在 ACH 文件中遇到的第一个 ACH 记录。因此,将这个记录作为第一个探索的记录添加到数据库中是有意义的。我们首先展示如何使用 ChatGPT 来处理这个问题,然后展示如何通过在我们的 IDE 中安装 GitHub Copilot 来完成一个完整的示例。

5.3.1 使用生成式 AI

生成式 AI 可以帮助处理很多样板代码,这些代码在一段时间后可能会变得重复。根据你的经验水平,这些样板代码可能对你来说是新的,并且多次进行这个过程可能是有益的。一旦变得乏味,这可能是一个很好的迹象,表明我们可以开始依赖生成式 AI 工具。例如,我们可以用以下通用提示来提示 ChatGPT:

image 请创建一个 Postgres 表来存储解析后的 Nacha 文件头记录。

然后,我们从 ChatGPT 那里收到了一个CREATE TABLE语句,它很好地结合了CHARVARCHARNOT NULL

列表 5.9 ChatGPT 生成的文件头 40
CREATE TABLE nacha_file_header (
    id SERIAL PRIMARY KEY,
    record_type_code CHAR(1) NOT NULL,
    priority_code CHAR(2) NOT NULL,
    immediate_destination CHAR(10) NOT NULL,
    immediate_origin CHAR(10) NOT NULL,
    file_creation_date CHAR(6) NOT NULL,
    file_creation_time CHAR(4),  #1
    file_id_modifier CHAR(1) NOT NULL,
    record_size CHAR(3) NOT NULL,
    blocking_factor CHAR(2) NOT NULL,
    format_code CHAR(1) NOT NULL,
    immediate_destination_name VARCHAR(23), #2
    immediate_origin_name VARCHAR(23),  #3
    reference_code VARCHAR(8) 
);

1 file_creation_time是一个可选字段,因此它可能是 NULL。

2 这些字段在 Nacha 标准中也是可选的。注意 ChatGPT 如何使用VARCHAR而不是CHAR,因为这些字段可能会用空格填充。

从个人经验来看,我们更喜欢在大多数字段中使用VARCHAR以避免不必要的填充。我们没有在使用CHARVARCHAR之间遇到任何有意义的性能影响。存储 ACH 记录可能是使用CHAR有意义的领域之一,因为固定长度字段不会有任何不必要的空间。CHAR在声明过大时往往会被误用,任何未使用的空间都会被填充。

出于好奇,我们询问了 ChatGPT 在 Postgres 数据库中CHARVARCHAR哪个性能更好。在对比了两者之后,它(未经我们要求)更新了示例,使用VARCHAR而不是CHAR!我们对此表示赞同,因为我们的偏好是使用VARCHAR

5.3.2 完整示例

如果我们有一个明确的目标或者不介意花时间配置提示,ChatGPT 可以非常有帮助。否则,我们可能想通过 Copilot 的帮助完成这个过程。图 5.3 显示了本节我们将使用的流程。在这里,我们更新过程末尾的单元测试,因为这是一个相对较短的开发周期。如果我们测试前不花太多时间编码,那么我们在稍微编码后进行单元测试应该没问题。

图片

图 5.3 本节流程和相关的代码列表

利用我们对存储记录的了解,我们应该能够将解析后的记录存储在数据库中,一旦完成,其他记录格式应该就会就位。回想一下,我们像以下列表所示那样将解析后的记录作为字典返回。

列表 5.10 包含解析文件头部记录的字典
        return {
            "record_type_code": line[0],   #1
            "priority_code": line[1:3],    
            "immediate_destination": line[3:13].strip(), #2
            "immediate_origin": line[13:23].strip(), 
            "file_creation_date": line[23:29],
            "file_creation_time": line[29:33],
            "file_id_modifier": line[33],
            "record_size": line[34:37],
            "blocking_factor": line[37:39],
            "format_code": line[39],
            "immediate_destination_name": line[40:63].strip(),
            "immediate_origin_name": line[63:86].strip(),
            "reference_code": line[86:94].strip(),
        }

1 我们使用硬编码的值作为偏移量,因为偏移量不会改变,并且我们希望在出现问题时能够快速引用相关字段。

2 当需要时,我们从字段中移除额外的空格。

现在我们想将那条记录存储在数据库中,而不仅仅是简单地返回它。我们可以保持这种解析方法,并从另一个也感兴趣将数据存储在数据库中的例程中调用它。我们可能选择的其他方法之一是创建专门的解析器类或实用方法来处理解析。任何这些方法都可以帮助保持代码的整洁和可重用性。为了简化,我们将采取这个例程,并将其数据简单地存储在数据库中。

首先,我们想在数据库中创建一个用于存储 ACH 文件头部的表格,接下来的列表显示了示例表格。在这个阶段,我们将保持简单,只提供存储数据所需的字段,而不引用任何外键或其他基本约束,如字段 长度NOT NULL。由于安装了 Copilot,许多这些字段名称被自动填充,这为我们节省了一些时间和精力。

列表 5.11 存储 ACH 文件头部的表格
CREATE TABLE ach_file_headers (
    ach_file_headers_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    record_type_code VARCHAR(1) NOT NULL,
    priority_code VARCHAR(2) NOT NULL,
    immediate_destination VARCHAR(10) NOT NULL,
    immediate_origin VARCHAR(10) NOT NULL,
    file_creation_date VARCHAR(6) NOT NULL,
    file_creation_time VARCHAR(4),
    file_id_modifier VARCHAR(1) NOT NULL,
    record_size VARCHAR(3) NOT NULL,
    blocking_factor VARCHAR(2) NOT NULL,
    format_code VARCHAR(1) NOT NULL,
    immediate_destination_name VARCHAR(23),
    immediate_origin_name VARCHAR(23),
    reference_code VARCHAR(8)
);

一旦我们有了表格,我们就可以继续更新我们的代码。我们选择将连接对象(如列表 5.4 中创建的那样)传递到我们的例程中。我们也可以将连接对象作为我们类的一部分存储,但通过作为参数传递将使单元测试我们的例程更容易。不同的情况可能需要不同的方法,所以这绝对不是完成我们任务的唯一方式。

列表 5.12 传递连接对象
match record_type:
    case "1":
        result = self._parse_file_header(conn, line)
    case "5":

现在我们有了连接对象,我们可以更新解析例程以存储数据。

列表 5.13 更新 _parse_file_header 例程
def _parse_file_header(self, conn: Connection[tuple[Any, ...]],
➥ line: str) -> Dict[str, str]: #1
    self.expected_record_types = ["5"]

    file_header = {
    … #2
    }

    conn.execute(f"INSERT INTO ach_file_headers (ach_file_headers_id, " #3
➥                     f"record_type_code, priority_code, 
➥ immediate_destination, immediate_origin," 
➥                     f"file_creation_date, file_creation_time, 
➥file_id_modifier, record_size," 
                       f"blocking_factor, format_code,
➥immediate_destination_name," 
                       f"immediate_origin_name, reference_code) " 
                       f"VALUES (DEFAULT, %(record_type_code)s, #4
%(priority_code)s, %(immediate_destination)s, "
                       f"%(immediate_origin)s, %(file_creation_date)s,
➥%(file_creation_time)s, "
                       f"%(file_id_modifier)s, %(record_size)s,
➥%(blocking_factor)s, %(format_code)s, "
                       f"%(immediate_destination_name)s,
➥%(immediate_origin_name)s, %(reference_code)s)" 
➥                           , file_header) #5

        return file_header

1 我们添加了一个连接参数。

2 返回语句变成了名为 file_header 的变量。

3 我们可以直接在连接上执行以插入记录。

4 我们使用命名变量作为占位符。

5 文件头变量传递了我们的值。

我们现在应该能够将文件头记录插入数据库。接下来,我们可以回过头来更新单元测试。

列表 5.14 更新的单元测试示例
import pytest
import psycopg
from psycopg.rows import dict_row
from typing import Dict
from ach_processor.AchFileProcessor import AchFileProcessor

POSTGRES_USER = "someuser"
POSTGRES_PASSWORD = "supersecret"

DATABASE_URL = f"dbname={POSTGRES_USER} user={POSTGRES_USER} 
➥ password={POSTGRES_PASSWORD} host=localhost port=5432"

@pytest.fixture
def setup_teardown_method():
    print("\nsetup test\n")
    yield
    print("\nteardown test\n")
    with get_db() as conn:
        conn.execute("TRUNCATE ach_file_headers")

def get_db(row_factory = None): #1
    conn = psycopg.connect(DATABASE_URL, row_factory=row_factory) #2
    return conn

def test_parse_file_header(setup_teardown_method):
    sample_header = "101 267084131 6910001340402200830A094101DEST NAME
➥              ORIGIN NAME            XXXXXXXX"

    expected_result: Dict[str:str] = {
… #3
    }

    parser = AchFileProcessor()
    with get_db() as conn:
        result = parser._parse_file_header(conn, sample_header) #4

    with get_db(dict_row) as conn: #5
        actual_result = conn.execute("SELECT * FROM 
➥ ach_file_headers").fetchone() #6
        del actual_result["ach_file_headers_id"] #7

    assert result == expected_result, f"Expected {expected_result},
➥ but got {result}"
    assert actual_result == expected_result,
➥ f"Expected {expected_result}, but got {actual_result}" #8

1 创建另一个带有 row_factory 参数的 get_db 函数

2 在 connect 方法中使用新参数

3 预期结果与之前相同。

4 保持当前的解析结果不变

5 获取另一个连接,指定结果应以字典形式返回

6 返回结果;actual_result 将是一个字典。

7 从返回结果中删除 ach_file_headers_id

8 比较两个结果

在这里,我们重复了一些代码,例如带有新参数的get_db方法。随着我们通过代码,我们必须关注这种重复,并在可能的情况下考虑将这些方法提取到实用程序或辅助类中。JetBrains 的 IDE(以及其他)通常会指出重复的代码,并提供将代码提取到函数中的自动化选项。

我们也保留了原始的结果比较,因为该方法仍然在返回记录。随着我们继续通过添加更多功能来改进项目,我们可能会移除它,转而使用其他返回信息(例如,记录是否被解析)。我们现在应该了解解析后的 ACH 记录如何存储在数据库中。对于 ACH 仪表板,为了提供一个具有聚合 ACH 数据的有意义界面,我们需要能够在数据库中存储所有解析的 ACH 信息。在接下来的章节中,我们将探讨代码如何随着我们的工作而演变,不仅扩展功能,还解决一些非功能性要求,如可维护性和可测试性。

5.4 存储剩余的 ACH 记录

我们现在已经创建了一个数据库并在两个单独的表中存储了数据,因此我们应该有足够的框架来在数据库中存储剩余记录的数据。对于所有剩余的表,过程都是相同的:

  • 创建表并重启 Docker。

  • 更新解析例程。

  • 更新单元测试并验证你的结果。

如果采用测试驱动开发方法,我们可以交换更新解析例程和单元测试。无论如何,我们都应该工作在短反馈周期中,让任何错误在过程早期而不是在实现了一打表之后被发现。我们还发现了一些清理代码并使其更好的机会。

根据我们数据库的预期结构,这可能是一个添加几个表的好地方。检查数据库结构是否合理——是否有什么需要改变的地方?

下一个部分将介绍从处理必须添加的其他表中学习的一些经验和见解。

5.4.1 存储 ACH 文件挑战:经验教训

在添加额外的表格时,我们应该对代码进行一些观察,并抓住机会改进我们的代码。有没有哪些特别突出的?重要的是要注意并解决可能的痛点,例如重复的代码、低效的处理、混乱的逻辑等等。有时候,通过清理代码使其更加直接和易于维护,我们真的能感受到成就感。我们将回顾我们在添加额外数据库表格时遇到的一些问题。

由于我们使用的是 psycopg3(它被混淆地定义为 psycopg)而不是 psycopg2,我们发现我们的生成式 AI 工具往往没有充分利用一些新的增强功能。例如,GitHub Copilot 最初坚持声明 cursor 方法,但似乎学会了我们的偏好,即使用连接,过了一段时间后,它就不再提供这些方法了。这是有道理的,因为 Copilot 应该学习和适应我们的风格和编码偏好。在这些工具的最新版本中,我们也看到了检索增强生成(RAG)的加入,这有助于大型语言模型(LLMs)如 ChatGPT 保持与最新信息的同步。当然,我们还将长期观察它们的性能,因为大量的训练数据并没有使用这些新功能。

在创建表格时,GitHub Copilot 在命名列方面做得很好,大多数情况下它们与文档中的字段名称相匹配,只有少数小的例外。这很有帮助,因为我们已经在我们的 Python 代码中创建了字段名称以匹配 NACHA 标准,并且因为我们能够在 SQL 查询中使用命名参数,所以从返回记录到写入数据库的过渡变得非常容易。另一个我们一直很欣赏的功能(尽管它并不总是起作用)是 Copilot 在编写单元测试方面的协助能力。我们已经为需要解析的每个记录创建了单元测试,当我们将其分解为字典以进行比较时,Copilot 提供了帮助,并用我们的测试数据填充了各个字段。尽管在某些情况下它可能错了一个或两个字符,但总体上它确实很有帮助。

在我们构建单元测试的过程中,我们最终创建了一个 SqlUtils 类来处理一些重复的任务。我们首先将数据库参数,如用户、密码和 URL 移动到例程中。然后后来,我们扩展了这个例程以处理行工厂参数的传递,这样我们就可以返回一个值的字典。我们还创建了一个例程来截断表,以便重复测试有干净的表可以工作。因此,当我们期望检索单个记录但检索到多个记录时,我们的断言不会失败。

与使用 SqlUtils 类去除代码重复类似,我们也通过创建一个变量来保存表名,并在必要时使用 Python f-strings 创建 SQL 命令(但不是用于参数传递),从而在整个单元测试中移除了对表名的硬编码。需要注意的是,尽管我们将其视为内部工具,并且不期望输入恶意代码,但我们仍然尽可能使用参数化查询。然而,我们也考虑了检查传递的表名是否与数据库信息模式中的信息匹配的可能性,以确保它们是有效的。然而,对于内部工具来说,这似乎有点过度了。

我们在编码时多次因为忘记将设置/拆卸 pytest.fixture 放入单元测试方法中而受到咬伤。这通常会在我们反复运行测试时导致错误,因为数据库并不总是处于干净的状态。这种情况发生得足够频繁,以至于我们考虑创建一个类层次结构,以便包含清理表的操作,这样我们就可以避免自己给自己找麻烦。然而,在我们目前的流程中,添加这个功能似乎还为时过早,所以我们暂时回避了这个问题。

5.5 存储异常

现在我们应该已经掌握了使用 Python 和 PostgreSQL 的工作基础。成功将我们的 sample.ach 文件存储到数据库中应该会增强我们的信心。然而,我们也应该注意到我们的异常还没有被存储在数据库中。我们希望也能跟踪这些异常。文件因各种原因被拒绝是很常见的,ACH 处理器需要能够确定文件是否可以手动修复,或者是否需要从原始方请求一个新的文件。图 5.4 展示了这一部分的流程。

图片

图 5.4 本节流程和相关的代码列表

第一项任务是创建表。我们从一个简单的记录开始,该记录只包含错误描述。随着我们项目的扩展,我们可能会发现我们需要按记录类型分解异常,添加对特定记录的引用(以帮助维护数据库的完整性,以防记录被删除或更新),或者实施其他一些随着项目增强而变得更加明显的改进。然而,这些关注点超出了我们当前需要完成的工作范围,所以我们将这个问题留到第八章再解决。

列表 5.15 简单的异常表
CREATE TABLE ach_exceptions (
    ach_exceptions_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    exception_description VARCHAR(255) NOT NULL
);

创建了这个表之后,我们可以重新启动 Docker,并在 AchFile­Processor.py 中添加一个方法来插入记录。

列表 5.16 写入表的简单方法
    def _add_exception(self, conn: Connection[tuple[Any, ...]],
➥ exception: str) -> None: #1
        conn.execute(f"INSERT INTO ach_exceptions#2
➥ (ach_exceptions_id, exception_description) "  #2
                     f"VALUES (DEFAULT, %(exception)s)", #2
➥ {"exception": exception} )   #2
        return

1 简单方法传递一个连接和异常字符串

2 将字符串插入到表中

完成这项任务后,我们可以用一个调用替换我们用来保存异常的数组。我们还需要更新覆盖这些异常的各种测试用例,例如错误的附加指示符、无效的记录长度以及确保跟踪号是递增的。更新这些单元测试提供了创建可维护代码的额外机会。

我们移除了将异常返回给调用例程的操作,因此像records, exceptions = parser.parse(file_path)这样的代码现在变为records = parser.parse(file_path)。然而,这个变化使我们立即检索异常,因为我们的单元测试正在验证异常的数量和异常消息文本。我们选择向SqlUtils添加另一个方法来处理这个问题。

列表 5.17 获取 ACH 异常的方法
    def get_exceptions() -> list:
        with SqlUtils.get_db() as conn:  #1
            exceptions = conn.execute(f"SELECT exception_description
➥ FROM ach_exceptions").fetchall() #2
        flattened_list = [item[0] for item in exceptions] #3
        return flattened_list  

1 与关键字一起使用以获取数据库连接

2 在单个执行命令中获取所有异常

3 返回异常列表而不是元组

在放置了辅助函数之后,我们现在可以用exceptions = SqlUtils.get_exceptions()返回异常,并且现有的单元测试逻辑应该无需任何修改即可工作。

由于我们将异常存储在表中,现在我们的单元测试中需要截断多个表。我们可以继续使用新的表调用SqlUtils.truncate()方法。在这个阶段,每个测试最多只有两个表。然而,我们更希望有一种方法来清除所有表,因为这将确保每个测试的数据库都是空的。列表 5.17 展示了截断所有表的方法。显然,这种方法应该谨慎使用,因为我们现在正在截断数据库中的所有表。我们也可以删除并重新创建数据库;然而,这样做我们可以对每个表进行单独访问,从而获得更多的控制。我们曾与采用这种类型方法的项目合作,以检查表中是否存在意外数据,例如确定数据是否意外写入其他表。当然,我们的需求会因项目而异,可能你不需要或希望使用这种方法截断数据。

列表 5.18 截断所有表的 SQL
DO $$         #1
   DECLARE        #2
      r RECORD;   
BEGIN             #3
   FOR r IN SELECT table_name FROM information_schema.tables
➥ WHERE #D  table_schema = 'public' #4
   LOOP                  #4
      EXECUTE 'ALTER TABLE ' || quote_ident(r.table_name) #4
➥ || ' DISABLE TRIGGER ALL';  #4
   END LOOP;  #4

   EXECUTE (  #5
      SELECT 'TRUNCATE TABLE ' || string_agg( #5
➥quote_ident(table_name), ', ') || ' CASCADE'   #5
        FROM information_schema.tables #5
       WHERE table_schema = 'public'   #5
   );  #5

   FOR r IN SELECT table_name FROM information_schema.tables
➥ WHERE table_schema = 'public' #6
   LOOP #6
      EXECUTE 'ALTER TABLE ' || quote_ident(r.table_name) || #6
➥ ' ENABLE TRIGGER ALL';  #6
   END LOOP;  #6
END $$; #7

1 创建一个匿名代码块

2 声明一个类型为 RECORD 的变量,作为没有预定义结构的行的占位符

3 表示新事务的开始

4 禁用表上的任何触发器

5 收集表列表并截断它们

6 重新启用触发器

7 提交当前事务

到目前为止,我们应该已经到达了与第三章中我们运行代码相似的地方。回想一下,我们能够对一些简单的 ACH 文件进行一些基本的解析,主要的变化是我们现在将我们辛勤工作的结果存储到数据库中。我们可以花点时间自我祝贺,但只能短暂地,因为虽然我们能够解析文件,但我们没有让用户加载文件的方法。接下来的几节将探讨扩展我们的 API 以上传 ACH 文件,并查看它对我们数据库产生的级联效应。

5.6 上传 ACH 文件

我们可能认为这是我们迄今为止最重要的改变。当然,能够解析文件是必要的,但对于许多开发者来说,最令人感到有成就感的是能够与用户进行交互。这可能是因为能够与我们的项目一起工作和交互本身就感觉像是一种成就。我们还认为单元测试给我们带来了几乎相同的成就感,这就是为什么我们如此喜欢测试!图 5.5 显示了本节的流程。

图片

图 5.5 本节的流程和相关的代码列表

我们之前使用硬编码的值构建了一个基本的 API。我们将使用那段代码并添加上传 ACH 文件的功能。从那里,我们将扩展 API 以从数据库中检索数据而不是硬编码的值。在原始的 ACH 仪表板中,一个问题是我们处理 ACH 文件时缺乏控制。

在创建新的 API 之前,让我们确保我们可以使用硬编码的值对现有的端点进行单元测试。接下来的列表显示了它的样子。Copilot 能够在我们输入时生成大部分代码,所以我们只需要确保它按照我们的意图执行。

列表 5.19 FastAPI 的单元测试
from fastapi.testclient import TestClient #1

from app.main import app  #2

client = TestClient(app) #3

def test_read_files(): #4
    response = client.get("/api/v1/files") #5
 assert response.status_code == 200 #6
 assert response.json() == [{"file": "File_1"}, #6
➥ {"file": "File_2"}] 

1 导入单元测试所需的 TestClient

2 导入我们的应用程序

3 定义客户端

4 定义了测试方法

5 调用我们的端点并保存响应

6 断言语句验证响应

所以,这相当简单。现在,我们希望能够将一个 ACH 文件POST到后端,获取一个上传成功的响应,然后开始解析文件。为什么不在解析文件后再向用户返回响应呢?我们的 ACH 文件处理可能需要一些时间,特别是当我们考虑到它最终需要与其他系统交互以执行诸如验证账户/余额、OFAC 验证、欺诈分析以及 ACH 处理器可能想要执行的其他任务时。为了避免让用户等待,我们可以验证文件已上传,并启动一个任务来执行解析。

我们已经有一个用于上传文件的端点,因此我们可以添加一个单元测试然后上传文件。由于文件将通过 HTTP POST 请求上传,它将使用 multipart-formdata 编码。这意味着我们需要 python-multipart 包,因为这是解析使用该格式的请求的要求。如果我们没有安装它,我们将收到一个友好的提醒(以错误的形式)。

列表 5.20 python-multipart 未安装
E   RuntimeError: Form data requires "python-multipart" to be installed. 
E   You can install "python-multipart" with: 
E   
E   pip install python-multipart

创建上传测试应类似于以下列表。

列表 5.21 上传文件的单元测试
def test_upload_file():
    with open("data/sample.ach", "rb") as test_file:  #1
        response = client.post("/api/v1/files", #2
  files={"file": test_file})    
    assert response.status_code == 201  #3

1 打开样本文件

2 使用客户端发送文件

3 确保我们收到 20 1 状态

我们还需要更新端点以接收文件。由于我们只对状态码感兴趣,因此不需要返回任何内容。

列表 5.22 更新后的端点以接收文件
from fastapi import APIRouter, Request, status,
➥ File, UploadFile #1
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_file(file: UploadFile = File(...)): #2
    return None  #3

1 需要额外的导入。

2 定义文件为 UploadFile

3 不返回任何内容;仅返回必要的状态码

现在我们应该有一个成功的单元测试,然后真正的任务可以开始。手动上传文件或通过某些自动过程应该是我们所有 ACH 表的驱动力。我们应该存储一些文件信息,如文件名、上传时间、文件哈希(用于跟踪重复文件),以及我们可能决定需要的其他信息。然后,该记录的 UUID 应该包含在任何子表中(我们刚刚创建的所有之前的表)。如果我们有更多的经验或以不同的顺序处理问题(例如,首先上传文件),我们可能会避免更多的返工,但我们也可能需要从开始引入更多的数据库概念。这种方法的好处是实际需要进行的返工以合并更改。通常,开发者会因为害怕改变而变得瘫痪。大型复杂系统有时就像一座纸牌屋,一次错误的移动可能导致一切崩溃。拥有良好的单元测试覆盖率和对测试的信心可以大大减轻这些恐惧。害怕改变和改进工作软件最终会导致代码腐烂。

我们将重新利用 ach_files 表作为主表,包含上传信息,并将 ach_files 重命名为 ach_records,使其唯一的工作是存储未解析的 ach 记录。更新的表定义如下所示。

列表 5.23 ach_filesach_records 的更新后的表列表
CREATE TABLE ach_files (         #1
    ach_files_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), 
    file_name VARCHAR(255) NOT NULL, 
    file_hash VARCHAR(32) NOT NULL, 
    created_at TIMESTAMP NOT NULL DEFAULT NOW(), 
); #2

CREATE TABLE ach_records (  #3
    ach_records_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    ach_files_id UUID NOT NULL REFERENCES ach_files(ach_files_id)
➥ ON DELETE CASCADE ON UPDATE CASCADE, #4
    file_name VARCHAR(255) NOT NULL,  #5
    unparsed_record VARCHAR(94) NOT NULL,
    sequence_number INTEGER NOT NULL
);

1 ach_files 表被复制并重新用于存储上传详情。

2 ach_files 表被复制并重新用于存储上传详情。

3 之前的 ach_files 将成为 ach_records。

4 创建一个名为 ach_files_id 的外键,它引用 ach_files 表中的 ach_files_id

5 移除 file_name,因为它现在存储在 ach_files 表中

新的表 ach_records 使用 REFERENCESON DELETEON UPDATE 关键字创建到 ach_files 表的外键。这个特性允许数据库保持其引用完整性。例如,当我们从 ach_files 中删除一个 ACH 文件时,我们不希望进入每个表并删除相关数据。相反,我们希望在表之间定义一个关系,如果我们删除了 ach_file,则所有相关数据都会被删除。一旦我们完成了更新我们的表,我们就可以看到这个功能的效果。这也会影响我们的测试。一旦我们实现了引用完整性,我们需要确保 FOREIGN KEY 约束得到维护。

例如,如果我们想让单元测试将记录写入 ach_records 表,我们需要一个有效的 ach_files_id(它必须在 ach_files 表中存在)。因此,我们可能会考虑扩展我们之前开发的 SqlUtils 类,设置一些通用的记录并使这更容易。维护引用完整性在我们设置测试时可能意味着额外的工作,但它值得实现。我们曾在具有引用完整性的系统中工作,并看到程序因不完整的关系而崩溃或循环。通常,缺乏引用完整性在开发人员编写了各种实用程序程序来扫描和修复数据的系统中是明显的(我们不得不在多个场合自己这样做)。

此外,我们还想存储 ACH 文件的 MD5(或您喜欢的任何算法)哈希值,以便识别重复文件。首先,我们可以使用 Get-FileHash -Path ".\sample.ach" -Algorithm MD5 | Select-Object -ExpandProperty 从命令行获取哈希值,在我们的例子中打印出 18F3C08227C3A60D418B3772213A94E3。我们将保留这些信息,因为我们将在数据库中以 Python 计算的哈希值存储,并期望它相同。

有趣的是,在编写函数时,Copilot 提醒我们使用字符串插值来编写 SQL 查询,f"INSERT INTO ach_files (file_name, md5_hash) VALUES ('{file.filename}', '{md5_hash}'),但我们继续使用我们的标准参数化查询,因为我们想保持安全的编码实践。使用字符串插值的查询容易受到 SQL 注入 (mng.bz/QDNj) 的攻击。我们的 /api/v1/files 端点的代码在下一个列表中显示。

列表 5.24 更新端点以上传 ACH 文件
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_file(file: UploadFile = File(...)):
    contents = await file.read() #1
 md5_hash  = hashlib.md5(contents).hexdigest() #2
 with DbUtils.get_db_connection() as conn: #3
 conn.execute( #4
 f""" #4
 INSERT INTO ach_files (file_name, file_hash) #4
 VALUES (%s, %s) #4
 """, (file.filename, md5_hash) #4
 ) #4
    return None #4

1 读取上传的文件

2 使用 hashlib 创建文件的哈希值

3 使用我们新创建的 DbUtils 返回连接

4 简单 SQL 将记录插入到 ach_files

我们创建了一个简单的 DbUtils 类来返回数据库连接。我们想象它将在我们的项目中多个地方使用,因此将其提取到自己的文件中。

列表 5.25 用于创建数据库连接的 DbUtils
import psycopg

POSTGRES_USER = "someuser" #1
POSTGRES_PASSWORD = "supersecret"  #1
 #1
DATABASE_URL = f"dbname={POSTGRES_USER} user={POSTGRES_USER} #1
➥ password={POSTGRES_PASSWORD} host=localhost port=5432" #1

def get_db_connection(row_factory = None):  #2
    conn = psycopg.connect(DATABASE_URL,  #2
➥row_factory=row_factory)  #2
    return conn  

1 硬编码的值将在以后被移除

2 创建并返回一个连接

通过这些更改,我们应该能够运行单元测试,然后转到我们的 CloudBeaver UI,查看新添加的记录并手动比较记录哈希。然而,手动检查记录并不有趣,所以作为最后一部分,我们应该更新我们的单元测试以包含相同的SqlUtils,就像我们之前看到的那样,并确保我们的表中至少有一条记录。以下列表显示了更新的单元测试。

列表 5.26 更新后的上传文件单元测试
…
from tests.SqlUtils import SqlUtils #1
import pytest
…
@pytest.fixture #2
def setup_teardown_method():  #2
 yield #2
 SqlUtils.truncate_all() #2
…
def test_upload_file(setup_teardown_method): #3
    with open("data/sample.ach", "rb") as test_file:
        response = client.post("/api/v1/files", files={"file": test_file})
    assert response.status_code == 201
 assert SqlUtils.get_row_count_of_1('ach_files') #4

1 必要的导入

2 定义我们的固定装置以确保所有表都被截断

3 将固定装置包含在我们的单元测试中

4 确保 ach_files 表中有一行

能够成功上传文件可能看起来不是什么大事,但我们必须记住,我们 ACH 仪表板的所有其他功能都基于这一部分。现在我们可以上传 ACH 文件,我们可以使用诸如错误检查和恢复、账户贷记/借记、欺诈分析等功能。完成这项任务后,我们可以扩展数据库结构。

5.7 以完整性存储记录

如前所述,我们希望我们的数据库具有引用完整性,这可以简化数据库工具中的导航,更重要的是,具有防止悬挂记录的能力。我们已经看到,没有引用完整性的数据库需要定期运行的维护程序来清理那些悬挂记录,以及由不完整数据引起的无限循环和程序崩溃。使用关系数据库的一个巨大好处是它们对数据完整性的支持。我们强烈建议在设计数据库时牢记这一点。

在我们之前的例子中,我们已经定义了PRIMARY KEY以及如何更新ach_records表。在列表 5.23 中,我们通过使用REFERENCES关键字添加了第一个FOREIGN KEY,这使我们开始了维护引用完整性的道路。我们继续使用外键更新剩余的表和代码,并审查使用它们对我们的表、代码和单元测试的影响。

如果我们现在尝试运行我们的单元测试,我们会看到引用UndefinedColumn的错误,因为我们更新了列表 5.23 中的表定义。这不是坏事。我们构建了单元测试,这使我们能够有信心重构我们的代码。我们从深入我们的test_record_count单元测试(来自列表 5.7)开始,解决我们的psycopg.errors.UndefinedColumn: column "unparsed_record" of relation "ach_files" does not exist的错误。有问题的代码如下所示。

列表 5.27 无效的列名
conn.execute("""
                INSERT INTO ach_files
[CA] (file_name, unparsed_record, sequence_number) 
                VALUES (%s, %s, %s)
                """, (os.path.basename(filename), line, sequence_number))

根据你的 IDE 和设置,这个语句可能已经被标记。在 PyCharm 中,我们已经定义了一个数据库连接,因此字段 unparsed_recordsequence_number 被标记为 Unable to resolve column 错误。如果你的 IDE 没有这个功能,那么堆栈跟踪也会显示行号。根据这些信息,我们注意到 INSERT 语句指向了错误的表,因为我们已经将 ach_files 重命名为 ach_records。更改这一点并重新运行测试会得到一个新的错误 psycopg.errors.NotNullViolation: null value in column "ach_files_id" of relation "ach_records" violates not-null constraint。我们定义了 ach_files_id 为外键,它不能为空——实际上,它必须指向 ach_files 表中的一个有效记录。回想一下,我们的 API 会在文件上传时创建这个记录。因此,现在 AchFileProcessor 可能需要同时调用正在解析的文件名和有效的 ach_files_id。我们可以更新单元测试,使解析例程使用这个 ID 被调用。parser.parse(file_path) 将需要变成 parser.parse(ach_files_id, file_path),但定义 ach_files_id 并不足以解决问题。我们需要一种方法来创建一个有效的 ach_files_id,因为我们必须保持数据库的完整性。我们可以在测试期间删除约束,这可能是一个选项,如果我们的单元测试主要关注功能的话。然而,在这种情况下,我们希望保持这个约束。因此,我们需要创建一个插入记录的函数,然后退后一步考虑我们的前进路径,因为我们可能想要采取几条不同的路线。

记住我们已经在代码中编写了一个 INSERT 语句来将 ach_files 记录插入到我们的 API 中。我们可以在单元测试中重复这个查询并获取插入的值。这会有效,但我们会重复代码,而我们希望尽可能避免这种情况。我们可以在 SqlUtils 中添加一个例程来为我们处理这个任务,这样其他方法也可以访问它,因为其他单元测试可能也需要这个功能。然而,SqlUtils 的目的是仅在我们测试时帮助我们,我们已经看到我们还需要在其他地方使用这个功能。也许我们应该为 AchFileProcessor 创建一个工具类来插入我们想要的记录,这将允许我们重构现有的 SQL 查询,使其从解析例程中分离出来。没有明确的答案。根据项目、需求、编程风格和经验,我们可能会看到其他替代方案或对如何处理这个问题有偏好。在这种情况下,我们认为使用 Pydantic 是前进的最佳方式。

5.8 使用 Pydantic

在上一节中,我们遇到了一个问题,即如何处理插入记录以使我们的单元测试工作。虽然我们可以采取几种方法,但 Pydantic 将有助于进一步重构和扩展我们的项目。我们之前在第四章中介绍了 Pydantic 来帮助我们文档化 API,因此我们知道它已经有一些好处。另一个好处是,我们将能够以允许开发者不必记住哪些字段存在的方式来定义我们的表和字段。换句话说,我们开始从解析逻辑中抽象 SQL。让我们从我们的ach_files表开始应用这一点,这样我们就可以创建一个记录并返回一个有效的记录 ID,这正是我们真正想要做的,以解决我们的第一个单元测试问题。

为什么没有对象关系模型框架?

对象关系模型(ORM)有许多好处,并且在许多不同的行业中都得到了广泛的应用。我们现在推迟将如 SQLAlchemy 之类的 ORM 纳入其中,因为我们想确保读者能够直接接触到 SQL,以防那些技能需要复习。

之后,我们将展示如何将 ORM 包含到项目中,所以请耐心等待。当然,如果你熟悉 ORM,你可以直接跳进去开始使用它们。

我们首先为我们的表创建一个 Pydantic 模型,如下所示。这个简单的模型为将要写入我们的ach_files表的建模提供了一个基本布局。

列表 5.28 我们为ach_files的 Pydantic 模型
from typing import Optional           #1
from datetime import datetime          #1
from pydantic import BaseModel, UUID4 #1

class AchFileSchema(BaseModel):  #2
    id: Optional[UUID4] = None   #3
    file_name: str                #3
    file_hash: str                #3
    created_at: Optional[datetime] = None #3

1 必要的导入语句

2 该类扩展了 Pydantic BaseModel。

3 我们的字段定义;注意 Optional 关键字用于数据库将提供的字段(如 ID)。

接下来,我们可以定义一个类,它将提供一些基本的创建、读取、更新和删除(CRUD)方法来处理与数据库表的工作。以下列表显示了我们将创建的AchFileSql类,以封装处理我们的ach_files数据库表的逻辑。

列表 5.29 AchFileSql
from typing import Optional  #1
from uuid import UUID    #1
from psycopg.rows import dict_row #1
from ach_processor.database import DbUtils #1
from ach_processor.schemas.AchFileSchema  #1
➥import AchFileSchema #1

class AchFileSql:
    def insert_record(self, ach_file: AchFileSchema)
➥ -> UUID: #2
        with DbUtils.get_db_connection() as conn:
            result = conn.execute(
                """
                INSERT INTO ach_files(ach_files_id,
➥ file_name, file_hash, created_at)
                               VALUES 
➥(DEFAULT, %(file_name)s, %(file_hash)s, DEFAULT)
                               RETURNING ach_files_id #3
                                """, ach_file.model_dump()) #4

        return result.fetchone()[0]

    def get_record(self, ach_file_id: UUID)
➥ -> AchFileSchema: #5
        with DbUtils.get_db_connection(row_factory=class_row(AchFileSchema))
➥ as conn: #6
            result = conn.execute(
                """
                SELECT * FROM ach_files WHERE ach_files_id = %s
                """, [ach_file_id.hex])

            record = result.fetchone()

            if not record: #7
                raise KeyError(f"Record with id #7
➥ {ach_file_id} not found")  #7

            return record

1 类所需的导入

2 创建一个函数来插入记录并返回 UUID

3 使用 RETURNING 关键字返回新插入记录的 ID

4 使用 model_dump 创建一个引用字段的字典

5 创建一个函数来返回一个指定的记录

6 通过使用 class_row 的 row_factory,我们可以直接返回记录。

7 如果没有找到任何内容,将引发错误

最后,我们创建一个单元测试来验证我们新创建的类。我们创建这个单元测试作为一个类,只是为了展示另一种帮助组织测试的方法。我们还引入了pytest固定件的autouse选项,这样我们就不必在每一个方法中都包含它们。

列表 5.30 测试我们的新类
import pytest  #1
from tests.SqlUtils import SqlUtils #1
from ach_processor.database.AchFileSql import AchFileSql #1
from ach_processor.schemas.AchFileSchema #1
➥ import AchFileSchema #1

class TestAchFileSql:
    @pytest.fixture(autouse=True)   #2
    def setup_teardown_method(self):
        print("\nsetup test\n")
        yield
        SqlUtils.truncate_all()  #3

    def test_insert_record(self):
        ach_file_record = AchFileSchema(
            file_name="sample.ach",
            file_hash="1234567890"
        )
        sql = AchFileSql()
        ach_file_id = sql.insert_record(ach_file_record)  #4
        retrieved_record = sql.get_record(ach_file_id)    #5
        assert SqlUtils.get_row_count_of_1("ach_files")
➥ is True, "Expected 1 record" #6
        assert retrieved_record.file_name == ach_file_record.file_name, #7
➥ f"Expected {ach_file_record.file_name}, but got 
{retrieved_record.file_name}"  #7

1 必要的导入

2 我们现在使用 autouse。

3 完成后截断表

4 插入记录

5 立即返回记录

6 断言语句验证我们的结果

这是一个将我们的项目提升到下一个层次的良好开端。你可能想知道为什么我们没有首先以测试驱动开发的方式编写我们的测试。有时,采取“先测试后”的方法更容易,尤其是在演示新概念时。再次强调,这并不是关于编写测试,而是关于在一个短周期内工作。因此,一旦我们有了可以测试的内容,我们就开始测试它。

记住,使用 Pydantic,我们可以获得字段验证和记录我们的 API 的能力。我们稍后会查看这一点,但现在,我们应该继续重构我们的代码以利用 Pydantic。一旦我们使用 Pydantic 重构了代码并且单元测试通过,我们就可以继续处理 API。

5.9 经验教训

将 Pydantic 包含到我们的代码中并对其进行分离的过程要求我们不仅重构了 ach_file_processor.py,还重构了我们的单元测试。重构使我们能够在两个领域都改进我们的代码,并获得更干净、更容易测试的代码。不幸的是,当我们完成了根据图 5.1 中的原始规范创建的数据库结构后,我们也遇到了一些问题。你在重构代码之前注意到任何结构上的问题了吗?

将外键插入到该数据库结构中揭示了在 ach_files 表中维护数据完整性的问题。虽然从那些子表中删除记录的行为是正确的,但 ach_files 表并没有删除我们想要的全部记录。例如,如果我们删除一个批次,我们期望相关的类型 6-8 记录也会被删除,但在当前的结构中这是不可能发生的。

这种情况并不少见。通常,当给定一个项目的规范时——根据项目利益相关者的经验以及用于研究项目领域的允许时间——可能无法完全梳理出所有设计细节。在扩展敏捷的背景下,我们可能会将这个项目视为一种启用器——更具体地说,是一种探索启用器。探索启用器有助于探索潜在的解决方案,包括研究和原型制作等活动。如果我们没有在项目开始之前充分完成这些活动,我们可能会遇到我们在这里遇到的问题。处理这种情况有几种替代方案,正确的答案可能取决于对项目要求的重新评估。

为了回顾项目要求,我们希望找到一个解决方案,

  • 在处理数据库时提供了 ETL 和 ELT 选项。

  • 在整个文件中提供了数据完整性。由于 ACH 文件是一个层次结构,因此存在多种场景,当文件、批次或条目被删除时,需要清理额外的记录。

让我们提出一些可能需要向业务利益相关者展示的不同选项。

首先,我们可以放弃将单个记录解析到数据库中,而只存储未解析的记录。这无疑会简化数据库设计,因为我们只需处理一个表。当然,这种方法限制了关系数据库的实用性,并且需要额外的应用程序代码来处理我们之前提到过的数据完整性。例如,如果用户想要删除一批记录,我们必须确保应用程序代码删除了所有记录,这将扩大并复杂化我们的应用程序代码。

第二,我们可以放弃在表中存储未解析的记录。如果我们确定我们不需要未解析的记录,这可能是一个潜在的解决方案。当然,这也意味着我们的文件需要符合数据库约束,而且业务已经要求在无效数据(如数值字段中的非数值数据)可能导致解析记录被数据库拒绝的情况下保留未解析的记录。这似乎是一个难以绕过的硬性要求。

第三,我们可以考虑设置数据库触发器来删除记录。数据库触发器是一段代码,可以在数据库中发生某些事件时自动执行。我们可能在解析记录表中创建触发器,当记录被删除时,也会删除相关的未解析记录。但这听起来并不有趣。

我们选择了另一种途径来解决这个问题——将表重新组织成我们为每种类型使用一个未解析记录表的结构。这需要对表及其相关应用程序代码进行大量重构,但对我们来说,如果我们想要保持保留未解析和解析记录的要求,这是最有意义的。更新的数据库结构图如图 5.6 所示,其中还包括了来自图 5.1 的原布局引用。

这种结构为我们提供了最初所寻找的数据完整性,但现在我们必须找到一种方法来查看所有未解析的记录。为了实现这一点,我们创建了一个数据库视图。数据库视图是一个虚拟表,是存储查询的结果。通过使用视图,我们可以避免自己和其他需要使用我们数据库的人进行繁琐的将未解析数据关联起来的任务。接下来的列表显示了创建的数据库视图。

列表 5.31 创建数据库视图
CREATE VIEW combined_ach_records AS
SELECT 
    r1.ach_records_type_1_id AS primary_key, 
    r1.unparsed_record, 
    r1.sequence_number,
    r1.ach_files_id
FROM 
    ach_records_type_1 AS r1

UNION ALL                 #1

SELECT 
    r5.ach_records_type_5_id, 
    r5.unparsed_record, 
    r5.sequence_number,
    r1_r5.ach_files_id
FROM 
    ach_records_type_5 AS r5
JOIN ach_records_type_1 AS r1_r5 
    USING (ach_records_type_1_id) #2
 #3

…
UNION ALL

SELECT 
       r9.ach_records_type_9_id, 
       r9.unparsed_record, 
       r9.sequence_number,
       r1_r9.ach_files_id
  FROM 
       ach_records_type_9 AS r9
  JOIN ach_records_type_1 AS r1_r9 
 USING (ach_records_type_1_id)

1 UNION ALL将合并连续的SELECT语句的结果。

2 使用USING语句可以提供更简洁的语法,而不是使用ON table.field = table.field。在 PostgreSQL 中,当连接字段在表中具有相同的名称时,可以使用此功能。

图片

图 5.6 更新后的表结构图

这段代码允许我们查看整个 ACH 文件,就像它被上传到系统中一样,没有任何我们的处理操作。有一种查看原始文件的方法,这允许我们向用户提供查看整个 ACH 文件内容以及异常记录的选项,以帮助他们调试任何问题。

5.10 编码更改

既然我们已经确定了数据库结构,那么编写任何额外的单元测试以及更新现有的测试以正确运行就是一个问题了。让我们看看为了支持这个结构所做的更改。

5.10.1 为未解析的 ACH 记录创建 Pydantic 架构

所有未解析的记录都共享 unparsed_record 和序列号字段。因此,这是一个创建一个将继承这些字段以避免每次都输入它们的类结构的好机会。我们创建了一个 AchRecordBaseSchema

列表 5.32 我们 ACH 记录的基架构
from abc import ABC  #1
from pydantic import BaseModel #2

class AchRecordBaseSchema(ABC, BaseModel):  #3
    unparsed_record: str    #4
    sequence_number: int  

1 导入 ABC 允许我们创建一个抽象类。

2 BaseModel 是 Pydantic 所必需的。

3 我们的这个类从 ABC 和 BaseModel 继承。

4 所有 ACH 记录类中都将存在的字段

使用该架构,我们可以定义每个记录类型,如下所示。

列表 5.33 ACH 记录架构
from typing import Optional #1
from pydantic import UUID4 #1
 #1
from ach_processor.schemas.ach_record.ach_record_base_schema import AchRecordBaseSchema #1
 #1
class AchRecordType1Schema(AchRecordBaseSchema): #2
    ach_records_type_1_id: Optional[UUID4] = None #3
    ach_files_id: UUID4 #4

1 必须导入

2 由于它是 AchRecordBaseSchema 的子类,而 AchRecordBaseSchemaBaseModel 的子类,因此这是一个 Pydantic 类。

3 我们有一个标记为可选的 ID 字段,因为它将由数据库分配。

4 ach_files_id 字段是必须的,因为它是一个外键,指向已上传的文件。

5.10.2 为解析的 ACH 记录创建 Pydantic 架构

在这个阶段,对于每个解析记录的 Pydantic 定义并不那么有趣,因为我们只是继承了 Pydantic 的 BaseModel 记录并定义了必要的字段。我们将在稍后扩展字段以用于约束、验证和文档。目前,我们只需保持它们简单。

列表 5.34 ACH 批次头记录的 Pydantic 架构
from pydantic import BaseModel, UUID4

class AchBatchHeaderSchema(BaseModel):
    ach_records_type_5_id: UUID4
    record_type_code: str
    service_class_code: str
    company_name: str
    company_discretionary_data: str
    company_identification: str
    standard_entry_class_code: str
    company_entry_description: str
    company_descriptive_date: str
    effective_entry_date: str
    settlement_date: str
    originator_status_code: str
    originating_dfi_identification: str
    batch_number: str

5.10.3 单元测试更改

作为代码重构的一部分,我们确保我们的测试也得到了清理(列表 5.35)。首先,我们更新了 setup_teardown_method 以将 autouse 设置为 true,并确保首先执行 SqlUtils.truncate_all 方法。我们可能之前选择在测试运行后清除表,这是一个很好的实践,可以清理测试中的任何数据。然而,这也带来了一个不幸的副作用,即在测试失败时也会清理数据,这在我们想要在测试后检查数据库时并不是很有帮助。为了使调试和故障排除更容易,我们决定在测试之前清除数据。这也确保了数据库已准备好,因为我们不需要依赖之前的测试来自动清理。添加 autouse 参数意味着我们不再需要将 fixture 传递给我们的测试。我们还使用了 truncate_all 而不是特定的表,因为我们现在使用了多个表。

列表 5.35 更新的 pytest 固定装置
@pytest.fixture(autouse=True)
def setup_teardown_method():
    SqlUtils.truncate_all()
    yield

5.11 设计和不同方法

向数据库添加外键需要进行相当多的重工作,所以我们可能会考虑如何最小化这项工作,或者为什么我们没有一开始就添加它们。正如你所见,我们深入解析了 ACH 文件,并完成了这项工作,然后转向支持一些需要大量重工作的附加功能。这主要是因为我们想在深入研究整体功能和额外的数据库知识之前,先展示一些 ACH 基础知识。

然而,让我们考虑一下我们是否从功能角度来处理这个问题,以及用户是否需要上传这些文件。如果我们当时从ach_files表及其相关端点开始,我们就可以从一开始就包含外键。假设你具备处理 ACH 文件和 API 的知识和经验,这肯定是一个有效的方法。然后我们可以以同样的方式继续进行,唯一的区别是我们可能从一开始就有一个设计得更好的数据库。

这只是说明初始设计同样重要,以及你解决问题的方法。我们之前讨论过启用故事,或者也称为研究尖峰。它们可以降低风险,帮助我们理解需求,并更好地理解需要执行的任务。我们无法强调这些类型故事的重要性,尤其是在处理大型复杂系统时。我们总是可以预期在项目进行过程中需要一些重工作,无论是由于未识别的需求还是无法预见的范围变化。希望研究尖峰可以帮助最小化这些情况,但企业往往难以将时间投入到他们看不到即时收益的事情上。这显然会成为一个问题,因为通过启用故事暴露的问题将不会被识别,在 PI 规划中也不会被发现。我们最终可能采取的方法可能需要重工作,而这些重工作本可以在早期识别出来,因此我们的故事点可以正确分配。

我们已经看到了许多例子,一个项目似乎已经完成,但最终某个主题专家指出在系统演示中遗漏了某些内容,需要重工作。重要的是要记住,我们有时必须处理这些类型的状况,因为这可能会经常诱使我们选择一条更简单的出路。在这种情况下,我们需要评估变化对项目时间表、重工作量、风险、技术债务等因素的影响。例如,企业可能决定我们不需要数据库的引用完整性,并且编写一个可以手动运行的程序来检查悬空记录/缺失关系会更快。

摘要

  • 本章展示了如何创建一个数据库,该数据库可以存储我们的未更改记录,以及一个解析后的记录版本,这在需要提供加载的 ACH 文件详细信息时是有益的。

  • 我们在后期过程中看到了向数据库添加引用完整性的影响,以及需要重新编写我们的代码和单元测试的需求。当一个功能需要重新工作才能正确实现时,它通常可以被放在次要位置。

  • 尽管实现这些类型的功能需要额外的工作,但向团队成员和管理层倡导它们同样重要,以确保它们不会被忽视。

  • 定义数据库对于应用程序中的数据存储、查询和完整性至关重要。

  • 当作为平面文件处理时,ACH 文件在性能、查询和数据完整性方面面临挑战。

  • 关系数据库提供优势,例如主键和外键、约束和数据完整性。

  • 实现引用完整性可以防止孤立记录并确保数据库一致性。

  • 在评估数据库设计和各种实现方法时,研究激增(使能故事)是有益的。

  • ELT 和 ETL 在处理 ACH 文件和处理错误方面提供不同的好处。

  • Pydantic 帮助建模数据库表,抽象 SQL,并增强文档和验证。

  • 上传文件和集成 API 是扩展 ACH 系统功能的基础。

  • 数据和引用完整性对于关系数据库至关重要,可以防止错误并提高可靠性。

  • 持续测试、重构和重新审视初始设计选择有助于维护和改进数据库性能和结构。

第六章:使用 Next.js 迈出下一步

本章涵盖了

  • 使用 Next.js 构建仪表板

  • 使用 Material UI 组件

  • 使用 WireMock 帮助我们模拟 API 调用

到目前为止,我们已经通过研究激增来探索创建和解析 ACH 文件、创建数据库以及生成访问我们数据的 API。在这个最终的研究激增中,我们将开始着手我们的应用程序仪表板。仪表板是一个用户界面(UI),用户可以通过它上传和查看文件、查看记录以及获取任何其他有用的信息。虽然有许多可用的创建 UI 的选项,但我们决定选择 Next.js,因为它建立在 React 之上,React 也非常受欢迎。然而,由于一切都是由 API 驱动的,UI 可以与 Remix、Vue、Angular 或我们公司使用的任何其他技术交换。

第四章向我们展示了如何采用以 API 驱动的设计方法。虽然我们还没有通过切换到 UI 来构建所有我们的 API,但我们将开始看到我们希望(或需要)作为我们 API 一部分返回的数据。通过模拟数据字段,我们可以确保我们的 UI 正在调用与真实 API 完全相同的调用。

6.1 Next.js 入门

Next.js 入门非常简单,我们选择它部分是因为它很容易让应用程序启动和运行,但也因为它可以扩展以支持我们想要创建的任何应用程序。Next.js 是一个全栈 React 框架。React 本身是一个用于构建网络界面的库。尽管它很容易学习,但它确实需要了解一些常见网络技术的基础,例如

  • 超文本标记语言 (HTML)——构建网页的标准标记语言

  • 层叠样式表 (CSS)——描述 HTML 元素应该如何显示的一种方式

  • 文档对象模型 (DOM)——定义了 HTML 元素的属性、方法和事件,允许如 JavaScript 这样的语言添加/更改/删除 HTML 元素和属性,以及响应 HTML 事件

React 通过提供额外的概念来构建和改进列出的网络技术,以促进 UI 开发。React 的一些关键概念包括

  • 组件——可重用且自包含的代码块,用于渲染 UI 的各个部分。它们是我们应用程序的构建块。

  • 状态——在组件内持有动态信息,可用于改变其行为并触发渲染。

  • 钩子——帮助管理组件的状态和生命周期。

所有这些可能看起来很多,尤其是如果你是 UI 开发的初学者。事实是,你应该仍然能够走遍这个研究激增,并对这些概念有一个基本的理解。

那么,Next.js 是如何融入所有这些的?React 网站(react.dev)建议使用 Next.js(或 Remix),因为它是一个功能齐全的生产级 React 框架。这意味着 Next.js 附带了许多强大的功能,例如

  • 基于文件系统的路由,支持多种布局、嵌套路由和错误处理

  • 根据应用需求,进行客户端渲染(CSR)和服务器端渲染(SSR)

  • 使用async/await进行数据获取

  • 支持多种样式方法,如 CSS Modules、Tailwind CSS 和 CSS-in-JS

  • 对图像、字体和脚本的优化

要开始,我们必须确保已经安装了 Node.js(nodejs.org/en),因为我们需要访问其工具,从npx命令开始。npx是一个命令行工具,有助于简化npm(Node 包管理器)命令的运行。

我们可以执行npx create-next-app@latest来回答几个问题,然后使用npm run dev启动项目。现在我们应该能够登录到 http://localhost:3000,看看默认页面是否出现。当然,默认页面是一个测试,以确保我们的设置正确。我们希望开发一个用户可以轻松加载文件并查看相关数据的 UI。通常,业务已经有一个基于现有标准的设计。由于我们不需要遵循任何特定的设计,一个好的选择是开始使用一个示例模板。市面上有很多选择,但一个很好的开始方法是使用 Material UI 提供的仪表板模板,mng.bz/Xx66。我们拉取了 MUI 5.x 的模板,可以在mng.bz/yWnq查看。如前所述,我们不仅限于这个特定的模板,但它确实为我们提供了一个可以快速定制的基布局,以便我们创建最终项目。

我们建议获取模板的 TypeScript 版本,因为我们将在整个项目中使用 TypeScript。TypeScript 是 JavaScript 的超集,这意味着当使用 TypeScript 时,你可以使用现有的 JavaScript 技能。TypeScript 的好处是它为 JavaScript 添加了显式类型(以及其他功能)。我们相信,显式类型有助于长期内代码的可维护性,因为它有助于在开发早期捕获错误。代码的长期可维护性非常重要,尤其是当我们考虑到我们的代码可能在野外存在很长时间时,考虑到 FinTech,这可能是一个很长的时间!虽然我们将在本项目中了解 TypeScript 的基础知识,但 Adam Freeman 的《Essential TypeScript 5》(2023 年,Manning)是一本很好的资源,或者你可以查看www.typescripttutorial.net/上可用的 TypeScript 教程。

在我们可以使用示例模板开始项目之前,我们需要确保已安装依赖项,这可以通过下一列表中的命令行来完成。此命令安装了 Material UI 的一些要求,以及 recharts(用于我们的可视化需求)和 axios(用于进行 API 调用)。这些包被选中是因为我们在 FinTech 中看到了它们的使用,并且它们有良好的文档,可以帮助我们在遇到困难时。

列表 6.1 安装仪表板的依赖项
npm install @mui/icons-material @mui/material @emotion/styled 
@emotion/react recharts axios #1

1 安装我们仪表板所需的所有组件

完成这些后,项目应该已经启动并运行。现在我们只需要自定义仪表板并添加额外的功能。

我们喜欢开始新代码的方式之一是直接深入其中,并开始添加额外的功能或重构现有代码。让我们直接开始吧!

6.2 创建上传页面

为了帮助您开始,Next.js 有广泛的文档和优秀的教程(nextjs.org/learn),如果您对 React 和 Web 开发完全陌生。我们以 Next.js 路由开始,因为我们认为它很简单,只需要在我们的文件系统中创建一个简单的结构。我们可以轻松创建一个页面来处理我们的 ACH 文件上传,如下一列表所示。page.tsx 是显示我们的网页的关键。TSX 是 TypeScript 的语法扩展,允许我们在 TypeScript 文件中编写类似 HTML 的标记。您也可能在各种教程中看到 JSX 页面,它们只是 JavaScript 的等效物。

列表 6.2 我们上传页面的目录结构
src/
├─ app/
│  ├─ uploads/            #1
│  │  ├─ page.tsx         

1 这种基于文件的路由会导致 Next.js 在我们导航到 /uploads 时显示 page.tsx。

我们想要创建一个简单的页面,允许我们将文件拖放到页面上进行上传。现在我们将暂时避开实际的文件上传,先专注于一些设计。根据列表 6.2 的结构,我们想要能够导航到 http://localhost:3000/uploads,所以我们将在 page.tsx 中放置一个占位符,以确保我们可以确认一切正常工作。

列表 6.3 占位符上传页面
import Typography from "@mui/material/Typography";
import * as React from "react";

export default function Uploads() { #1
    return (
        <Typography variant="h1">Hello, Uploads!
➥</Typography> #2
    )
}

1 我们导出了一个名为 Uploads 的默认函数。

2 字体排版是 MUI 的一个组件,允许我们创建文本;在这里,我们使用 h1 的一个变体,这将给我们一个非常长的文本,就像 HTML 的 h1 标签一样。

安装好这些后,您应该能够导航到上传端点并看到“Hello, Uploads!”

6.2.1 测试我们的上传页面

尽管我们还没有创建一个实质性的页面,但让我们花点时间看看我们如何在 React 中测试我们的页面。我们将使用 Jest,这是测试 React 应用程序的一个流行选择。首先,我们需要安装所需的依赖项。

列表 6.4 Jest 依赖项
npm install -D jest jest-environment-jsdom 
➥@testing-library/react @testing-library/jest-dom ts-node #1

1 额外的库以帮助支持测试工作

接下来,我们使用 npm init jest@latest 生成一个基本的配置。然后,我们创建一个目录结构,用于存放我们的测试。

列表 6.5 测试目录结构
__tests__/
├─ pages/
│  ├─ uploads.test.tsx

如列表 6.5 所示,标准流程是创建一个高级的 __tests__ 目录,因为子目录并不重要(你只希望它们有意义)。uploads.test.tsx 将存储我们的实际测试代码。目前,我们只想确保当上传组件渲染时,显示“Hello, Uploads!”文本。为了实现这一点,以下列表展示了示例测试。

列表 6.6 简单 Jest 测试
import { render, screen } from '@testing-library/react' #1
import Uploads from "@/app/uploads/page";  #1
import '@testing-library/jest-dom/jest-globals';  #1
import '@testing-library/jest-dom';  #1
import {describe, it} from "@jest/globals";  #1
import expect from "expect";  #1
#2
describe('Upload Page', () => {  
    it('renders a heading', () => { #3
        render(<Uploads />)  #4

        const heading = screen.getByRole('heading', { level: 1 }) #5

        expect(heading).toBeDefined();  #6
        expect(heading).toHaveTextContent(/^Hello, Uploads!$/); #7
    })
})

1 我们测试所需的导入语句

2 describe 关键字允许我们将测试分组,这些测试将显示在“Upload Page”标题下。

3 it 关键字开始我们的测试,并带有标题“渲染标题”。

4 渲染我们的组件

5 使用 ARIA 角色检索 h 1 标签。级别参数对应于标题的大小:h 1、h2 和 h3 分别使用级别 1、2 和 3。

6 确保变量已定义

7 使用正则表达式确保文本匹配我们期望的文本

列表 6.6 中定义的单元测试应该看起来有些熟悉,考虑到我们用 pytest 在 Python 中创建的测试脚本。当然,语法可能略有不同,但方法相同。回顾用于获取我们的标题标签及其内容的 getByRole 方法将很有趣。此方法使用可访问性丰富互联网应用程序(ARIA)标准中定义的角色。通过使用 getByRole 创建我们的单元测试,我们采取了以可访问性为首要考虑的网页设计方法。辅助技术将通过 ARIA 标签与我们的 Web UI 交互。

计算机程序截图  自动生成的描述

图 6.1 运行我们的 Jest 测试的输出

如果你在一个像 JetBrains、WebStorm 或 VS Code 这样的 IDE 中开发,你可能可以直接在 IDE 中运行这些测试。或者,你也可以从命令行执行 npm run test 命令。从命令行执行有助于说明 describeit 关键字的重要性。如列表 6.6 所示,Upload Page 标题是从 describe 关键字中提取的。每个由 it 关键字定义的测试也被表示出来,如图 6.1 所示,这些关键字使我们的测试保持组织性,输出可读性。

如我们所见,单元测试不仅对我们后端的 Python 代码很重要,对 UI 也同样重要。

如果你直接浏览了上传页面,你可以看到我们丢失了页面的导航栏。下一节将展示如何将它们添加回来。

6.3 重构我们的导航

就像任何我们没有编写的代码一样,我们需要探索以了解布局和结构。由于我们将根据我们的需求自定义仪表板,我们需要了解仪表板如何在组件之间导航。这可能需要一些侦探工作,但这是值得的。经过一番挖掘,我们发现我们页面的导航由我们可以在 Dashboard.tsx 中看到的两个组件控制——AppBarDrawer。我们可以从将它们重构为各自的组件开始。在重构完成后,我们可以决定如何在后续页面上包含它们。

当务之急是从 Dashboard.tsx 中提取 AppBar 标签,并将其移动到 src/app/components/navigation/TopMenuBar.tsx 中的自己的组件。目录结构和命名规范可以在合理范围内自由选择。该组件包含现有的 AppBar 逻辑,并进行了一些更新以处理,无论抽屉是打开还是关闭。

列表 6.7 导航菜单栏
…
interface TopMenuBarProps {  #1
    toggleDrawer(): void;     #1
    drawerOpen: boolean;     
}
…
export default function TopMenuBar({toggleDrawer, drawerOpen}:
➥ TopMenuBarProps) {

    const [open, setOpen] = useState(drawerOpen); #2

    useEffect(() => {        #3
        setOpen(drawerOpen);  #3
    }, [drawerOpen]);        

    return (
    <AppBar position="absolute" open={open}> #4
…
<IconButton edge="start" color="inherit" aria-label="open drawer"
➥ onClick={toggleDrawer}> #5
…
    </AppBar>
    );
}

1 定义了一个接口用于 TopMenuBar,它接受一个在按钮被点击时执行的函数和一个布尔值来确定抽屉是否打开

2 在组件中维护状态

3 当参数 drawerOpen 发生变化时,相应地更新状态

4 AppBar 的 open 参数是我们的内部状态。

5 当图标被点击时,执行传递进来的 toggleDrawer 函数。

我们还希望对侧边导航做类似的事情,因此我们将 Dashboard.tsx 中的 Drawer 标签重构,将其移动到自己的组件中。

列表 6.8 侧边导航
…
interface SideBarNavProps {  #1
    toggleDrawer(): void;     #1
    drawerOpen?: boolean;     #1
}
…
export default function SideBarNav({toggleDrawer, drawerOpen}:
➥ SideBarNavProps) { #2

    const [open, setOpen] = useState(drawerOpen); #3

    useEffect(() => {         #4
        setOpen(drawerOpen);   #4
    }, [drawerOpen]);          #4

    return (
    <Drawer variant="permanent" open={open}> #5
…
            <IconButton onClick={toggleDrawer}> #6
…
    </Drawer>
    );
}

1 定义了一个接口用于 SideBarNavProps,它接受一个在按钮被点击时执行的函数和一个布尔值来确定抽屉是否打开

2 在函数调用过程中解包了 SideBarNavProps

3 在组件中维护状态

4 当参数 drawerOpen 发生变化时,相应地更新状态

5 我们的开启状态用于确定抽屉状态。

6 当图标被点击时,执行传递进来的 toggleDrawer 函数。

然后,我们可以像下一个列表中所示的那样,在 Dashboard.tsx 中使用这些组件。

列表 6.9 更新的 Dashboard.tsx
…    
const [open, setOpen] = useState(true);  #1
const toggleDrawer = () => {      #2
    setOpen(!open);                #2
};                                 #2
…
       <TopMenuBar toggleDrawer={toggleDrawer}
➥ drawerOpen={open} /> #3
       <SideBarNav toggleDrawer={toggleDrawer} #3
➥ drawerOpen={open} />  #3
… #3

1 存储抽屉的状态

2 创建了一个名为 toggleDrawer 的箭头函数,我们调用它来设置打开状态

3 使用我们重构的组件,这些组件接受一个函数来显示/隐藏抽屉和抽屉的当前状态

通过这个重构练习,我们了解了 Material UI 和 Next.js。此外,我们现在显然能够将这些组件包含在我们的上传页面中,这是我们最初的目标。虽然我们已经重构了代码,但在处理抽屉是否打开的问题上,有一些明显的重复处理。按照我们目前的方式,我们需要在每个我们选择使用它们的组件中重复 open 状态和 toggleDrawer 函数,这并不理想,因为它们总是需要一起使用。

导航组件挑战

虽然偶尔可能需要复制代码,但我们可能不需要或想要在这种情况下这样做。我们可能有几种处理方式(也许我们不想将AppBarDrawer元素分离出来)。如果我们将它们重构为单个组件,我们只需要导入该单个组件。或者,既然我们已经将它们分开,我们能否将它们包装成一个单独的组件?这个练习的目的是消除在每个组件中重复open状态和toggleDrawer函数的需要。

6.4 将最近订单转换为最近的 ACH 上传

现在我们已经尝试了一些 Next.js,让我们来看看重新设计Recent Order组件以列出我们的最近 ACH 上传。一如既往,我们希望进行短周期的开发。这意味着我们首先使用模板中作为一部分的硬编码数据,并更新它以适应我们的需求。然后,我们将硬编码的数据替换为模拟的 API 调用。模板有一个函数用于创建数据行,如下一列表所示。

列表 6.10 createData函数
function createData(       #1
  id: number,              
  date: string,            
  name: string,            
  shipTo: string,          
  paymentMethod: string,   
  amount: number,          
) {                        
  return { id, date, name, shipTo, paymentMethod, amount }; #2
}

1 需要更新的现有参数

2 需要更新的现有返回语句

我们重新使用这段代码来包含我们希望在查看当天加载的文件时看到的字段。目前,我们可以想象我们希望看到文件加载的日期(可能还有时间),文件的名称以及文件的信用/借记金额。所需的更新在下一列表中显示。

列表 6.11 更新的createData函数
function createData(      #1
    id: number,            #1
    date: string,          #1
    name: string,          #1
    creditAmount: number,  #1
    debitAmount: number,   #1
) {
    return { id, date, name, creditAmount, debitAmount }; #2
}

1 更新createData的参数。我们在此处不一定需要担心数据类型。我们只使用标准的数字和字符串类型。我们可以将日期定义为日期类型,但在此阶段这是不必要的。

2 更新的返回语句

接下来,我们使用新的金额创建数据行。请注意,我们有两个金额字段,并且每次我们有金额时,我们希望包含各种金额以确保数据按预期显示(由项目中的利益相关者决定)。根据我们使用的 API 和数据类型,在显示金额时可能会出现一些外观和感觉问题。因此,列表 6.12 显示了我们要传递的数据,并且我们想要确保我们处理了一些边缘情况,例如

  • 尾随零,在使用数值数据类型时可能会被丢弃

  • 金额大于$999 时,金额将显示分隔符(如逗号)

列表 6.12 样本数据
createData(
    1,
    '03/20/2023',  #1
    'sample1.ach',
    1866.90, #2
    1234.10  
)

1 返回一个日期;这个日期可能通过 API 作为时间戳返回,我们需要处理它。

2 确保我们的数值值覆盖了我们概述的一些用例

根据 API,你可能正在接收一个格式化的金额,在这种情况下,你显然不需要担心提到的那些问题。然而,当我们设计 API 时,我们通常尽量将格式化保持在最低限度,除非必要,否则不强制执行格式化/区域特定性。因此,我们更喜欢返回1000.0代表$1,000.00,因为根据位置的不同,我们可能使用空格和/或小数来分隔数字。当然,用例因公司而异,因项目而异。在我们的情况下,我们处于一个独特的位置,可以控制 UI 和后端,这意味着我们可以按照我们想要的显示方式返回金额,并将其直接放置在页面上。

尽管在这个应用程序中不太可能成为问题,但本地化(L10n)可能对我们来说是个问题,因为 ACH 处理的是美国金融机构,这就是为什么我们更喜欢 API 返回未格式化的金额。也许我们计划让我们的仪表板足够抽象,以便容易适应 SEPA 或 BECS,因此我们需要确保我们的应用程序具有灵活性。

接下来,我们更新表格以显示更新的行。我们使用map函数从rows中获取数据,并为每个元素创建表格行。

列表 6.13 更新的表格
<Table size="small">
   <TableHead>
      <TableRow>
         <TableCell>Date</TableCell>                       #1
         <TableCell>Filename</TableCell>                    #1
         <TableCell align="right">Credit Total</TableCell>  #1
         <TableCell align="right">Debit Total</TableCell>   #1
      </TableRow>
   </TableHead>
   <TableBody>
      {rows.map((row) => (          #2
         <TableRow key={row.id}>    #3
            <TableCell>{row.date}</TableCell> #4
            <TableCell>{row.name}</TableCell> 
            <TableCell align="right">         #5
               {`${row.creditAmount}`}         #5
            </TableCell>                       #5
            <TableCell align="right">          #5
               {`${row.debitAmount}`}          #5
            </TableCell>                       #5
         </TableRow>
      ))}
   </TableBody>
</Table>

1 更新的表头

2 我们使用 map 创建一个包含标签的新数组。

3 每个表格行包含一个键属性。

4 使用我们的行数据用于日期和名称

5 我们以原样显示金额字段以查看其外观。

在这一点上,如果我们查看我们的网页,我们应该注意到我们正在遇到我们提到的那些问题——截断末尾的零和缺少千位分隔符,如图 6.2 所示。

我们可以通过使用toFixed和硬编码的美元符号来执行一些基本格式化,如列表 6.14 所示。

带有黑色线条的白色背景,描述自动生成

图 6.2 未格式化的信用和借记总额
列表 6.14 基本格式化
<TableCell align="right">
   {`$${row.creditAmount.toFixed(2)}`}     #1
</TableCell>                                 #1
<TableCell align="right">                    #1
   {`$${row.debitAmount.toFixed(2)}`}        #1
</TableCell>

1 对金额进行一些初始格式化,强制使用美元符号,并通过使用 toFixed(2)确保我们看到两位小数

这段代码只使用千位分隔符进行基本格式化。我们不必记住在所有地方格式化货币,这可能导致不一致并使我们陷入一个区域,让我们定义一个函数,我们可以在需要格式化货币时调用它。这不仅会在我们展示货币方面提供一致性,而且还应该允许我们找到我们在代码中显示货币的地方(如果我们需要的话)。

为了实现这一点,我们将使用Intl.NumberFormat,它不仅可以为不同位置格式化货币,还可以为其他度量单位,如 mph/kh 和 liter/gallon。以下列表显示了我们的简单导出函数,它包装了Intl.NumberFormat

列表 6.15 Intl.NumberFormat包装器
export function formatCurrency(value: number,  #1
           currency: string = "USD", #2
           locale: string = 'en-US'): string {  #3
    return new Intl.NumberFormat(locale, { #4
        style: 'currency',                  #4
        currency: currency                  #4
    }).format(value);                       #4
}

1 函数接收一个数字。

2 可以传递不同的货币,但默认为 USD。

3 可以控制区域设置,默认为 en-US。

4 实际的工作是由 Intl.NumberFormat 完成的,我们将其格式化为货币。

我们还可以创建一些 Jest 测试来确保我们理解这段代码的工作方式,以及确认我们看到了预期的格式。以下列表显示了我们可以用来确认格式化正在进行的示例 Jest 测试。

列表 6.16 对我们的formatCurrency例程的 Jest 测试
import {describe, it} from "@jest/globals";               #1
import expect from "expect";                               #1
import {formatCurrency} from "@/app/utils/CurrencyUtils"; 

describe('Formatting Currency', () => {              #2
    it('Has trailing zeros', () => {          #3
        const result = formatCurrency(1.0)     #3
 #3
        expect(result).toBe('$1.00')           #3
    })                                         #3

    it('Default currency is USD', () => {    #4
        const result = formatCurrency(1.1)    #4
 #4
        expect(result).toBe('$1.10')          #4
    })                                        #4

    it('Has comma separator', () => {          #5
        const result = formatCurrency(1000.0)   #5
 #5
        expect(result).toBe('$1,000.00')        #5
    })                                          #5

    it('Has comma separators for large numbers', () => {  #6
        const result = formatCurrency(1000000.0)           #6
 #6
        expect(result).toBe('$1,000,000.00')               #6
    })            #6

    it('German formatting', () => {   #7
        const result = formatCurrency(1000000.0, 'EUR', 'de-DE')   #8
 #8
        expect(result).toBe('1.000.000,00\u00A0€')       #8
    })                                                   #8
})

1 导入必要的函数

2 使用 describe 来组合测试

3 确保不删除尾随零

4 确保使用默认值

5 检查是否包含逗号

6 检查是否添加了多个逗号

7 测试欧元格式的德语。\u00A0是表示不间断空格的 Unicode 表示法。

在确认我们的测试运行成功后,我们可以用对新的formatCurrency例程的调用替换调用toFixed(2)的表格单元格。

列表 6.17 使用新的formatCurrency函数
<TableCell align="right">                #1
   {{formatCurrency(row.creditAmount)}}   #1
</TableCell>                             
<TableCell align="right">
   {{formatCurrency(row.debitAmount)}}
</TableCell>

1 用对新的函数的调用替换手动格式化

图 6.3 显示了我们的金额格式化得正好是我们想要的,有小数点后的逗号和两位有效数字。

白色背景,黑色线条,自动生成描述

图 6.3 格式化后的信用和借记总额

通常,使用现有的组件作为起点并定制它以满足我们的需求更容易。在我们的定制之后,我们有一个能够向用户显示上传的 ACH 文件的运行组件。

6.5 模拟我们的 ACH 上传数据

在上一节中,我们定制了我们的组件以适应我们查看最近 ACH 文件上传的特定用例。然而,我们使用了硬编码的值,虽然这有助于我们了解所需的字段,但对于除了模拟 UI 之外的其他用途并不太有用。我们有一个数据库,我们希望从该数据库中检索这些数据,但在构建 API 方面我们还没有完全准备好。作为一个中间步骤,让我们添加 API 调用,但让它检索模拟数据而不是实际击中端点。模拟数据不仅让我们能快速开始,而且还有各种其他用途,如测试边缘情况、模拟不良数据和其他场景。因此,了解模拟数据将带来超越这个示例的长期利益。

我们为什么会选择这样做呢?现实情况是,项目可能涉及多个人、团队、部门,甚至公司。我们完全可能被分配去工作在 UI 上,而 API 和后端代码则并行开发。当我们试图尽可能快地将产品推向市场时,这种并行开发通常在进行,我们无法等待 API 和后端完成后再开始前端工作。或者,我们可能需要调用第三方 API,但合同谈判仍在进行中,我们无法访问该 API。

在这种情况下,像 WireMock 这样的工具可以让我们的代码执行相同的 API 调用,但返回预定义的响应。我们还可以使用这个工具来获取错误响应或边缘情况数据,这些数据我们可能没有 readily available,这有助于我们测试我们的代码。

6.5.1 将 WireMock 添加到 Docker

我们以相同的方式将 WireMock 添加到我们的项目中——首先创建一个 Dockerfile,然后在 docker-compose.yml 中引用它,如下面的两个列表所示。所需的 Dockerfile 内容不多,可以包含在 docker-compose.yml 中,但我们更喜欢尽可能保持 docker-compose.yml 的简洁。

列表 6.18 WireMock 的 Dockerfile
FROM wiremock/wiremock:latest-alpine

ENTRYPOINT ["/docker-entrypoint.sh",
➥ "--global-response-templating", "--disable-gzip",
➥ "--verbose"]

注意,在第 6.19 列表中,我们已经向 WireMock 服务定义中引入了卷。这些卷存在于本地文件系统中,并通过将它们映射到容器中的目录来提供。类似于端口映射的方式,我们首先指定主机目录,然后指定它应该映射到容器中的目录。WireMock 允许我们指定存根映射和文件定义存根(稍后详细介绍)。使用卷将允许我们更改映射,而无需反复构建。虽然 Dockerfile 允许使用 VOLUME 关键字来指定卷,但推荐的方法是使用 docker-compose.yml 文件,因为它允许在构建(Dockerfile)和运行时(docker-compose.yml)之间分离职责。

我们是否可以在 Dockerfile 中使用 COPY 命令将我们的映射移动到容器中?如果我们要构建一个包含映射的镜像并将其分发给团队,这将是一个备选方案。然而,在这种情况下,我们只对在本地使用这些映射感兴趣,并且我们不需要每次想要使用更新的映射时都重新构建我们的容器。

列表 6.19 docker-compose.yml 的更新
  wiremock:
    build:
      context: ./wiremock
      dockerfile: Dockerfile
    volumes:                                       #1
      - ./wiremock/__files:/home/wiremock/__files   #1
      - ./wiremock/mappings:/home/wiremock/mappings
    ports:
      - 8081:8080

1 将我们的文件系统上的位置映射到容器

完成这些后,我们可以再次启动 Docker 容器,导航到 http://localhost:8081/__admin/,在那里我们应该收到类似于以下列表的响应。

列表 6.20 WireMock 管理员响应
{
  "mappings" : [ ],
  "meta" : {
    "total" : 0
  }
}

这段代码确认我们确实已经启动了 WireMock。也许不出所料,我们还没有定义任何映射。那么,让我们在下一节中解决这个问题。

6.5.2 在 WireMock 中设置映射

WireMock 是一个强大的工具,我们在这个部分只是触及了表面。然而,这应该足以让我们开始,并且我们可以根据需要的功能来调查其附加功能(记住我们第三章的 YAGNI 原则)。我们主要感兴趣的是模拟响应,以便当我们的 UI 进行 API 调用时,我们返回一些有意义的数据。我们首先在 ./wiremock/mappings 目录中创建一个简单的存根,命名为 hello_world.json。在这个阶段,名称并不非常重要。下一个列表显示了文件的内容。

列表 6.21 我们的第一个存根
{
    "request": {
        "method": "GET",
        "url": "/api/v1/hello/world"
    },
    "response": {
        "status": 200,
        "body": "Hello World!",
        "headers": {
            "Content-Type": "text/plain"
        }
    }
}

如果我们尝试访问 http://localhost:8081/api/v1/hello/world,我们会收到如下响应:“没有响应可以提供,因为在这个 WireMock 实例中没有存根映射。”这是怎么回事?我们需要向 http://localhost:8081/__admin/mappings/reset 发送一个空的 POST 请求,这样 WireMock 才能拾取我们的更改。有一些与这段文本相关的 Postman 示例可以帮助你开始。在这次调用之后,你应该能够再次访问端点并看到“Hello, World!” 此外,如果你从 http://localhost:8081/__admin/ 请求映射,你会看到以下类似的列表。

列表 6.22 使用我们新存根的 WireMock 管理员响应
{
 "mappings": [
 {
 "id": "ac60ce7e-63f4-417e-894b-3e25ad496c42",
  "request": {
                "url": "/api/v1/hello/world",
                "method": "GET"
            },
            "response": {
                "status": 200,
                "body": "Hello World!",
                "headers": {
                    "Content-Type": "text/plain"
                }
            },
            "uuid": "ac60ce7e-63f4-417e-894b-3e25ad496c42"
        }
    ],
    "meta": {
        "total": 1
    }
}

这应该不会很痛苦。现在,让我们看看我们如何模拟我们的响应以获取上传的 ACH 文件的信息。我们应该已经对想要返回的字段有了很好的了解,因为我们已经在构建 UI 时使用了硬编码的数据。所以,我们将使用这些数据,并在这次在 __files 目录中创建 ach_files_response.json。通过在 __files 目录中定义响应,这将使我们避免复杂化映射,并允许其他 API 根据需要重用响应。下一个列表显示了响应样本,我们可以使用我们想要的数据量。

列表 6.23 示例 ACH 响应体
[
    {                                                   #1
        "id": "45bc2000-0a14-4c0a-ae7f-938b3fdab575",    #1
        "date": "03/01/2024",                            #1
        "filename": "sample.ach",                        #1
        "creditTotal": 312.40,                           #1
        "debitTotal": 100.20                             #1
    },                                                   #1
…
]

1 示例 JSON 包含我们的测试数据

既然我们已经定义了响应的内容,我们需要为它定义一个映射。这个映射与我们之前看到的类似,只是我们现在在响应中指定了 bodyFileName,使用之前定义的 ach_files_response.json 文件名。

列表 6.24 ACH 响应映射
{
    "request": {
        "method": "GET",         #1
        "url": "/api/v1/files"   #2
    },
    "response": {
        "status": 200,           #3
        "headers": {                           #4
            "Content-Type": "application/json"  #4
        },                                     
        "bodyFileName": "ach_files_response.json"  #5
    }
}

1 请求只会在 GET 上执行。

2 使用 URL 进一步细化映射

3 响应将是一个状态码 200。

4 定义任何需要的头信息

5 使用 bodyFileName 并指定 __files 目录中的一个名称来返回

定义好之后,我们只需要刷新我们的映射,以便更改生效。我们可以使用多种方法,并且它们按照对整体环境的影响顺序列出(从最小到最大影响):

  1. 管理员 API 可以重新启动以重新加载映射。

  2. 重新启动容器。

  3. 完全重新启动我们的 Docker Compose 实例。

一旦映射被刷新,导航到 http://localhost:8081/api/v1/files 将显示响应。图 6.4 使用 Postman 展示了响应,它提供了输出的一些突出显示/格式化,并允许我们轻松检查返回的标题。

WireMock 是一个强大的工具,我们只是触及了它的表面。目前,这足以帮助我们开始构建 UI。接下来,我们将探讨如何向后端发送请求。

6.5.3 使用 axios 访问端点

如果尚未完成,我们首先需要确保 axios 已经通过 npm install axios 安装。之后,我们可以根据需要将其导入到我们的组件中并开始工作!

现在,我们可以打开 RecentAchUploads.tsx,移除 createData 函数,并清除我们的测试数据,因为我们将从 WireMock 实例中检索数据。我们将定义我们的行为一个 AchUploads 数组(列表 6.25),而不是硬编码的数据。

计算机屏幕截图  自动生成的描述

图 6.4 在 Postman 中查看的 /api/v1/files 端点的响应
列表 6.25 准备我们的组件接收数据
…
import {useState} from "react";
…
interface AchUpload {         #1
    id: number;                #1
    date: string;              #1
    name: string;              #1
    creditAmount: number;      #1
    debitAmount: number;       #1
}                              #1

export default function RecentAchUploads() {

    const [rows, setRows] = useState<AchUpload[]>([]);  #2
…

1 定义我们的数据结构,以便我们可以适当地为其添加类型

2 我们的数据行将存储在类型为 ACHUpload 的数组中。

现在,我们可以发出请求以填充数据。

列表 6.26 使用 axios 进行 API 调用
useEffect(() => {   #1
   axios.get('http://localhost:8081/api/v1/files') #2
   .then(response => {              #3
      console.log(`Response data #3
➥ ${JSON.stringify(response.data)}`);   #3
         setRows(response.data);      #3
      })                              #3
   .catch(error => {                 #4
         console.log(error);         
   });
}, []);                              #5

1 useEffect 是一个完美的 React 函数,用于进行 API 调用。

2 使用 axios 获取我们的文件列表

3 处理返回的 Promise,显示数据并将行设置为返回的数据

4 将任何问题记录到控制台

5 空数组 [] 告诉 React 只执行一次。

通常,我们建议这些 API 调用应该从单个位置调用,而不是嵌入到组件中,但就目前而言,将它们放在组件内部是可以的。例如,我们经常创建一个 api 文件夹或类似的地方,以便可以从那里导入这些函数。这使得 API 调用可以从多个位置轻松访问。

然而,我们更紧迫的问题是,我们有一个指向 localhost(http://localhost:8081)的硬编码 URL,当我们将仪表板部署到 Docker 时,这肯定会改变。所以,为了目前,让我们利用 Next.js 存储我们的变量,然后,我们将能够轻松地为 Docker 配置它们。因此,我们创建一个 .env.local 文件。

列表 6.27 我们的 .env.local 文件
NEXT_PUBLIC_API_URL=http://localhost:8081/api/v1

我们现在可以更新我们的代码,以在 URL 中使用环境变量。

列表 6.28 更新的 API 调用
    useEffect(() => {
 const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? ''; #1
        axios.get(`${apiUrl}/files`)       #2

1 当环境变量未定义时,返回环境变量或将其设置为空字符串

2 更新 axios 调用以使用新的 apiUrl

使用此代码,我们可以测试我们的 API,并且仍然可以看到我们从 WireMock 返回的示例文件。我们现在能够更改构建 Docker 容器时我们的 API 所触发的 URL。

6.6 使用 ACH 批量视图扩展 UI

现在你已经理解了这些部分,让我们看看如何结合 Next.js、Recharts、Axios 和 WireMock 来创建 ACH 批次的视图。在仪表板上,我们希望能够点击显示的文件名之一来查看该文件的批次。视图将显示散点图,并提供显示 ACH 文件中每个公司批次更多详细信息的功能,如图 6.5 所示。提供关于批次特征(在这种情况下基于它们的金额和条目数量)的信息可以帮助我们在未来扩展应用程序时开始实施欺诈检测和风险管理等任务的控制。散点图使我们能够轻松地发现金额较大或项目计数较高的批次。最终,如果我们混合历史数据,我们就可以分析这些公司的模式,并突出不寻常的活动模式。

图片

图 6.5 ACH 批处理视图

6.6.1 定义 ACH 批处理视图的 WireMock 数据

我们的 JSON 数据本质上是一个数组的数组。我们也知道在一个 ACH 文件中可以有多个批次。每个批次都关联一个公司,而公司可能在文件中有多个批次。我们希望返回并按公司分组批次。所以,以下列表应该可以工作。

列表 6.29 ACH 批处理数据示例
{
    "batches" : [         #1
        {
            "companyName": "Foodyum",  #2
            "companyBatches": [        #3
                {
                    "batchId": "de444a20-d303-4b72-8d8d-48af7a58630d", #4
                    "batchNumber": "1",                                 #5
                    "recordCount": 680,                                 #5
                    "totalDebitAmount": 500.00,                         #5
                    "totalCreditAmount": 1000.25   #5
                },
                {
                    "batchId": "57e38258-b0d9-414a-80ff-8c59f2a4a357",        
                    "batchNumber": "2",
                    "recordCount": 120,
                    "totalDebitAmount": 500.00,
                    "totalCreditAmount": 500.25
                }
            ]
        },
…

1 我们返回一个按公司分组在文件中的所有批次的数组。

2 每个批次的公司名称都存在。

3 包含公司名为 companyBatches 的所有批次的数组

4 包含我们批处理数据的多个 JSON 对象

我们定义了一些我们将与用于文件的模拟数据关联的批次。具体来说,当有人查询 API 端点,如/api/v1/files/{fileId}/batches时,我们希望返回这些数据。正如我们在 WireMock 中看到的,我们可以为所有我们感兴趣的fileId定义硬编码的路径。然而,这将导致大量的映射。相反,我们可以使用模板动态匹配这些字段,并为每个返回特定的响应。我们在以下列表中定义了 ach_files_batches.json。请注意,我们使用urlPathTemplate并指定{fileId}。然后我们还在响应的bodyFileName中使用{{request.path.fileId}},这允许我们提取我们期望接收的动态 UUID,并在__files目录中定义的响应中使用它。

列表 6.30 WireMock 模板示例
{
    "request": {
        "method": "GET",
        "urlPathTemplate": "/api/v1/files/{fileId}/batches"  #1
    },
    "response": {
        "status": 200,
        "headers": {
            "Content-Type": "application/json"
        },
        "bodyFileName": "ach_files_batches
➥{{request.path.fileId}}.json" #2
    }
}

1 使用 urlPathTemplate 允许使用参数

2 request.path.fileId 引用在 urlPathTemplate 中定义的变量。

所有的这些,我们将能够定义对 API 调用的特定响应。图 6.6 更好地说明了这个概念。请注意,我们实际上并没有将任何东西保存到 WireMock 中。我们正在使用预先填充在 WireMock __files 目录中的预定义响应。

一个图表的截图  自动生成的描述

图 6.6 WireMock 序列

现在我们能够返回数据,我们可以定义一个可以显示数据的组件。

6.6.2 更新仪表板以跳转到批次屏幕

我们更新了 RecentAchUploads.tsx,使文件名成为一个链接。

列表 6.31 更新的 RecentAchUploads
<TableCell>
   <Link onClick={() =>                      #1
      route.push(`/fileDetails/${row.id}`)}>  #1
         {row.filename}                      
   </Link>
</TableCell>

1 将文本更新为使用文件 ID 的链接

fileDetails 屏幕接受文件的 UUID,这将有助于驱动生成的查询。Next.js 使用基于文件的路由,允许我们使用简单的文件夹结构定义 URL。在这种情况下,将 UUID 作为 URL 的一部分表示为方括号中的目录。以下列表是我们需要创建的文件夹结构。

列表 6.32 文件详情页面的文件夹结构
src/
├─ app/
│  ├─ fileDetails/
│  │  ├─ [id]/          #1
│  │  │  ├─ page.tsx

1 方括号表示我们可以访问的 URL 参数。

[id] 是我们 URL 中的一个动态参数。例如,使用这种结构,我们应该能够导航到类似于 /fileDetails/0854b018-c5ce-4e00-8a4f-d268a1a85aa2/fileDetails/d11fb49c-4a07-4b96-88cc-db1fc7277bfa 的 URL,假设这些是我们 ACH 文件的有效 UUID。

6.6.3 显示批次详情

page.tsx 将包含检索批次信息并显示它的逻辑。

列表 6.33 批次详情
…
export interface AchBatchInfo {           #1
    companyName: string;                   #2
    companyBatches: AchBatchDetails[];     #2
}
…
export interface AchBatchDetails {    
    batchId: string;                  
    companyName: string;              
    recordCount: number;              
    totalDebitAmount: number;         
    totalCreditAmount: number;        
}
…
interface AchBatchInfoResponse {  
    batches: AchBatchInfo[];      
}
…
export default function FileDetails({params}: any) { #4

    const id: string | string[] = params.id; #5
    const [batches, setBatches] =
➥ useState<AchBatchInfo[]>([]); #6

    useEffect(() => {        #7
        const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? '';
        axios.get<AchBatchInfoResponse>(`${apiUrl}/files/${id}/batches`)
            .then(response => {
                console.log(`Response data
➥ ${JSON.stringify(response.data.batches)}`);
                setBatches(response.data.batches);
            })
            .catch(error => {
                console.log(error);
            });
    }, []);
…
                    {batches.map((item, index) => (          #8
                            <Accordion key={item.companyName}>
                                <AccordionSummary
➥ expandIcon={<ExpandMoreIcon />}>
                                    <Typography>{item.companyName}</Typography>
                                </AccordionSummary>
                                <AccordionDetails>
{item.companyBatches.map((companyBatch, index) => (
                                    <>
                                        <Typography>
                                  Batch ID:
➥ {companyBatch.batchId}<br />
                                  Record Count:
➥ {companyBatch.recordCount}<br />
                                  Total Debit Amount:
➥ {formatCurrency(companyBatch.totalDebitAmount)}<br />
                                  Total Credit Amount:
➥ {formatCurrency(companyBatch.totalCreditAmount)}
                                        </Typography>
                                        <Link>View Batch</Link>
                                        <Divider />
                                    </>
                                )
                                )}
                                </AccordionDetails>
                            </Accordion>
                        ))}
…

1 定义我们的 JSON 结构,以便我们可以对响应进行强类型化

4 允许访问 URL 参数

5 通过与我们在方括号中定义的相同名称引用 URL 参数

6 使用 useState 类似我们在其他组件中做的那样存储批次

7 使用 useEffect 从 API 获取数据

8 将返回的批次映射到显示 UI 元素

此组件展示了本章前面提到的作为 6.5 图的一部分的折叠视图,用于 ACH 文件中的公司。从这个组件中我们可以得到的一个重要启示是看到我们如何可以在页面上动态创建布局元素。在其他组件中,我们将使用提供表格结构以查看动态数据的 DataGrid 视图。然而,了解我们可以在需要时手动创建具有动态数据的布局也是有帮助的。

6.7 创建散点图组件

最后,我们想在页面上添加一个散点图,显示批次中的项目与借方和贷方总额的关系。以下列表显示了我们将要创建的目录结构。您可能需要创建另一个子目录来存储特定的散点图相关文件。由于我们尚未达到这个目录包含大量文件的程度,我们将暂时保持原样。

列表 6.34 我们图表的目录结构
src/
├─ app/
│  ├─ components/
│  │  ├─ charts/
│  │  │  ├─ AchBatchesScatterPlot.tsx
│  │  │  ├─ AchBatchesScatterTooltip.tsx
│  │  │  ├─ CurrencyTick.tsx

6.7.1 散点图组件

散点图有助于展示变量之间可能的相关性。在我们的案例中,我们可能并不一定对分析方面感兴趣。然而,散点图也提供了一种识别具有大量项目或大量金额的批次的途径,这可能会对我们的用户很有趣。当然,我们也许还有机会展示特定公司的批次趋势,这也许有助于我们的用户进行数据分析。

同时,这个组件提供了探索使用颜色、自定义提示和自定义轴刻度的机会。这些特性将在后续章节中讨论。散点图的初始代码显示在列表 6.35 中,实际组件渲染显示在列表 6.36 中。

组件定义了一个名为 AchBatchesScatterPlotProps 的接口,以便它可以正确设置类型。此外,这个组件接受批处理作为只读参数,而不是在组件内部进行任何 API 调用。我们本可以在组件内部进行 API 调用。然而,如果我们有多个组件想要使用这些数据,我们最终会进行重复的 API 调用来收集相同的数据。我们还标记数据为只读,以帮助强制执行组件仅利用数据的概念,并通过使用不引入意外后果。然而,请注意 Readonly 是 TypeScript 的编译时特性,生成的 JavaScript 不会有这个特性。最后,由于我们感兴趣的是批次的总金额,我们将信用和借记金额相加,并将它们存储到 totalCreditAndDebits 中。

列表 6.35 散点图组件
…
interface AchBatchesScatterPlotProps {  #1
    batches: AchBatchInfo[];             #1
}                                       

export default function AchBatchesScatterPlot({batches}:
➥ Readonly<AchBatchesScatterPlotProps>) { #2

    const plotData = batches.map(batch => {
        const updatedCompanyBatches =
➥ batch.companyBatches.map(companyBatch => {
            return {
                ...companyBatch,   #3
                totalCreditAndDebits:
➥ companyBatch.totalCreditAmount + companyBatch.totalDebitAmount #4
            };
        });
        return {
            ...batch, #5
            companyBatches: updatedCompanyBatches #6
        };
    });

    return (
    # Refer to AchBatchesScatterPlot.tsx source code for this logic
    );
}

1 定义组件属性的接口,以便我们可以进行强类型化

2 作为只读属性接受属性,所以我们不会修改它们

3 将 companyBatch 对象的内容展开

4 向对象添加一个名为 totalCreditAndDebits 的新属性

5 将批处理对象的内容展开

6 用包含 totalCreditAndDebits 的新批处理替换现有的 companyBatches

在下面的列表中,我们有渲染实际散点图的组件。我们利用 recharts 的灵活性,通过传递一个名为 AchBatchesScatterTooltip 的组件来定义自定义提示内容,并定义一个用于 YAxis 的自定义刻度标记组件,称为 CurrencyTick。此外,我们在图上放置点时为每个公司分配不同的颜色(关于这些自定义组件的更多内容将在后续章节中介绍)。

列表 6.36 散点图渲染
<>
   <Title>ACH Batches</Title>
   <ResponsiveContainer>
      <ScatterChart
         width={400}
         height={400}
         margin={{
            top: 20,
            right: 20,
            bottom: 20,
            left: 20,
         }}>
      <Legend />
      <CartesianGrid />
      <XAxis type="number" dataKey="recordCount" name="Count" unit="" />
      <YAxis type="number"
             tick={<CurrencyTick/>}   #1
             dataKey="totalCreditAndDebits" name="Total Amount" unit="" />
      <Tooltip cursor={{ strokeDasharray: '3 3' }} 
         content={<AchBatchesScatterTooltip />} />   #2
         {plotData.map((entry, index) => (
            <Scatter key={`${entry.companyName}`} 
                     name={`${entry.companyName}`} 
                     data={entry.companyBatches} 
                     fill={stringToColor(entry.companyName)}>  #3
               <Cell 
                  key={`cell-${entry.companyName}-${index}`} 
                            fill={stringToColor(entry.companyName)} />  
                      </Scatter>
                   ))}
      </ScatterChart>
   </ResponsiveContainer>
</>

1 用于 Y 轴刻度的自定义组件

2 当悬停在数据点上时自定义提示

3 根据公司自定义颜色

6.7.2 散点图自定义提示

在下一个列表中定义我们的自定义提示组件后,我们可以自定义显示的提示。我们通过使用 Material UI 中可用的组件保持了简单性。我们还可以重用之前定义的formatCurrency方法,以确保在提示中正确显示数据。

列表 6.37 散点图的自定义提示
export function AchBatchesScatterTooltip
➥ ({ active, payload, label }: TooltipProps<string, string>)  {
    if (active && payload && payload.length) {
        const value = payload[1].value ?? '0.0';
        return (
            <Tooltip title={<Typography>{`Value: ${label}`}
➥</Typography>} arrow>
                <Paper sx={{ p: { xs: 1, sm: 2, md: 3 } }}>
                    <Typography variant="h6">Total Debits and
➥ Credits</Typography>
                    {formatCurrency(Number.parseFloat(value))} #1
                </Paper>
            </Tooltip>
        );
    }

    return null;
}

1 重新使用我们的 formatCurrency 函数以确保显示金额的一致性

6.7.3 散点图自定义刻度

在 recharts 中自定义图表的另一种方法是定义自定义刻度。我们希望我们的刻度格式化为货币形式,而不是未格式化并添加一个标签来说明“美元”或类似的内容。我们定义了一个包含传递数据的接口,然后再次使用我们的formatCurrency实用工具(列表 6.38)。

此组件同时使用Readonly(以确保组件不修改任何参数)和Partial。使用Partial类型是因为recharts控制传递的参数。此外,我们不需要使用?标记每个参数为可选,而是可以使用Partial<CurrencyTickProps>CurrencyTickProps中的所有参数标记为可选。

列表 6.38 自定义货币刻度
interface CurrencyTickProps { #1
    x: number;                 #1
    y: number;                 #1
    payload: {                 #1
        value: string;         #1
    };                         #1
}                              #1

export function CurrencyTick({ x, y, payload }:
➥ Readonly<Partial<CurrencyTickProps>>): React.JSX.Element { #2
    const value = payload?.value ?? '0.0';  #3
    return (
        <g transform={`translate(${x},${y})`}>
            <text x={0} y={0} dy={16} textAnchor="end"
➥ fill="#666" transform="rotate(-35)">
                {formatCurrency(Number.parseFloat(value))} #4
            </text>
        </g>
    );

1 定义我们的属性,以确保它们是强类型的

2 属性被标记为 Readonly 和 Partial。

3 如果有效载荷不存在,则替换为 0.0。

4 使用我们方便的 formatCurrency 函数

显然,使用自定义刻度进一步自定义我们的图表是一个强大的工具。即使我们选择使用标准刻度,保留这种类型的自定义以备业务提出更具体的要求也是有帮助的。在下一节中,我们将探讨如何为我们的图表自定义颜色。

6.7.4 使用颜色为图表着色

颜色是任何可视化的一个非常重要的部分。在我们的情况下,我们不限制散点图中点的数量,因此我们不一定知道需要识别的公司数量。我们总是可以通过说这是一个“前 10 家公司”或“前 10 批”的散点图来限制可视化,这样我们就不需要枚举额外的颜色来处理不确定数量的公司。

下一个列表显示了如何枚举颜色,然后代码将循环遍历这些颜色,并在需要时重复使用它们,就像我们正在做COLORS[index % COLORS.length]。这种方法虽然可行,但也会在定义的颜色数量少于公司数量时重复颜色,这显然会使用户难以理解可视化。

列表 6.39 列举颜色
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', 'red', 'blue'];
…
   <Scatter key={`${entry.companyName}`}
            name={`${entry.companyName}`}
            data={entry.companyBatches}
            fill={COLORS[index % COLORS.length]}>
      <Cell key={`cell-${entry.companyName}-${index}`}
            fill={COLORS[index % COLORS.length]} />
   </Scatter>

我们采取的另一项选择是基于公司名称计算颜色,这为我们处理可视化中的许多公司提供了更好的机会(尽管当它不再有价值时会有一个阈值)。然而,目前这种方法也不允许我们使用可能散列到相同值的名称,也不一定能在可视化中给出最佳的颜色。稍后,我们可能会决定显示前 10 家公司是处理可视化的最佳方式。

列表 6.40 展示了用于从公司名称(或任何字符串)计算十六进制颜色代码的代码。在这种情况下,如何从字符串创建颜色代码的实现细节并不那么重要。相反,我们想了解在解决问题时可能使用的方法,并尝试理解每种方法的潜在权衡。在这种情况下,我们失去了对所使用颜色的精确控制,这也意味着颜色可能无法区分或与整个可视化冲突。然而,这种方法允许我们展示许多公司,同时也为公司提供了颜色的统一性。因此,颜色可以在其他可视化中保持一致,或者每天改变。

列表 6.40 使用公司名称来确定颜色
export function stringToColor(str: string): string {
    let hash = 0;
    for (let i = 0; i < str.length; i++) {
        hash = str.charCodeAt(i) + ((hash << 5) - hash);
    }

    let color = '#';
    for (let i = 0; i < 3; i++) {
        const value = (hash >> (i * 8)) & 0xFF;
        color += ('00' + value.toString(16)).slice(-2);
    }
    return color;
}

我们如何展示数据不仅是一个偏好问题——我们选择的布局、格式和颜色可能会影响数据解读。例如,

  • 使用红/绿色调会使色盲人士难以区分不同类别的颜色。

  • 3D 条形图可能具有视觉吸引力,但可能导致对数据的错误解读,因为视角可以使条形看起来比实际更大/更小。

  • 在精选的时间段内,不符合特定叙述的数据被隐藏或最小化。

如需了解此主题的更多信息,请参阅 Desireé Abbott 所著的 《日常数据可视化》(2024 年,Manning 出版社),特别是如果您想将您的可视化提升到下一个层次。

6.8 在 Docker 中部署仪表板

作为最后一步,我们希望将我们的代码部署到 Docker 中,以便将我们系统的各个部分连接起来。下一章将重点介绍实现所需的 API,确保组件可以通信,并进行一般性维护。目前,我们只想确保容器构建正确,并且可以与我们的 WireMock 实例通信。

如您所忆,我们的 API 调用使用NEXT_PUBLIC_API_URL,这允许我们配置与后端通信所需的基 URL。列表 6.41 显示了 docker-compose.yml 的服务实现。我们现在引入了使用args参数向构建过程传递参数。我们将 URL 设置为与我们的.env.local项目设置中相同的端点。这是因为代码将在客户端的浏览器中运行,这意味着我们需要使用localhost:8081而不是您最初可能想象的内部引用wiremock:8080

列表 6.41 将仪表板添加到 Docker
…
  dashboard:
    build: 
      context: ./dashboard
      dockerfile: Dockerfile
      args:  #1
        - NEXT_PUBLIC_API_URL=http://localhost:8081/api/v1
    ports:
      - 4000:3000

1 定义传递给构建的变量

在以下列表中,我们有列表 6.41 中引用的 Dockerfile。此代码负责设置我们的环境变量,并构建和运行应用程序。在此 Dockerfile 中,我们通过展示如何设置默认值以及如何在容器中设置它,利用了NEXT_PUBLIC_API_URL

列表 6.42 仪表板的 Dockerfile
FROM node:alpine

ARG NEXT_PUBLIC_API_URL=
➥'http://override-me-in-docker-compose' #1
ENV NEXT_PUBLIC_API_URL $NEXT_PUBLIC_API_URL     #2

WORKDIR /usr/src/app

COPY app/package*.json ./

RUN npm install

COPY app/. .

RUN npm run build

CMD [ "npm", "start" ]

1 定义一个可使用或默认的参数

2 设置一个将包含我们的 API URL 的环境变量

到目前为止,我们应该能够构建容器,并使用docker-compose down; docker-compose up --build像以前一样启动它们。然后,导航到 http://localhost:4000 后,我们应该看到我们的仪表板启动,显示我们的 WireMock 数据。

摘要

  • 仪表板作为文件上传和数据视图的用户界面,由 API 提供支持,可以使用各种技术进行开发,目前首选 Next.js。

  • Next.js 通过使用 HTML、CSS 和 DOM 原则以及提供基于文件的路由、CSR、SSR 和多种样式方法,支持使用 React 构建交互式 Web 应用程序。

  • 从 Next.js 开始涉及设置Node.js,使用 npx 创建项目,并运行默认应用程序以验证设置的正确性。

  • 仪表板可以使用 Material UI 等模板进行初始化,并且应该安装依赖项以支持组件和 API 调用。

  • 通过添加显式类型并在开发过程中捕获错误,引入 TypeScript 增强了可维护性。

  • Jest 用于对 UI 进行单元测试,确保通过 ARIA 角色实现页面功能和无障碍性。

  • 对导航组件进行重构需要更好的代码组织和将其包含在上传页面中。

  • 硬编码的数据最初用于 UI 开发,然后使用 WireMock 等工具进行并行开发时,用模拟的 API 调用替换。

  • 使用 WireMock 模拟 API 涉及设置 Docker、配置存根和刷新以使更改生效,从而在不完全可用后端的情况下促进 UI 测试。

  • 在 Docker 中部署仪表板涉及配置环境变量并确保与模拟 API 的通信以实现动态数据显示。

第七章:我们的最小可行产品

本章涵盖

  • 最小可行产品(MVP)的目的

  • 将一切整合到 MVP 中

  • 安全问题

  • 使用 Playwright 进行测试

在之前的迭代中,我们研究了检查项目各个组件的 spike 故事。你被介绍到生成式 AI 和可以加速软件开发的不同工具。我们还回顾了特定的技术,如 Python/FastAPI、Postgres、Docker 和 Next.js。现在,业务线希望看到我们劳动的成果。本章重点是将一切整合成一个最小可行产品(MVP),使用户能够

  • 上传 ACH 文件

  • 解析 ACH 文件并将其存储在数据库中

  • 在我们的仪表板上可视化结果

功能性不会从之前迭代中开发的内容显著扩展。相反,我们想要确保这些个别组件现在可以无缝集成。在这个过程中,我们将探索系统演示中会发生什么,并使用 Playwright 验证我们的 MVP。拥有 MVP 将使我们能够向客户发布产品并收集反馈,我们将利用这些反馈在接下来的章节中改进我们的项目。

7.1 我们在谈论哪个最小值?

在你的职业生涯中,你可能已经听说过至少几个不同的最小值,例如

  • 最小可爱产品(MLP**)—类似于 MVP,但专注于从一开始就提供客户喜爱的东西

  • 最小市场产品(MMP**)—专注于提供可以销售给客户的最小功能集

  • 最小市场特性(MMF**)—专注于提供对我们客户有价值的软件特性

最小可行产品(MVP)的概念最初由 Frank Robinson 提出,并由 Eric Ries 在其著作《精益创业》中推广。Ries 将 MVP 定义为“那种新产品版本,它允许团队以最少的努力收集关于客户的最大量的验证学习。”

让我们花点时间来分析这个声明。显然,我们的新产品代表了我们对 ACH 仪表板的现代化,但“验证学习”又是什么呢?通过验证学习,我们正在获取关于我们如何接近满足客户需求的产品进展的知识。验证学习应该是基于证据和可操作的,给我们一些东西,我们可以用它来改进下一迭代的产品。这里的“最少努力”并不意味着生产出次品,而是指在短期内以最小的投资实现的目标。

我们对 ACH 仪表板 MVP 的目标是向客户提供一款产品,它开始解决他们对现有仪表板的担忧,但最终帮助我们了解最终产品。这种方法使我们能够将产品从可能相对孤立开发的东西转变为客户可用的东西(在大型公司中,客户请求可能通过多个层级过滤给我们,每个请求都将公司的偏好体现在产品规格中)。一旦我们的客户拥有产品,我们可以利用他们的反馈来帮助推动产品的方向,并快速提供所需的功能。与此相比,其他方法可能在整个产品开发并交付过程中没有从客户群体中获得有意义的反馈。我们可能会得到一个不受欢迎的产品,因为它缺少客户可能认为重要的功能,或者它可能因为客户独特的需求而在客户环境中无法使用。确实,我们看到了完全开发的产品,因为未解决当前问题的基本担忧或未考虑它们需要运行的特定环境而被搁置。表 7.1 提供了关于 MVP、MLP、MMP 和 MMF 的关键概念的快速概述。

表 7.1 MVP、MLP、MMP 和 MMF 的关键概念
MVP MMP MLP MMF
目的 测试和验证核心假设 创建可销售的产品版本 提供客户可以连接并喜爱的产品 提供增值功能并可单独进行市场推广
开发方法 优先考虑测试和学习 平衡功能与市场需求 专注于设计和情感参与 专注于单一有效功能
客户价值 为早期采用者提供核心价值 为目标市场需求提供完整解决方案 建立情感联系并在市场中脱颖而出 为现有产品提供渐进式改进
成功结果 核心假设的验证 销售和市场接受度 客户喜爱和品牌忠诚度 采用率和用户参与度

7.2 准备 MVP

在以下章节中,我们确保所有之前的工作都整合到最小可行产品(MVP)中。但在开始这段旅程之前,我们回顾了将构成我们的 MVP 的各个部分和组件——ACH 解析、数据库设计和用户界面,这些内容在之前的章节中已有讨论。图 7.1 展示了这个项目部分所需的组件。

产品图  自动生成的描述

图 7.1 输入到 MVP 中的各个部分和组件

如果我们的初步工作做得恰当,构建 MVP 的过程应该相对直接。毕竟,我们已经做了大量的前期工作,使各种组件达到这个阶段。记住,我们之前的研究峰值为以下组件的建设:

  • Python 中的 ACH 解析器

  • 使用 FastAPI 编写的 Python API 生成的 OpenAPI 文档

  • 用于存储 ACH 数据的 Postgres 数据库设计

  • 使用 Next.js 编写的 UI

在最坏的情况下,我们可能会遇到“方枘圆凿”的典型情况,即我们没有很好地记录(或遵守记录的功能),这可能需要一些重工作以使这些部分正确对齐。我们还有机会添加一些最后的修饰,例如使用 Playwright 进行集成/系统测试,这将使我们能够从最终用户的角度确保产品功能。最后,我们探讨了一些可能希望在项目中包含的额外安全性。

7.3 构建出/files API

在第四章中,你学习了如何使用 FastAPI 在 Python 中构建 API。然后,我们使用了 Next.js 和 WireMock 来创建可以返回给 UI 的模拟 API 响应,这些响应包含所需的数据。在这个阶段,我们想要验证后端和 API 是否正确对齐。

首先,我们通过 JetBrains IDE(PyCharm)调用了不同的组件,这使得我们可以启动当前数据库的 Docker、API 和仪表板 UI。通过导航到 http://localhost:8000/docs(OpenAPI 文档),我们可以利用我们在研究期间构建的POST请求(可能不是完美的,但应该可以加载文件)。图 7.2 显示了将允许我们选择样本的示例POST请求。

白色和绿色的条纹背景,描述自动生成,中等置信度

图 7.2 示例POST请求

如果我们刷新页面,最近上传的 ACH 文件在许多字段中显示 NaN(不是一个数字)(见图 7.3)。出现 NaN 可能有几个原因:

  • 解析错误——解析非数字数据,如parseInt("abcd")

* *未定义或 null 值*——使用 API 中不存在的字段* *算术错误*——除以零

py`Let’s see if we can pinpoint this particular “square peg into a round hole” problem. ![](https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/bd-fin-sw-genai/img/CH07_F03_Kardell.png) ##### Figure 7.3 Data being returned but displayed incorrectly We can explore the data returned by the `/files` API by using the OpenAPI documentation page (or a tool such as Postman). The following listing shows the data being returned from the API. ##### Listing 7.1 Data being returned from `/files` endpoint [ { "ach_files_id": "dcb8e6a4-79e9-4cf7-a138-491e58c1ebb8", "file_name": "sample.ach", "file_hash": "application/octet-stream", "created_at": "2024-02-18T01:05:37.513025" } ] py If we compare this code to the interface for `AchUpload` from RecentAchUploads.tsx, we can see the differences. Not only are we missing fields such as `creditTotal` and `debitTotal`, but we also need to map some of the field names. ##### Listing 7.2 The `AchUpload` interface 接口 AchUpload { id: number; date: string; filename: string; creditTotal: number; debitTotal: number; } py We should also review how the database is configured and change the amount fields from the file control record (type 9) to be `NUMERIC` instead of `VARCHAR`. This is a somewhat personal choice as arguments may be made to have a more flexible character value since the fields themselves can be formatted improperly. Making this type of change can be beneficial because the database will now enforce the data to be numeric. However, we may run into errors with existing functionality that worked before. Why? It’s because we could have unknowingly been putting spaces or other non-numeric data into that field. Of course, hopefully, our unit tests would have prevented this or, at the very least, would have quickly pointed out possible adjustments. In our case, we have tried to create a database structure that handles both unparsed records (for those times when the records are simply not parsable) and more detailed parsed records. The following listing shows the updated constraint for the numeric field. Note that the fields are 12 characters long, with a precision of 2. ##### Listing 7.3 ACH table for the file control record total_debit_entry_dollar_amount NUMERIC(12, 2) NOT NULL, total_credit_entry_dollar_amount NUMERIC(12, 2) NOT NULL, py The parsing program needs to be updated as well (see listing 7.4). We use the `Decimal` datatype since it is a bit safer than using a floating point ([`docs.python.org/3/library/decimal.xhtml`](https://docs.python.org/3/library/decimal.xhtml)). Note that we pass the fields as a string to `Decimal` instead of converting it to an `int`/`float` to avoid accidental conversion. For instance, the results of `Decimal('0.1')` and `Decimal(0.1)` are `Decimal('0.1')` and `Decimal('0.1000000000000000055511151231257827021181583404541015625')`, respectively, so we still have to be careful. ##### Listing 7.4 Converting the string to a number that includes a decimal point total_debit_entry_dollar_amount= #1 Decimal(f"{record[31:41]}.{record[41:43]}"), #1 total_credit_entry_dollar_amount= #1 Decimal(f"{record[43:53]}.{record[53:55]}"), #1 py #1 Parses the fields using a Decimal object and f-strings Having to update our database to convert from storing a string to numeric where appropriate adds extra work. We could have cut down some of the work had we tackled it in our previous research spikes when working on the database. In addition, we may be asking ourselves whether it is worth the effort to convert these fields to numeric for an MVP. That is a valid question. While at the very least we get the benefit of the database enforcing the formatting of our data, the tradeoff is increased development time. Unfortunately, there is no right answer. Depending on the project, requirements, and timeframe, you may not have the extra time to convert the fields and may choose to do it later. In this case, we convert the fields since we need to update the API to return these fields anyway. We can make the changes, and based on the interface defined in the UI (listing 7.2), we know that a numeric was expected. Now that we have the data stored correctly, we need to return the fields as part of the query. That brings us to another area that has to be refactored. We created schemas and used them in both the database and to represent the data being returned from our APIs. We would like to keep the models separate because this separation of concerns provides us with more flexibility, better security, and data integrity. We refactor our schemas directory to have folders for `api` and `database`, and we move all existing schemas to the database folder, which results in the structure presented in the following listing (note that not all files are shown). ##### Listing 7.5 New schemas directory structure schemas/ ├─ api/ ├─ database/ │ ├─ ach_record/ │ │ ├─ ach_record_base_schema.py │ │ ├─ ... │ ├─ ach_file_schema.py │ ├─ ... py With the updated structure, we are free to add our API responses. We can now continue with updating the API as we wanted. If you remember, we wanted to return a response with the file totals. Therefore, we created ach_files_response.py that contains the `AchFilesResponse`, as shown in the following listing. We also take the time to use more of Pydantic by providing documentation to the fields. ##### Listing 7.6 The `AchFilesResponse` class from decimal import Decimal from datetime import datetime from pydantic import BaseModel, UUID4, Field class AchFilesResponse(BaseModel): id: UUID4 = Field(..., #1 description="ACH 文件的唯一标识符", #2 title="ID") #3 date: datetime = Field(..., description="上传 ACH 文件的日期和时间", title="日期") filename: str = Field(..., title="文件名", description="加载 ACH 文件的文件名", max_length=255, #4 ) credit_total: Decimal = Field( ..., description="ACH 文件中信用交易的总金额", ge=0, #5 title="信用总额", ) debit_total: Decimal = Field( ..., description="ACH 文件中借记交易的总金额", ge=0, title="借记总额", ) class Config: json_schema_extra = { "example": { "id": "123e4567-e89b-12d3-a456-426614174000", "date": "2024-01-01T12:00:00", "filename": "ACH_20240101_123.ach", "creditTotal": "1000.00", "debitTotal": "5000.23", } } py #1 By using Field from Pydantic, we can specify both constraints and documentation. #2 A description of our field shown as part of the API doc #3 A title for the field shown in our API doc #4 A constraint on the field; strings longer than max_length will cause validation errors. #5 Constrains the field to be ≥0 We have previously defined a simple query to return all the fields from the `ach_files` table, but now that we want to include the credit and debit totals in the response, we need to pull them from the parsed type 9 record. To work out the query, we can use CloudBeaver to run queries against our database and ensure the results are correct. Once we start pulling the required data, we can create a new method to return the data retrieved. ##### Listing 7.7 Updating SQL query def get_files_response( self, limit: Optional[int] = None, offset: Optional[int] = None ) -> list[AchFilesResponse]: with get_db_connection(row_factory=dict_row) as conn: result = conn.execute( """ SELECT af.ach_files_id AS id, #1 af.file_name AS filename, #1 af.created_at AS date, #1 afcr.total_debit_entry_dollar_amount AS debit_total, #1 afcr.total_credit_entry_dollar_amount AS credit_total #1 FROM ach_files AS af INNER JOIN ach_records_type_1 AS art1 USING (ach_files_id) #2 INNER JOIN ach_records_type_9 AS art9 USING (ach_records_type_1_id) INNER JOIN ach_file_control_records AS afcr USING (ach_records_type_9_id) ORDER BY af.created_at DESC #3 LIMIT %s #4 OFFSET %s """, [limit, offset], ) return result.fetchall() py #1 Fields are renamed to match the AchFilesResponse. #2 When joining tables on columns that are named the same in both tables, we can employ USING. #3 Sorts our results by the created_at date in descending order #4 Although not used at the moment, we can limit the results and allow for paging through them when we have large amounts of data. Finally, we update our route not only to call our new `get_files_response` but also to provide more documentation for our end-user, as shown in the following listing. This documentation step is important because it makes the API more accessible to nontechnical stakeholders, such as the QA department, who may be helping us with testing the APIs, or technical writers, who may be helping us with documentation. Furthermore, this change can benefit the API consumers who do not have the advantage of viewing the underlying code. ##### Listing 7.8 Updated API route @router.get( #1 "", #1 response_model=list[AchFilesResponse], #1 summary="检索上传的 ACH 文件", #1 description="检索 ACH 文件的详细信息,包括信用/借记总额", #1 response_description="请求的 ACH 文件的详细信息" #1 ) async def read_files() -> list[AchFilesResponse]: return AchFileSql().get_files_response() py #1 We can use Pydantic again to document our fields. With this completed, we have a usable API with documentation. Figure 7.4 shows the results of some of the documentation we obtained using Pydantic. So, in addition to being able to assist us with data validation, parsing, and type safety, Pydantic also provides these documentation benefits. ![A screenshot of a computer Description automatically generated](https://github.com/OpenDocCN/ibooker-dl-zh/raw/master/docs/bd-fin-sw-genai/img/CH07_F04_Kardell.png) ##### Figure 7.4 Our API now has documented fields and examples We are almost there—just a few finishing touches on the UI side, and everything will be hooked together. In the RecentAchUploads.tsx, we named our interface `AchUploads`. We would prefer to name that something closer to what the API is returning. We also have a mapping problem where we have fields defined as `creditTotal`, but they are being returned as `credit_total` from the API. While Python uses `snake_case` for variable names, TypeScript prefers `camelCase`. So, we rename `AchUploads` to `AchFiles` and leave the fields as is. Then, we create a new interface—`AchFilesResponse`. ##### Listing 7.9 The `AchFilesResponse` interface 接口 AchFilesResponse { id: number; date: string; filename: string; credit_total: number; debit_total: number; } py Next, we update the call to the `/files` endpoint in the component to transform the response from `AchFilesResponse` to `AchFiles`. We do so to maintain consistent naming standards for the language we are using. However, this can also yield another benefit—providing a level of abstraction between the UI and API. This benefit can be valuable if we change the vendor of our API (not likely in this case), or something unexpectedly changes within the API (where the API did not maintain backward compatibility, or unannounced changes were made). The following listing shows the updated `axios` call. ##### Listing 7.10 Updated `axios` call axios.get<AchFilesResponse[]>(${apiUrl}/files) .then(response => { console.log(Response data ➥ ${JSON.stringify(response.data)}); #1 const transformedData: AchFiles[] = ➥ response.data.map((row: AchFilesResponse) => ({ #2 id: row.id, #2 date: row.date, #2 filename: row.filename, #2 credit

第三部分 超越最小可行产品

可行产品

当利益相关者手中有了最小可行产品(MVP)时,您将开始收到反馈并考虑进一步开发的可能性,将 MVP 的功能扩展到令人兴奋的新方向。

第八章通过扩展系统的异常处理来增强 ACH 处理。软件的这个重要方面可能并不引人注目,但对于稳健且用户友好的软件来说仍然至关重要。

第九章通过提供搜索和创建审计跟踪的能力来扩展 ACH 仪表板,以跟踪用户与 API 的交互。

第十章将公司信息添加到仪表板中。这项活动将让您探索几个额外的领域,为增强仪表板开辟新的方向。

第十一章深入探讨国际 ACH 交易及其对我们系统产生的监管影响。

第十二章通过提供如何进一步推进这个项目的想法来总结一切。

阅读书籍后,您将熟悉各种技术、工具和技术。我们希望您已经找到了一些有趣的主题进行深入研究。此外,您将拥有一个相当稳健的 ACH 仪表板,并对 ACH 处理有更深入的理解!

第八章:异常情况

本章涵盖

  • MVP 的初始反馈

  • 规划和解决用户请求

  • 识别不同类型的异常

  • 更新仪表板

  • 确保我们的 UI 响应

在第七章中,我们成功地将我们的最小可行产品(MVP)推向用户基础,以帮助征求反馈并推动产品的增强。反馈差异很大——从喜欢仪表板新方向的用户到那些表达不满的用户,他们认为它缺少太多功能,尽管它目前并不是一个完整的替代品。现在,业务希望我们既要利用做得好的地方,也要解决用户目前迫切要求的一些缺失功能。

在我们的 MVP 中,最大的需求是拥有更多种类的 ACH 相关异常,并在用户界面中实现它们的处理。本章确定了不同类别的异常以及识别和可能从中恢复的方法。

8.1 计划增强

向我们的目标受众提供 MVP 的好处是,我们可以从他们那里获得反馈,然后利用它来确定哪些功能需要添加到软件中。这种优势使我们能够专注于最有价值的物品。在这个例子中,我们希望专注于提供一个在用户界面中查看 ACH 异常的方法,以及搜索我们加载的文件,这将帮助我们找到用户迫切需要且实用的应用程序增强。这尤其有价值,因为我们专注于预先安排的支付和存款(PPD)ACH 交易,这意味着工资和账单支付有未处理的风险。

根据我们的情况,我们可能有一个正常的冲刺来完成我们的工作,或者如果需求迫切,我们可能需要更快地完成更改和修复。我们也可能会看到为这些项目编写的用户故事,例如

  • 作为操作员,我希望能够查看处理异常并了解需要解决的潜在 ACH 问题,以确保交易不会延迟到客户账户的入账。

  • 作为操作员,我希望能够搜索特定交易,以便我可以研究客户询问的项目。

在任何情况下,为项目提供时间表或甘特图都是有利的。如果我们面临硬性截止日期,这些可视化工具尤其有帮助。我们通常从截止日期开始倒推,试图完成预期任务,同时将它们全部纳入分配的时间范围内。当然,所需的时间框架可能不可能实现,将它们展示给管理层是帮助我们争取更多时间的好方法。再次强调,我们依赖 PlantUML 为我们提供一个简单的甘特图,我们可以与利益相关者分享。以下列表显示了所需的语法。

列表 8.1 - Gantt 图的 PlantUML 语法
@startgantt
saturday are closed  #1
sunday are closed    

header ACH Dashboard Enhancements - Round 1 #2

Project starts 2024-08-01 
-- Exceptions Enhancement -- #3
[Database Design] starts 2024-08-01 and lasts 2 days #4
[Exception API Design] starts at [Database Design]'s#5
➥ end and lasts 2 days  #5
[ACH Processor] starts at [Exception API Design]'s #5
➥ end and lasts 3 days #5
[UI Exception Changes] starts at [ACH Processor]'s #5
➥ end and lasts 3 days #5

-- UI Enhancement –
[Update fields screen] starts at 2024-08-01 and lasts 3 days
[Update batch screen] starts at [Update fields screen]'s
➥ end and lasts 3 days
[Sprint Complete] happens at [UI Exception Changes]'s end
@endgantt

1 从我们的图表中排除周末,因为我们不想加班

2 图表的标题

3 在图表中创建一个视觉上的中断

4 创建一个具有开始时间和持续时间的任务

5 任务可以依赖于其他任务。

此列表生成了图 8.1 所示的图表。正如我们提到的,此类图表有助于可视化工作,并且通常受到项目利益相关者的赞赏。

一个项目图  自动生成的描述

图 8.1 - 我们增强功能的甘特图

现在我们已经概述了这次冲刺的初始工作,我们可以开始着手添加异常到我们的应用程序所需的步骤。

8.2 异常和警告

记住,我们更喜欢在这里进行短的开发周期,如图 8.2 所示。在步骤 1 中,我们需要创建一个包含我们所需异常条件的 ACH 文件,这意味着我们将更新ach_file_creation.feature和相关的 test_create_ach_files.py,以支持创建具有我们所需异常的文件。在步骤 2 中,我们通过直接开始工作或以更测试驱动开发(TDD)的方式创建单元测试来更新 ach_file_processor.py,以支持新的异常。最后,在步骤 3 中,我们更新仪表板的实际 UI 以支持查看和显示异常。

虽然步骤将在单独的部分中介绍,但请注意,我们可能会通过这些一般步骤对每个个别异常进行工作。并且在我们处理每个异常的过程中,我们会注意到可以做出的改进/细化。

一个软件系统图  自动生成的描述

图 8.2 - 表示我们的短开发周期的流程图

首先,我们需要确切地定义当我们使用“异常”、“错误”和“警告”这些术语时我们指的是什么。它们可能看起来有些可以互换,但就我们的目的而言,我们希望提供一些背景信息,说明我们谈论每个术语时的想法。请记住,这些是我们自己的定义,并不反映任何金融科技或 Nacha 标准。

  • 错误 - 阻止处理发生的条件。我们可以将错误视为诸如文件上传不完整或软件中的错误导致处理停止的条件。某些条件会导致错误,例如,记录类型代码不存在于 ACH 标准中(例如,记录类型代码 2),因为我们没有在数据库中为该记录留出位置。

  • 异常 - 一种可能阻止文件处理的业务条件,这可能包括格式不正确的文件、文件中的无效数据,或者业务定义的应停止处理的条件。这些业务条件的例子可能包括超过一定金额的交易、标记的客户或其他可能引起担忧的交易活动。

  • 警告—需要通知的业务条件,但并不一定阻止文件被处理。警告可能允许我们在执行 ACH 规则时对文件或客户表现出宽容。例如,如果控制记录上的计数不正确,业务可能会允许这种差异并继续处理文件。

如我们所见,不同类别在条件和响应上存在细微差异。在遇到错误的情况下,我们也应清楚地记录恢复或研究步骤。我们曾参与在深夜支持软件,代码中进行了明确的检查,但没有任何指示说明条件可能发生的原因、如何解决它或它是否重要。不止一次,它并不重要到足以停止处理,代码会被注释掉以便绕过检查。这个故事说明了确定异常严重性的必要性,以及何时何地应该报告它们。图 8.3 显示了一个流程图,可以帮助你决定是否需要停止处理异常或以不同的方式处理。

流程图示意图  自动生成的描述

图 8.3 决定如何处理错误的流程图

总结来说,我们应该考虑

  • 是否这是一个需要停止处理的问题

  • 是否可以及时向用户报告

  • 无论这是否可以等待但最终需要解决的事情

太频繁地,我们可能会创建被用户忽略和删除的警报、消息和报告,在采取纠正措施之前。这可能会是一种平衡行为,因为不同的产品或业务需求需要不同的方法。

从我们的软件和开发角度来看,我们必须考虑这些不同类型的错误和警告。同时,了解美联储有其自己的方式来查看文件的 ACH 状态也很重要。这些状态可以是接受挂起拒绝,这正是你可能会预期的:

  • 接受—文件良好,已被美联储接受。

  • 挂起—金融机构必须确认文件应该被接受或拒绝。这通常发生在由于文件格式或内容存在潜在异常时。

  • 拒绝—条件/错误导致文件被拒绝。当金融机构选择拒绝之前处于挂起状态的文件,或者美联储直接拒绝文件时,我们可能会得到这种错误状态。

应当注意的是,当文件被纠正时,应该有一个审计跟踪。如果文件被修改,文件的原创建者可能需要被通知。否则,这些更改在尝试研究问题时可能会导致混淆。我们可以想象试图与某人讨论一个文件,其中文件 ID 从数字更改为字母,但没有任何表明这是已完成的指示。这可能会导致一些令人沮丧的互动,金融机构的某个人声称没有看到文件,尽管用户明显看到交易击中他们的账户。

8.3 创建带有异常的 ACH 文件

作为第七章 MVP(最小可行产品)的一部分,我们创建了ach_file_creation.feature,这使得我们能够轻松地使用 BDD-style 语法(称为 Gherkin)创建格式良好的文件。回想一下,它允许我们创建如下所示的文件:

  Scenario: Create an ACH file with a single batch and a single debit
    Given I want to create an ACH file named "ppd-single-debit.ach"
    And I want to have an immediate destination of "123456789"
    And I want to have an immediate origin of "987654321"
    And I want to have 1 batch with ACH debits only
➥ and a standard entry class code of "PPD"
    And I want 1 entries per batch with random amounts between 100 and 100
    And I want to use individual names of "John Doe"
    And I want to have company name "My Company"
➥ and company id "1234567890"
    When my ACH is created
    Then I should have a file named "ppd-single-debit.ach"
    And there should be 1 batch in the file
    And there should be 1 entries in the file

这对于我们经常从快乐路径开始的情况效果很好,但现在我们需要扩展我们的代码来处理一些可能的异常,以便我们可以着手构建所需的功能。在某些情况下,我们的语法可能有助于强制执行 ACH 文件的正确格式,这显然是好事。我们希望创建过程比手动创建更容易,但现在我们还想为文件添加一些灵活性。最终,我们可能会接受 CSV 或其他格式化的文件,这将使定制字段变得容易。目前,我们可以考虑扩展并希望改进我们的语法。

8.3.1 创建带有文件级别异常的 ACH 文件

文件级别的错误和警告将与文件头(类型 1)或文件尾(类型 9)记录有关。我们可能遇到的大多数异常都源于文件格式问题,我们希望能够引入以下异常:

  • 文件 ID 不是 A 到 Z 范围内的字母大写或数字。如果它是小写字母,文件可能会被拒绝,或者金融机构可能会决定修复它,因为这是一个小问题,容易纠正。

  • 直接目的地应该是我们银行的路线号。这很重要,因为我们显然想确保我们正在处理正确的文件,为正确的银行。如果我们收到一个打算给另一个机构的文件,它可能被归类为信息泄露、未经授权的披露或不经意的数据共享。无论我们称它为什么,在客户隐私方面都有严重的后果,我们可能还有法律和监管义务。虽然这似乎是一个无害的问题(例如,字段不匹配),但它将是一场全员参与的事件。在金融科技领域,这种事情可能发生在许多地方,尤其是在处理多租户环境时。例如,它可能发生在存储/检索图像、在移动或网络银行应用程序中显示交易、发送对账单或与不正确或未经授权的地址通信时。这个变化还要求我们维护一个与银行关联的路线号列表,因为我们需要能够验证它们。由于银行的合并或收购,银行可能有多个路线号。

  • 块因子不应改变,但如果改变了,则将是一个错误。这给了我们机会更新我们的语法以支持设置字段和使用 get(field, 默认值),这样我们就不需要有很多不同的语法,这可以进一步扩展以支持 padding-left/rightfill valuelength

  • 记录大小类似于块因子,我们始终期望为 094。理论上,此字段可以用来更改记录格式长度,但在实践中,系统通常只假设 94 字节,并且不使用记录大小来读取不同大小的记录。这确实允许我们扩展我们的检查以变得更加通用,交换由特定上下文施加的约束,并允许更多的灵活性,但随之而来的是更大的责任,即了解如何使用它。例如,我们正在调整字段偏移量以满足 Python 的需求。我们本可以将它从 1 开始,这样与 ACH 文档更一致,并在代码中更改偏移量。

  • 文件总计。文件尾记录应与文件计算出的总计相匹配

  • 文件哈希。每个批量控制都有一个哈希值,该值累加,并在必要时截断结果以保留 10 个低位数字(最右侧的数字)。

8.3.2 使用批量异常创建 ACH 文件

随着我们解析更多文件内容,我们需要考虑批量级异常。这些异常特定于批量记录,尽管它们与其他异常有一些相似之处,但我们可能会发现它们并不一定导致整个文件被拒绝。相反,批量本身可能会被系统拒绝。这反过来又可能导致整个文件被拒绝,或者金融机构可能只需要求发起者发送一个新的文件。以下是一些批量级异常的例子:

  • 无效的有效日期会导致批量错误,因为这是正确发布文件所必需的信息。所以,无论是缺失还是格式错误,都无法恢复,需要一个新的文件。

  • 控制记录上的无效批量总计会导致警告。在实践中,这通常会导致文件被拒绝,但也可以纠正给定的文件,使总计与文件条目匹配,以便文件被接受。

  • 批量中的不良标准入口代码(SEC)会导致错误,并使批量被拒绝。有一系列可行的 SEC 代码可以使用,其中一些会改变记录的解析。我们一直专注于 PPD(用于工资、账单支付、养老金等)的 SEC 代码,并将继续在本章中这样做。

8.3.3 使用入门级异常创建 ACH 文件

入门级异常很有趣,因为除了常见的格式异常之外,它们还可能更多地与系统中的账户有关。覆盖这些异常需要系统有更多的功能,但为了更好地说明我们的意思,让我们来看看一些常见的入门级异常:

  • 无效账户号码—可能与格式有关,或者系统上找不到。

  • 无效金额—金额格式错误(例如,包括小数或包含空格)。

  • 资金不足(NSF)—账户没有交易的资金。这为我们项目打开了许多有趣的可能性,从维护客户和账户表到评估无法处理的交易的费用。

8.3.4 清理我们的文件创建语法

需要创建额外的文件时,我们将重新审视我们用来创建文件的 BDD 风格语法。通常,这是一个获得重构代码和寻找微小改进以使语法或代码更健壮的好地方。因为这些通常是面向内部的,我们可以通过重构现有代码并观察其可能产生的潜在影响来获得经验。

例如,我们曾经有@then(parsers.re('I should have a file named "(?P<expected_file_name>.*)"'))这一行,但再次指定文件名是没有必要的。这不仅会在我们复制/粘贴场景以创建类似场景时引起潜在问题,而且纯粹是多余的输入。相反,既然我们已经存储了文件名,我们可以更新它成为@then("I should have a file of the same name")。然后代码可以从setup_info['filename']中提取存储的文件名以执行检查。

我们可以关注的另一个改进是清理我们解析某些语句中字符串的方式。我们最初使用正则表达式来捕获使用的字符串,虽然这并不一定是问题,但它确实增加了一层潜在的混淆。我们想起了 Jamie Zawinski 的著名引言:“有些人面对问题时会想,‘我知道,我会使用正则表达式。’现在他们有两个问题。”为了解决这个问题,我们可以将我们的正则表达式,如@given(parsers.re(r'I want to create an ACH file named "(?P<filename>.*)"')),替换为一个远更简单的@given(parsers.parse('I want to create an ACH file named "{filename}"'))。保持我们的代码简单确保它更易于维护,并且在一年或两年后返回添加更多改进时,更不容易出错。

还有哪些可以改进的地方?记住,我们应该寻求平衡功能与需求。我们可能可以添加很多花哨的功能,但如果它们未被使用,这真的值得额外的努力吗?这种平衡在讨论不一定会增加客户可见价值的内部工具时尤为重要。如果我们有一个功能齐全的 ACH 创建实用工具,但没有为他们处理这些文件的实际工具,我们的利益相关者可能不会印象深刻!

8.4 扩展 ACH 处理以处理异常

当我们将异常添加到解析器时,我们将通过创建一个测试来验证预期的异常是否已添加到数据库中,从而遵循 TDD(测试驱动开发)方法。使用 TDD 方法,我们预计这个测试会失败,然后我们将努力添加必要的逻辑以使测试通过。这种方法可能不适合每个人,但我们鼓励您尝试一下,至少为了从不同的角度看待开发。

8.4.1 数据库中的 ACH 异常

最初,我们在数据库中存储了一些异常文本。然而,我们可能希望有更精细的控制和更少冗余的数据。因此,我们创建了一个名为ach_exception_severity的数据库类型,以限制在创建异常时可以使用的条目。使用ENUM可以确保类型的一致性并减少数据库中的冗余。缺点是ENUM不是 SQL 标准的一部分,因此,它们可能不会被你的关系数据库管理系统(RDBMS)实现。虽然这些示例假设使用的是支持ENUM的 Postgres 数据库,但也可能通过一个单独的表来模拟这种功能,该表提供我们用于ENUM的值,并在该表中建立外键关系。

以下列表展示了创建ENUM类型以及我们新创建的ach_exception_codes表的过程,我们使用这个表来存储错误扩展描述。

列表 8.2 创建数据库ENUM和异常代码表
CREATE TYPE ach_exception_severity AS ENUM
➥ ('error', 'warning', 'info'); #1

CREATE TABLE ach_exception_codes (
    ach_exception_codes_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    exception_code VARCHAR(3) NOT NULL UNIQUE,
    exception_severity ACH_EXCEPTION_SEVERITY NOT NULL, #2
    exception_description VARCHAR(255) NOT NULL
);

1 创建ach_exception_severity类型

2 将我们的类型用作字段的数据类型

接下来,我们创建一个异常表来跟踪我们的异常。有几种方法可以做到这一点,即为每个特定条目创建多个表(类似于有多个 ACH 记录类型),然后通过视图将它们关联起来。然而,在这里,我们演示使用一个可以包含其他表引用的单一表,这些表包含我们可以查找的记录异常。以下列表包含CREATE TABLE语句。与之前的表相比,重要的区别是我们使用了外键约束,这使得ach_records_type_5_idach_records_type_6_id字段可以接受 NULL,因为只有在我们有该类型的异常记录时,这些信息才会被填充。请注意,每个异常都将与一个文件相关联,因此我们预计ach_files_id将在每次都填充;因此,我们仍然有一个NOT NULL约束。

列表 8.3 创建异常表
CREATE TABLE ach_exceptions (
    ach_exceptions_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    ach_files_id UUID NOT NULL 
       REFERENCES ach_files(ach_files_id) 
       ON DELETE CASCADE ON UPDATE CASCADE,
    ach_records_type_5_id UUID    #1
       REFERENCES ach_records_type_5(ach_records_type_5_id)  #1
       ON DELETE CASCADE ON UPDATE CASCADE,  #1
    ach_records_type_6_id UUID REFERENCES     #1
       ach_records_type_6(ach_records_type_6_id)   #1
       ON DELETE CASCADE ON UPDATE CASCADE,  #1
    record_number NUMERIC NOT NULL,
    exception_code VARCHAR(3) NOT NULL REFERENCES  #2
    ach_exception_codes(exception_code)              #2
  ON UPDATE CASCADE   
);

1 这些字段不一定总是被使用,可以是 NULL。

2 异常代码详情在ach_exception_codes中引用。

向我们的表中添加数据是数据库启动的一部分。我们这样做是因为这些是我们可以考虑作为产品一部分的标准错误。我们经常发现这些类型的表或设置是金融机构设置的一部分。

列表 8.4 向异常表添加数据
INSERT INTO ach_exception_codes (exception_code,#1
  exception_severity, exception_description)  #2
VALUES ('001', 'error', 'Record length is not 94 characters');  #2
INSERT INTO ach_exception_codes
 (exception_code, exception_severity, exception_description)
 VALUES ('002', 'error', 'Record type was an unexpected value');
…

1 每个异常都需要被插入到数据库中。

添加这种持久数据对于仪表板是必要的;然而,这也增加了一层复杂性。我们的测试变得依赖于这些表的定义和数据填充。我们还遇到了一个问题,因为我们的单元测试会将所有表截断以确保数据库是干净的。以下列表显示了截断脚本的一部分更改,以便我们可以理解对 SqlUtils.truncate_all() 所需的更改。我们可能想要考虑更新名称(或者至少是文档),以便理解 truncate_all 实际上不再截断所有数据。

列表 8.5 更新 truncate 脚本
EXECUTE (
   SELECT 'TRUNCATE TABLE ' || 
          string_agg(quote_ident(table_name), ', ') || 
          ' CASCADE'
   FROM information_schema.tables
   WHERE table_schema = 'public'
   AND table_type = 'BASE TABLE'
   AND table_name NOT IN ( 'ach_exception_codes', #1
                       'ach_recovery_options',    #1
                       'bank_routing_numbers')   
);

1 我们有一些表需要在截断逻辑中跳过,因为这些表包含硬编码的值。

虽然我们现在可能不会因为这个方法而过度负担,但将来我们可能需要更好地管理测试数据库。一个流行的选择是使用 Testcontainers (testcontainers.com/),它允许我们创建包含我们的数据库的临时 Docker 容器,这些容器不会超出测试的生命周期。这在处理单元测试时提供了明显的优势,尤其是在 CI/CD 管道中运行的测试。

8.4.2 恢复选项

在更新 ACH 解析器以支持我们新的异常之前,让我们花一点时间考虑记录异常的另一方面——当遇到异常时应该做什么!我们想要确切地考虑用户应该如何从问题中恢复。这在与加载交易处理时尤其如此,仪表板最终可能会涉及到这一点。由于 ACH 文件用于移动资金,如果我们的文件加载失败或存在其他错误条件,我们可能需要考虑对用户账户的影响。换句话说,仅仅加载文件并再次尝试可能是不够的。我们需要确保交易不会再次发布到账户,导致客户被多次贷记或借记。在这个阶段,我们的担忧不需要集中在那些细节上,但为用户提供一些帮助是很重要的。

一个浮现的情景是遇到一个异常被抛出,这涉及到比较系统两部分的总额。如果总额不匹配,处理就会停止,导致凌晨 2 点时需要联系开发者。担忧的并不是异常让我们在非正常时间工作。如果发生关键处理异常,我们可以预期偶尔会出现问题。相反,担忧的是在遇到错误时应该采取的恢复步骤——没有!结果是多个开发者不得不介入以确定需要做什么。确定检查可以安全地绕过,我们可以在第二天跟进并进一步调查。最终,发现审计记录没有按照预期写入,这导致了总额不准确,但在发现这个问题之前,问题已经重复出现几次了。

虽然我们目前的项目没有这样的担忧,但为用户提供一些额外的信息当然会很不错。毕竟,仪表板最终可能需要自己的内部帮助系统,或者查看异常可能会提供一些关于可以采取哪些行动的建议。以下列表显示了创建一个与ach_exception_codes表关联的ach_recovery_options表,允许将多个条目与一个异常代码关联。

列表 8.6 ACH 恢复选项表
CREATE TABLE ach_recovery_options (
    ach_recovery_options_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    exception_code VARCHAR(3) NOT NULL REFERENCES  #1
       ach_exception_codes(exception_code) ON UPDATE CASCADE, 
  recovery_option VARCHAR NOT NULL #2
);

1 每个恢复选项应引用一个特定的异常。

2 恢复选项是用户可以查阅以获取帮助的简单文本。

然后,我们可以作为启动过程的一部分预填充表中的恢复选项。以下列表显示了将一些可能的恢复选项插入数据库的示例。

列表 8.7 预填充恢复选项
INSERT INTO ach_recovery_options (exception_code, recovery_option)
➥ VALUES ('001', 'Request a corrected file from the originator');
INSERT INTO ach_recovery_options (exception_code, recovery_option)
➥ VALUES ('002', 'Request a corrected file from the originator');
INSERT INTO ach_recovery_options (exception_code, recovery_option)
➥ VALUES ('004', 'Specify a new File ID Modifier');

恢复选项当然可以扩展。然而,在这个阶段,我们只想确保恢复和帮助是我们开发中的一个考虑因素。我们都应该意识到干净代码和注释的重要性,这样当我们不得不在 6 到 12 个月后再次深入研究代码时,我们可以理解代码。同样重要的是确保我们的软件对最终用户是可用的。否则,我们可能会发现自己需要为用户应该能够自行解决的问题提供支持。在应用程序中定义帮助,并在 Confluence 等工具中进行更广泛的文档和故障排除,对最终用户和支持人员都有帮助。最终,这将让您有更多时间用于开发,这正是我们在这里的原因!

8.5 更新 ACH 解析器

随着数据库能够支持更好的异常处理,我们现在可以着手更新 ACH 解析以编写我们的异常。从格式方面可能遇到的许多异常可以通过 Pydantic 处理。我们可以继续向我们的 Pydantic 定义添加约束和验证,这将从我们的手中移除许多繁琐的任务。其他异常可能需要检查我们记录的字段之外的内容。例如,确保跟踪号是递增的,这是我们更愿意在 Pydantic 之外完成的事情。

8.5.1 为异常创建单元测试

我们已经展示了如何更新我们的 BDD 风格语法来创建带有所需错误的文件。然后我们可以创建基本布局,如下面的列表所示。这种方法对于测试应该效果很好,无论是否遇到异常。

列表 8.8 测试无效的立即目的地
def test_incorrect_immediate_destination(parser):
    ach_filename = "invalid_immediate_destination.ach" #1
    dir_path = os.path.dirname(os.path.realpath(__file__))  #2
    file_path = os.path.join(dir_path, "data", "bad_files",  #2
                                             ach_filename)  #2

    expected_exceptions_count: int = 1 
    expected_exceptions_code: str = #2
       AchExceptions.INVALID_IMMEDIATE_DESTINATION.value

    ach_files_id = 
       SqlUtils.create_ach_file_record(filename, "123456789") #3

    parser.parse(ach_files_id, file_path) #4
    exceptions = SqlUtils.get_exceptions() #5

    assert (          #6
        len(exceptions) == expected_exceptions_count #6
    ), f"Expected {expected_exceptions_count}, #6
➥ but got {len(exceptions)}"  #6
 #6
    assert (  #6
        exceptions[0] == expected_exceptions_code #6
    ), f"Expected {expected_exceptions_code}, #6
➥ but got {exceptions[0]}"  #6

1 定义初始变量,以便我们可以获取一个要加载的文件

2 我们使用 ENUM 值,以便异常代码保持灵活。

3 为我们的插入插入一个所需的虚拟记录

4 解析文件,将其加载到数据库中

5 获取异常

6 断言我们有一个预期的异常

考虑到之前的示例,我们可能会注意到其中大部分代码对每个测试都是相同的。这是一个回忆重构是 TDD 阶段之一的绝佳机会。在这种情况下,我们可以重构代码,使文件的处理过程通用化到一个例程,并使用预期的值调用它,如下面的列表所示。这种方法对于这些基本测试仍然很有效,我们只是想确认异常已被捕获。由于 assert 引发异常,我们仍然在 test_incorrect_file_id_modifier 方法内部看到我们的错误被抛出。

列表 8.9 重构测试
def test_incorrect_file_id_modifier(parser):
    process_file_and_verify_exception(       #1
       ach_filename = "invalid_file_id.ach",  #1
       expected_exceptions_count = 1,     #1
       expected_exceptions_code =  #1
          AchExceptions.INVALID_FILE_ID_MODIFIER.value,  #1
       parser = parser)  #1

def process_file_and_verify_exception(ach_filename: str, #2
   expected_exceptions_count: int,   #2
   expected_exceptions_code: str,  #2
 #2
parser: AchFileProcessor):  #2
…

1 我们创建一个函数并将所需的变量传递给它。

2 被调用的函数包含解析和断言所需变量的相同逻辑。

通过这些基本测试,我们可以轻松地为我们的代码添加异常。当然,在某个时候,我们还将进行更广泛的测试,但这是朝着正确方向前进的一个很好的方法。

8.5.2 在我们的解析器中支持异常

要开始编写我们的异常,我们必须添加一个可以存储异常信息的模式。通过提供文件、批次和条目的 UUID,我们在不同级别报告异常方面具有灵活性。例如,当金额无效时,我们可以直接钻到导致问题的记录,因为我们有权访问文件、批次和条目的 UUID。与此相比,如果我们只有导致异常的 ach_files_id,如果我们文件中有成千上万的记录,我们将有大量工作要做,确定有问题的记录。下面的列表显示了我们的 ACH 异常模式。我们排除了字段定义,以保持简单。

列表 8.10 ACH 异常架构
class AchExceptionSchema(BaseModel):
    ach_exceptions_id: Optional[UUID4] = Field(…)
    ach_files_id: UUID4 = Field(…)  #1
    ach_batch_id: Optional[UUID4] = Field(…)
    ach_entry_id: Optional[UUID4] = Field(…)
    record_number: int = Field(…) #2
    exception_code: str = Field(…) #3

1 我们总是将异常与特定的文件关联。

2 记录号有助于定位未解析的记录。

3 异常代码有助于定位错误详情。

我们继续创建我们的 AchExceptionsSql 类,它具有通常的 insert_recordget_record 方法。我们本可以将其命名为 insert_exceptionget_exception,但为了与其他方法保持一致,我们保持代码相似。

列表 8.11 ACH 异常 SQL
class AchExceptionsSql: #1
    @staticmethod
    def insert_record(ach_file_exception: AchExceptionSchema) -> UUID:
        with get_db_connection() as conn:
            result = conn.execute(
                """
           INSERT INTO ach_exceptions #2
➥ (ach_files_id, record_number,  #2
              exception_code)  #2
           VALUES (%(ach_files_id)s,   #2
➥%(record_number)s, %(exception_code)s)  #2
           RETURNING ach_exceptions_id #2
            """,
                ach_file_exception.model_dump()
            )

        return result.fetchone()[0] #3

    @staticmethod
    def get_record(ach_exception_id: UUID) -> AchExceptionSchema:
        with get_db_connection
➥(row_factory=class_row(AchExceptionSchema)) as conn:
            result = conn.execute(
                """
                SELECT * FROM ach_exceptions #4
                WHERE ach_exceptions_id = %s
                """,
                [ach_exception_id]
            )

            return result.fetchone()

1 包含任何 SQL 调用的异常类

2 标准 SQL 用于插入并返回新添加的 ID

3 返回结果中的第一个字段,即 ID

4 标准 SQL 用于读取异常

现在我们可以创建异常记录,我们可以在 ach_file_processor.py 中需要写入异常的地方简单地调用 _add_exception 例程。列表 8.12 展示了这个异常例程。注意,我们接受 AchExceptionSchema 和未解析的行作为参数。我们选择将行作为可选参数,并将其写入无效记录表。这是因为我们在数据库中较早地将单个记录类型拆分出来。为了适应可能无法解析的记录(因为它不是有效的类型)的情况,我们仍然希望将其存储在某个地方。

列表 8.12 添加异常方法
    @staticmethod #1
    def _add_exception(exception: AchExceptionSchema, #1
➥ unparsed_record=None) -> None: 
        sql = AchExceptionsSql()
        sql.insert_record(exception)
        if unparsed_record is not None: #2
            sql_invalid_rec = AchRecordsSqlTypeInvalid()  #2
            sql_invalid_rec.insert_record #2
➥(AchRecordTypeInvalidSchema(  #2
                ach_files_id=exception.ach_files_id,  #2
                unparsed_record=unparsed_record,  #2
                sequence_number=exception.record_number,  #2
            ))

1 当我们在处理过程中遇到异常时调用的函数

2 当记录已传递时,我们需要将其写入一个特殊的数据库表,因为我们无法确定正确的表(例如,不存在的记录类型)。

通过创建异常的能力,我们需要更新我们的解析方法以返回自定义验证错误。单个字段解析没有变化,但我们希望捕获验证错误并将其包装在自定义错误中。

列表 8.13 更新解析例程
    @staticmethod
    def parse_file_header(
            ach_records_type_1_id: UUID, record: str
    ) -> AchFileHeaderSchema:
        try:  #1
            return AchFileHeaderSchema(  #1
…  #1
            ) 
        except ValidationError as e: #2
            raise AchParsingValidationError( #3
   message='Error parsing file header',  #3
   validation_errors=e.errors())  

1 尝试使用 Pydantic 解析标题

2 当遇到验证错误时进行捕获

3 使用 raise 关键字创建一个自定义错误,用于存储我们的 ACH 相关信息

自定义 AchParsingValidationError 在以下列表中显示。自定义错误的目的在于我们可以将遇到的错误转换为等效的 AchException 代码,这有助于将它们写入数据库。

列表 8.14 自定义 ACH 解析错误
class AchParsingValidationError(Exception): #1
…
    def get_exception_codes(self) -> list[str]: #2
        exception_codes = []   
        for error in self.validation_errors: #3
            if error['loc'][0] == 'file_id_modifier': #4
exception_codes.append(AchExceptions.  #4
➥INVALID_FILE_ID_MODIFIER.value)  #4
            elif error['loc'][0] == 'immediate_destination':   #4
exception_codes.append(AchExceptions.  #4
➥INVALID_IMMEDIATE_DESTINATION.value)  #4
        return exception_codes

1 我们的错误是 Exception 的子类。

2 定义一个获取异常列表的函数

3 处理数组 self.validation_errors 中的每个验证

4 将有问题的字段翻译为我们的一种异常代码

然后,我们可以更新记录的个别解析,以在遇到异常时添加异常。

列表 8.15 处理验证错误的更新
case "1":
   ach_record = AchRecordType1Schema(
      ach_files_id=ach_file_id,
      unparsed_record=line,
      sequence_number=sequence_number)
   ach_record_id = AchRecordsSqlType1().insert_record(ach_record)
   current_file_header_id = ach_record_id
   try:  #1
      self._parse_file_header(ach_record_id, line)  
   except AchParsingValidationError as e:  #2
      for exception_code in e.get_exception_codes():  #2
         self._add_exception(AchExceptionSchema(  #2
            ach_files_id=ach_file_id,   #2
            record_number=sequence_number,  #2
            exception_code=exception_code,  #2
            ))  #2

1 我们尝试解析记录。

2 对于我们找到的每个错误,它都会写入一个异常。

这为 ACH 解析器中异常的处理提供了一个基本框架。相同的公式适用于其他记录类型。

8.5.3 Pydantic 中的验证

大部分字段级验证将在 Pydantic 中进行。让我们看看一些异常以及我们如何在 Pydantic 中实现它们。

首先,我们希望解决类型 1 记录上的文件 ID 修改符。这应该是一个从 A 到 Z 的大写字母或从 0 到 9 的数值,我们可以在 Pydantic 中轻松指定。

列表 8.16 Pydantic 对文件 ID 修改符的验证
file_id_modifier: Annotated[str, 
   StringConstraints(
      pattern=r'^[A-Z0-9]$', #1
      min_length=1, #2
      max_length=1)] 

1 我们定义一个正则表达式模式来验证。

2 我们还限制了字段大小,尽管模式本身应该足够。

另一个需要覆盖的条件是立即目的地。我们知道这应该匹配我们银行的路线号。我们不一定想将这些值硬编码到我们的代码中,因为它们可能会改变(尽管不经常),但如前所述,经历收购和合并的银行可能会获得(或失去)路线号。因此,我们想要一种方法,让 Pydantic 根据在运行时已知的值列表验证立即目的地。

这个任务是通过 Pydantic 中的 field_validator 注解完成的。为了我们的目的,我们将将其连接到数据库调用,这让我们可以验证字段与数据库中允许的值列表。

列表 8.17 Pydantic 立即目的地字段验证器
@field_validator('immediate_destination') #1
@classmethod 
def validate_immediate_destination(cls, v):
  try:
      if BankRoutingSql().valid_bank_routing_number(v): #2
         return v
      else:  #3
         raise ValueError('immediate_destination #3
➥ is not a valid routing number') 
   except Exception: #4
      raise ValueError('Unable to validate #4
➥ immediate_destination') 

1 使用 field_validator 和 classmethod 注解

2 使用 SQL 检查银行路由号,如果有效则返回它

3 否则,如果没有找到,将引发 ValueError

4 也用 ValueError 处理任何意外的错误

为了支持这一点,我们必须创建一个简单的表格,其中包含我们想要考虑为我们的银行路由号的路线号。根据我们期望验证的记录数量,对于每个记录(以及可能的多字段)执行数据库查询可能效率低下。在我们的情况下,任何我们加载的文件中都应该只有一个 ACH 文件头记录。因此,我们不太关心性能的影响,但这些潜在问题的类型应该被记住。

一旦我们开始验证立即起源,我们需要检查并纠正许多测试,在这些测试中,我们有一个有效的数字,但它不在我们的数据库中。将进行额外的单元测试清理来处理异常,并扩展传递给例程的参数,以便我们可以记录异常。当这些方法开始变得过于复杂时,将是时候将它们拆分到自己的记录类型类中,远离解析器本身。

8.6 更新 API

在通过 ACH 解析和错误处理单元测试后,我们可以跳转到仪表板来查看实际操作。不幸的是,如果你尝试加载其中一个异常文件,你将不会在仪表板上看到它。发生了什么?

我们已经有了支持加载文件并检查结果的 API 端点的 BDD 语法,因此不需要编写任何新代码来支持下一个列表中显示的测试。能够创建新测试而不需要编写任何新代码是 BDD 及其灵活语法的优势之一。运行这个测试确实显示没有返回任何记录。

列表 8.18 使用无效文件测试我们的 API
Scenario: I should get a file even when
➥ it has an exception #1
   Given that I have a clean database #2
   And that I have posted the file "invalid_file_id.ach" 
   When I request a list of files #3
   Then I should have a file that includes the
➥ file "invalid_file_id.ach" #4

1 定义一个场景

2 设置数据库并处理 ACH 文件

3 执行 API

4 检查我们包含的文件

另一个拥有测试的好处是,我们可以简单地添加一个断点到端点,以调试模式重新运行测试,这允许我们逐步执行代码。如果你花时间这样做,你应该会看到查询没有返回任何结果。

查询无法处理解析记录(ach_file_headersach_file_control_records)未找到的情况。因为我们使用了INNER JOIN,记录必须存在于数据库中才能返回。

列表 8.19 在我们的原始查询中使用INNER JOIN
SELECT af.ach_files_id AS id,
                       af.file_name AS filename,
                       af.created_at AS date,    
                       afh.immediate_origin_name AS originator,                 
                       afcr.total_debit_entry_dollar_amount AS debit_total, 
                       afcr.total_credit_entry_dollar_amount
➥ AS credit_total,
                FROM ach_files AS af
                INNER JOIN ach_records_type_1 AS art1 USING (ach_files_id)
                INNER JOIN ach_records_type_9 AS art9 USING
                                     (ach_records_type_1_id)
                INNER JOIN ach_file_headers AS afh USING  #1
                                     (ach_records_type_1_id)  #2
                INNER JOIN ach_file_control_records AS afcr USING #2
                                           (ach_records_type_9_id)   #2
                ORDER BY af.created_at DESC

1 在这些文件上使用 INNER JOIN 可能会导致异常发生时记录不返回。

查询中需要ach_file_headersach_file_control_records。现在我们已经开始添加异常并且不再输出解析后的记录,我们也将需要更加关注这些查询。由于无法保证这个功能一定会存在,我们应该使用LEFT JOIN来返回结果。下面的列表显示了更新我们的查询以使用LEFT JOIN。根据我们用来处理这个问题的流程,我们可能不想在测试验证之前对两个表都进行更改。

列表 8.20 更新我们的查询以使用LEFT JOIN
INNER JOIN ach_records_type_1 AS art1 USING (ach_files_id)
INNER JOIN ach_records_type_9 AS art9 USING (ach_records_type_1_id)sd
LEFT JOIN ach_file_control_records AS afcr#1
➥ USING (ach_records_type_9_id)  #1
LEFT JOIN ach_file_headers AS afh #1
➥ USING (ach_records_type_1_id) 
ORDER BY af.created_at DESC

1 将我们的查询更新为 LEFT JOIN 通过保留左表中的数据来解决问题。

随着查询的更新,我们现在看到测试通过了。我们也许会认为我们之前所有章节都在添加异常,但我们既没有任何 API 来处理它们,也没有更新我们现有的 API 来考虑它们。

当考虑在我们的仪表板上包含异常时,我们可能希望有 API 可以帮助我们完成以下任务:

  • 返回所有异常——无论我们是寻找所有曾经遇到的异常还是只是今天遇到的异常,我们都需要对遇到的异常有所了解。此外,如果我们有兴趣允许用户对异常进行维护(更改异常级别),这是一个好的起点。

  • 返回特定文件的异常——肯定会有获取特定文件异常的需求。

我们有更多 API 的用例,但这些应该足以让我们开始使用一些 API。我们必须做出的一个设计选择是端点布局。我们是创建一个新的端点,使我们的异常类似于现有的 /files 端点(类似于 /api/v1/exceptions),还是异常是文件的一部分(类似于 /api/v1/files/exceptions)。我们会使用两者的组合吗?由于异常将与加载的文件相关联,我们保持异常与 /files 端点相关联。

接下来,我们想要确保我们的异常 API 正在返回预期的数据。例如,当我们加载 invalid_file_id.ach 时,我们期望从数据库返回一个错误代码和消息。我们可以在 test_file_api.py 中使用我们的大部分现有代码,并只包括几个其他步骤。从这一点开始,我们可以创建一个测试,然后对其进行验证。我们创建了一个名为 ach_files_exception_endpoint.feature 的新文件,用于存储我们的异常功能。

列表 8.21 测试我们的异常 API
Feature: Exceptions returned for files
  Test the functionality of the exception returned for loaded ACH files

  Scenario: I get a list of exceptions for a file #1
    Given that I have a clean database #2
    And that I have posted the file "invalid_file_id.ach" 
    When I request a list of exceptions for the#3
➥ file "invalid_file_id.ach"  #3
    Then I should receive an error of "004" and a #3
➥ message of "Invalid File ID Modifier" 

1 为异常 API 设置场景

2 确保数据库为空并加载文件

3 调用 API 并验证异常

为了支持新的 When 子句,我们必须添加代码来请求文件列表,以便我们可以确定 UUID。之后,我们使用获得的 ID 向异常端点发起请求。为了避免任何意外错误,我们也可以确保从响应中返回一条记录,以及一个有效的响应代码。然而,由于我们已经清空了数据库并只加载了一个文件,这应该是一个导致这种情况的异常条件。

列表 8.22 添加新的 When 子句
@when(parsers.parse('I request a list of exceptions
➥ for the file \"{filename}\"'))
def get_list_of_exceptions_for_file(api_response, filename):
    response = client.get("/api/v1/files/")  #1
    assert response.status_code == 200, response.text
    file_id = response.json()[0]["id"] #2
    response = client.get #3
➥(f"/api/v1/files/{file_id}/exceptions")  #4
    assert response.status_code == 200, response.text
  api_response["response"] = response.json() #4

1 获取文件列表并确保其成功

2 从响应中提取 file_id

3 使用我们从响应中提取的 file_id,再次调用我们的异常 API,并验证 HTTP 响应

4 将响应保存以供其他步骤使用

我们已经有了存储的 API 响应,现在我们想要确保我们从响应中获得了预期的错误代码和消息。为什么我们要验证错误代码和返回的消息文本?难道错误代码不应该起决定性作用吗?一方面,API 的消费者应该依赖于错误代码而不是错误消息描述。另一方面,我们想要确保错误代码和描述都符合我们的预期。此外,我们可能还想确定描述何时发生变化,因为也许我们的 API 的某些消费者直接与描述而不是与错误代码打交道。也许 API 在某个时刻是错误的,并为不同的错误返回了相同的错误代码。

errors.

为了绕过错误,消费者可能直接查询了描述,对其更改可能会破坏 API。当然,如果我们更改了该 API,我们可能会选择更改版本,以便消费者在他们准备好时迁移到更新的 API。话虽如此,我们只需意识到更改可能会以意想不到的方式影响最终用户。我们已经看到客户为了自己的目的实现了自己的日志消息抓取。当这些消息(尽管它们是应用程序内部的)更改时,基础设施就崩溃了。最后,必须将消息放回原位。因此,请非常小心——看似无害的更改可能会引起问题。

列表 8.23 添加新的 Then 子句
@then(parsers.parse('I should receive an error 
➥ of \"{error_code}\" and a message of \"{error_message}\"')) 
def check_error_code_exists_on_response(error_code, 
➥ error_message, api_response):
    assert any( 
        error_code == response["exception_code"]
➥ for response in
api_response["response"] 
    ), f"Expected {error_code} in" 
➥ f" {api_response['response']}" 
    assert any(#1
        error_message == response["description"]
➥ for response in api_response["response"]
    ), f"Expected {error_message} in" f" {api_response['response']}"

1 使用 any,我们可以在我们的响应中搜索一个字段。当处理一个值数组并且我们不知道响应的顺序时,这种策略很方便。

我们现在对这些 API 进行了测试,以确定它们是否成功返回数据。作为一个编程挑战,你能创建这些 API 背后的逻辑并使测试通过吗?

我们创建了异常端点 /{file_id}/exceptions,如下所示。

列表 8.24 文件的异常端点
@router.get(   #1
    path="/{file_id}/exceptions",  #1
    response_model=list[AchExceptionsResponse],  #1
    summary="Retrieve ACH File Exceptions",  #1
    description="Retrieve the exceptions in an ACH file.",  #1
    response_description=  #1
➥"The exceptions in the requested ACH file.",  #1
    tags=["ACH Files"],  #1
)
async def read_exceptions_for_file(file_id: UUID) ->  #2
list[AchExceptionsResponse]:  #2
    return AchExceptionsSql() #2
➥.get_exceptions_response(file_id)  #2

1 我们将用于 OpenAPI 文档的 API 详细信息

2 实际的函数

接下来,我们创建了 get_exceptions_response 方法。

列表 8.25 get_exceptions_response 方法
    @staticmethod
    def get_exceptions_response(ach_files_id: UUID = None) -> list[AchExceptionsResponse]:
        query_parameters = []
        sql = """
                    SELECT #1
                        afe.ach_exceptions_id AS id, 
                        afe.ach_files_id AS file_id, 
                        afe.ach_records_type_5_id AS batch_id, 
                        afe.ach_records_type_6_id AS entry_id, 
                        afe.record_number AS record_number, 
                        afe.exception_code AS exception_code, 
 aec.exception_description AS description
 FROM ach_exceptions AS afe #2
 INNER JOIN ach_exception_codes AS aec #3
 USING (exception_code) 
  """
        if ach_files_id is not None:    #4
            sql += " WHERE afe.ach_files_id = %s"  #4
            query_parameters.append(ach_files_id)  #4

        with get_db_connection(row_factory=class_row(AchExceptionsResponse)) as conn:
            result = conn.execute(sql, query_parameters) #5
            return result.fetchall() 

1 使用我们想要的字段的标准 SELECT 语句

2 我们最初的表是 ach_exceptions。

3 将我们的异常与异常代码连接起来以获取更多详细信息

4 如果我们收到了特定的 ach_files_id,则使用它来通过附加 WHERE 子句过滤我们的结果。

5 执行查询并返回结果

我们的测试应该通过。如果我们逐步执行代码并检查我们收到的响应,我们应该看到 batch_identry_id 都被设置为 None,如下例响应所示。

列表 8.26 示例响应
[{…, 'batch_id': None, 'entry_id': None,
➥ 'record_number': 1, 'exception_code': '004',…}]

我们可以考虑更新我们的 SQL 查询,使用 COALESCE 将返回的值设置为空字符串——例如,COALESCE(afe.ach_records_type_5_id, '' ) AS batch_id。然而,因为我们已经定义了 Pydantic 模型以期望一个 UUID,而空字符串不是一个有效的 UUID,我们将收到一个 InvalidTextRepresentation 错误。因为我们返回 JSON,更好的替代方案可能是从响应中删除此值。请注意,这不一定总是最佳选择。例如,如果我们想指出值的明确缺失,可能希望返回某种表示值缺失的东西。然而,对于我们的目的,删除值就足够了。以下列表显示了如何从我们的响应中删除 None 值。由于我们对此不感兴趣,我们将看到我们的 UI 将在没有我们帮助的情况下处理缺失的值。

列表 8.27 从我们的模型中删除 None
@router.get(
    path="/{file_id}/exceptions",
    response_model=list[AchExceptionsResponse],
    summary="Retrieve ACH File Exceptions",
    description="Retrieve the exceptions in an ACH file.",
    response_description="The exceptions in the requested ACH file.",
 response_model_exclude_none=True, #1
    tags=["ACH Files"],
)

1 删除包含 None 的字段

response_model_exclude_none 设置为 True 后,我们应该准备好将注意力转向 UI 以开始利用我们的异常 API。现在我们已经声明了文件.py 中的多个端点,我们想要确保我们注意到的声明顺序。我们确保我们从一般端点,如 /files/exceptions,到更具体的端点,如 /{file_id}/exceptions。如果我们遇到 422 错误,这可能表明 FastAPI 选择了错误的端点来使用。

8.6.1 获取未解析记录

我们需要为仪表板添加的另一项功能是从数据库中检索未解析记录的能力。正如我们所见,当存在解析异常时,我们不会将解析后的记录写出来。如果最终用户能够看到导致异常的记录的样子,这将很有帮助。当然,他们可以自己打开文件并查看,但这并不友好。我们还建议始终传递记录(这将使此请求更直接),因为未解析的记录可能包含非公开信息(NPI)数据。通过创建一个单独的端点来返回记录,例如当按钮或图标被点击时,我们可以调用特定的端点来检索数据,这使我们能够采取预防措施,例如

  • 当用户未认证时隐藏显示记录的按钮。

  • 在查看信息时创建审计跟踪

  • 防止不必要的数据被返回,因为用户并不总是需要看到未解析的记录

我们可以首先创建一个场景来测试这种方法。

列表 8.28 未解析记录的场景
Scenario: I get the unparsed record for a
➥ specific exception #1
    Given that I have a clean database
    And that I have posted the file "invalid_file_id.ach"
    When I request the unparsed record for the exception code "004"
    Then I should receive an
➥ unparsed record of "1…i…XXXXX" #2

1 未解析记录 API 的场景

2 包含整个未解析记录

以下代码创建了一个步骤,有助于支持获取给定异常的未解析记录。我们假设我们只返回一个文件,并且唯一的异常就是我们想要的。稍后,我们可能需要使代码更健壮,但现在没有必要。

列表 8.29 获取未解析记录的步骤定义
@when('I request the unparsed record for the exception code "004"')
def request_unparsed_record(api_response):
    response = client.get("/api/v1/files/") #1
    assert response.status_code == 200, response.text #1
    file_id = response.json()[0]["id"]  #1
    response = client #1
➥.get(f"/api/v1/files/{file_id}/exceptions")  #1
    assert response.status_code == 200, response.text #1
 exception_id = response.json()[0]["id"] #1
 response = #1
 client.get(f"/api/v1/files/{file_id}  #1
➥/exceptions/{exception_id}")  #1
 assert response.status_code == 200, response.text #1
  api_response["response"] = response.json() #2

1 与之前的 API 测试类似,我们必须进行多次调用以深入到我们感兴趣的数据。在每次 API 调用中,我们必须在继续之前验证成功的 200 响应代码,以防止后续调用中的错误。

2 我们将最终 API 调用的结果存储起来以供进一步使用。

如果我们运行此代码,我们可能会得到 404 错误,因为我们尚未定义端点。你应该看到 client.get 调用中的一个失败,错误如下:

Expected :200
Actual   :404

需要采取哪些步骤来修复此错误?

  • 我们需要在文件.py 中创建一个新的路由来获取给定 exception_id 的未解析记录。

  • 我们更新了 ach_exceptions_sql.py 以从数据库中返回 unparsed_record

我们将在第九章讨论审计时介绍此代码。现在,请随意尝试完成它。

8.7 通过 UI 了解异常

在前面的章节中,我们已经构建了创建异常文件、识别这些异常、适当地记录它们,并最终通过 API 返回信息的功能。现在,我们可以看看如何将查看异常的功能整合到我们的仪表板中。我们可能希望为用户提供越来越具体的视图,以便深入到异常中。例如,

  • 对于所有文件的异常有助于操作人员了解系统中的异常,并分析遇到的异常的数量/类型。

  • 对于特定文件的异常可能有助于确定文件是否需要被移除并重新处理。

  • 对于特定批次的异常有助于确定是否有特定公司存在问题。此外,即使只有特定批次被拒绝,文件仍然可以处理。

  • 对于特定条目的异常可能有助于与客户或公司合作解决导致异常的原因。

提供所有这些视图是否必要?当然,客户将会有他们的意见,业务将为我们提供指导。此外,业务也会考虑我们的意见。这些可能更容易或更高效,具体取决于事情是如何设置的。在我们的情况下,我们可以轻松提供所有文件和特定文件的异常,所以我们将从这里开始。

8.7.1 显示所有异常

了解所有异常的最简单方法是在屏幕左侧提供一个导航按钮,以便查看当天所有异常(我们说“当天”,但我们将把按天过滤作为你的练习)。记住,最终,为了提供更好的用户体验,我们需要提供用户搜索和过滤的能力。每天将加载多个 ACH 文件,如果用户很快就会感到不知所措,或者如果我们不小心推送的数据量过大,仪表板可能会变得无响应。当我们到达异常数量开始影响性能和用户体验的程度时,我们需要考虑通过提供分页、搜索功能和其他策略来限制每次检索的数据量。

考虑到这一点,我们可以审查更新 NavButtons.tsx 以导航到异常页面。

列表 8.30 向仪表板添加错误导航
<ListItemButton onClick={() => route.push("/exceptions")}>
   <ListItemIcon>
      <Error/>
   </ListItemIcon>
   <ListItemText primary="Exceptions"/>
</ListItemButton>

一旦我们有了按钮,我们就需要创建实际的页面。该页面应满足以下要求:

  • 显示所有异常的列表

  • 允许查看导致异常的记录

  • 当行被点击时,允许导航到文件

这些是一些直接的要求。让我们看看如何实现它们。首先,我们希望使用数据网格元素显示异常,就像我们在页面上的一些其他数据上所做的那样。回想一下,Material UI(MUI)的数据网格元素允许我们排序、搜索和过滤,使得它在我们很少的工作下就能完全功能化。以下列表显示了用于从 http://localhost:8000/api/v1/files/exceptions 检索异常并填充到我们的数据网格的基本 API 调用。

数据网格。

列表 8.31 填充数据网格的 API 调用
…
    const [entries, setEntries] = useState<AchExceptionsResponse[]>([]);
…
    useEffect(() => {
        const apiUrl = process.env #1
➥.NEXT_PUBLIC_API_URL ?? ''; 
        axios.get<AchExceptionsResponse[]> #2
➥(`${apiUrl}/files/exceptions`,  #2
            {    #2
            headers: {  #2
                'Content-Type': 'application/json' #2
            }  #2
        })  #2
            .then(response => {  #2
                console.log(`Response data 
${JSON.stringify(response.data)}`);  #2
                setEntries(response.data);  #2
            })  #2
            .catch(error => {  #2
                console.log(error);  #2
            });  #2
    }, []); #2
… #2

1 我们 API 的 URL

2 发起 Axios 调用以获取所有异常

使用数据网格,我们采取了另一种方法来展示额外的功能(列表 8.32)。我们定义了一个包含信息图标的列,并使用renderCell添加图标。这可以是一个方便的方式来控制单元格的内容,尤其是在我们不直接将某些内容映射到表格的情况下。我们还利用了另一个函数——valueGetter——来转换我们使用之前定义的convertDateFormat接收到的日期。

列表 8.32 定义数据网格
…
    const columns: GridColDef[] = [
        {field: 'view', headerName: 'View', 
➥ sortable: false, width: 10, renderCell: (params) => (
                <IconButton  #1
                    onClick={(e) => {  #1
                        e.preventDefault();  #1
                        setIsOpen(true);  #1
                    }}  #1
                    color="primary"  #1
                    ▶ #1
 <InfoIcon /> #1
 </IconButton> #1
 )},
  {field: 'file_name', headerName: 'Filename', width: 150},
        {field: 'created_at', headerName: 'Date',
➥ width: 150, valueGetter: (params) => convertDateFormat(params.value)},
        {field: 'record_number', headerName: 'Record Number', width: 150},
        {field: 'exception_code', headerName: 'Code', width: 10},
        {field: 'description', headerName: 'Description', width: 300},
    ]
…
<DataGrid columns={columns} rows={entries}/> #2
… 
            <AlertMessage open={isOpen} setOpen={setIsOpen} #3
      message="Exception Record" title="Record Exception" /> 
…

1 定义了一个可点击的图标,可以用来显示未解析的记录

2 异常网格使用 MUI 数据网格组件。

3 重新使用我们的 AlertMessage 组件并显示异常记录

在我们的 API 和数据网格完成后,我们现在应该能够上传一个包含异常的文件,导航到这个页面,并看到我们的异常被列出。当点击信息图标时,我们渲染一个静态消息作为占位符。为什么不使用 API 调用而不是静态消息来检索实际记录,以便我们可以显示未解析的记录?我们当然可以这样做;然而,有一些原因要避免这样做:

  1. 返回不必要的数据**——由于用户不太可能点击列表中的每个异常,因此大量数据将保持未使用状态。

  2. *职责分离**——我们希望这个 API 作为我们异常的概述。使用单独的 API 调用返回数据有助于保持 API 和 SQL 代码的简单性。

  3. 安全考虑**——未解析的记录可能包含账户号码和其他我们不希望总是传递回的敏感 NPI 数据。通过将其作为单独的 API 调用,我们可以控制谁可能能够查看这些详细信息,并且可以精确记录某人何时查看这些数据。

通过理解显示未解析的记录可能会打开显示 NPI 数据的功能,我们可以调用 API 获取未解析的记录并在警告框中显示它。当然,我们最终可能希望利用我们为异常保留的其他详细信息来创建一个格式更好的窗口。目前,仅显示相关的记录就足够了。我们可以使用params.row从点击的行中检索所需信息。以下列表显示了放入图标按钮的更新后的代码。

列表 8.33 更新的图标按钮
<IconButton
   onClick={(e) => {
      e.preventDefault();
      const fileId = params.row.file_id; #1
      const exceptionId = params.row.id; 
      const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? ''; #2
      axios.get<AchExceptionDetailsResponse>( #3
         `${apiUrl}/files/${fileId}/exceptions/ 
➥${exceptionId}`, { 
         headers: { 
            'Content-Type': 'application/json'
         } 
      }) 
      .then(response => { #4
         setUnparsedRecord(response.data.unparsed_record);  #4
         setIsOpen(true);  #4
      })  #4
      .catch(error => { #5
         setUnparsedRecord(error.message)  #5
         setIsOpen(true);  #5
      });  #5
   }}
color="primary">
…
</IconButton>

1 获取行中文件和异常的 ID

2 我们的 API URL

3 Axios 调用来获取未解析的记录

4 在响应时,设置未解析的记录和用于对话框的布尔值

5 如果有错误,则在对话框中显示它

现在我们有了一种查看异常和导致错误记录的方法。这对我们的最终用户应该很有用。然而,当上传 ACH 文件时,他们是否应该点击异常?让我们更新我们的初始 Recent ACH Uploads 屏幕以在文件有异常时在文件名旁边显示一个指示器。

8.7.2 异常的视觉提示

当我们首次查看仪表板时,应该有一些指示表明已加载的文件有异常。我们通过更新仪表板,在文件包含异常时包含图标来实现这一点。我们进行以下更新:

  1. SQL 查询需要返回一个布尔值,指示文件是否有异常。

  2. API 需要返回新的字段作为 has_exceptions

  3. UI 需要检查此字段,当其为真时显示图标。

我们首先创建了一些测试场景,这样我们就可以处理我们列表上的前两项。以下列表显示了新添加的场景。

列表 8.34 测试 has_exceptions 的场景
  Scenario: I should not have any exceptions
    Given that I have a clean database
    And that I have posted the file "sample.ach"
    When I request a list of files
    Then the has_exceptions field should be False

  Scenario: I should get a file even when it has an exception
    Given that I have a clean database
    And that I have posted the file "invalid_file_id.ach"
    When I request a list of files
    Then I should have a response that includes
➥ the file "invalid_file_id.ach"
    And the has_exceptions field should be True

可以将 get_files_response 更新为包含 has_exceptions 字段。以下列表显示了如何使用公用表表达式(CTE)获取唯一的 ach_files_id 值,然后将这些值转换为布尔值以用于 has_exceptions

列表 8.35 更新 get_files_response 的 SQL
WITH exceptions AS (  #1
   SELECT DISTINCT(afe.ach_files_id)  #1
   FROM ach_exceptions AS afe #1
)  #1
SELECT af.ach_files_id AS id,
       af.file_name AS filename,
       af.created_at AS date,    
       afh.immediate_origin_name AS originator,                 
       afcr.total_debit_entry_dollar_amount AS debit_total, 
       afcr.total_credit_entry_dollar_amount AS credit_total,
       CASE #2
          WHEN exceptions.ach_files_id IS NOT NULL THEN TRUE #3
       ELSE FALSE #3
       END AS has_exceptions #3
       FROM ach_files AS af
INNER JOIN ach_records_type_1 AS art1 USING (ach_files_id)
INNER JOIN ach_records_type_9 AS art9 USING (ach_records_type_1_id)
LEFT JOIN ach_file_headers AS afh USING (ach_records_type_1_id)
LEFT JOIN ach_file_control_records AS afcr USING (ach_records_type_9_id) 
LEFT JOIN exceptions USING (ach_files_id) #3
ORDER BY af.created_at DESC
LIMIT %s
OFFSET %s   

1 创建一个公用表表达式(CTE)以获取唯一的文件 ID

2 使用情况语句设置一个名为 has_exceptions 的布尔字段

3 由于并非每个文件都有异常,因此使用 LEFT JOIN

现在我们可以更新 UI 以支持新的 has_exceptions 字段。

列表 8.36 更新的 API 映射
const transformedData: AchFiles[] = response.data.map
➥((row: AchFilesResponse) => ({
   id: row.id,
   date: row.date,
   filename: row.filename,
   originator: row.originator,
   creditTotal: new Decimal(row.credit_total),
   debitTotal: new Decimal(row.debit_total),
 hasExceptions: row.has_exceptions #1
}));

1 确定文件是否有异常的新字段

以及更新的 RecentAchUploads.tsx

列表 8.37 显示错误图标
import {ErrorOutlineOutlined} from "@mui/icons-material";
…
<TableCell>
   <Link onClick={() => route.push(`/fileDetails/${file.id}`)} 
         sx={{ cursor: 'pointer' }}>{file.filename}</Link>
 {file.hasExceptions &&
➥ <ErrorOutlineOutlined color="error" />} #1
</TableCell>

1 如果文件有异常,显示图标以指示其存在

这样,当上传带有异常的 ACH 文件时,我们的 Recent ACH Uploads 应该有一个如图 8.4 所示的视觉指示。

计算机屏幕的特写  自动生成的描述

图 8.4 文件错误的视觉指示

8.8 其他 UI 更改

现在我们有了异常查看的基本结构,我们可以处理一些来自用户在初始 MVP 评估期间提出的要求。

  1. 文件屏幕还应显示该文件的特定异常。这应该在我们的初始异常处理之后进行。我们可以重构我们的 UI 异常页面,使其成为一个组件,并将所需的异常信息传递给它。

  2. 批量屏幕应包含包含总计和交易代码行项分解等统计信息的组件。

  3. 我们在批次项目列表中应包含它们关联的应用程序,例如检查、储蓄等。虽然交易代码与那些应用程序相关,确保它们包含在我们的 DataGrid 中将提供更好的用户体验。

  4. 我们应该对尺寸变化更加敏感。目前,我们的组件按列堆叠,我们希望它们也能使用可用宽度。

8.8.1 创建异常组件

我们可以将页面重构以创建一个异常组件,这有助于我们在其他地方展示错误时防止代码重复。我们需要显示的异常信息是相同的,无论我们是要查看所有异常还是仅按文件过滤它们。一方面,这意味着我们可以轻松地复制并粘贴现有页面来创建一个新的页面或组件,该页面或组件可以按我们的文件进行过滤,并且我们就完成了。另一方面,我们可以创建一个通用的异常组件,两个页面都可以使用,传递给它的是完整的异常列表或为特定文件过滤过的列表。正如我们之前所述,只要可能,我们希望避免重复工作,因为这可能在短期内通过将产品推出市场而获得回报,但未来的维护变得困难且繁琐,因为必须多次应用修复。以下列表显示了将 DataGrid 和相关调用从原始页面拉入独立组件的结果。

列表 8.38 独立异常组件
…
interface ExceptionsProps {
    exceptions: AchExceptionsResponse[];
}

export default function Exceptions({exceptions}:
➥ Readonly<ExceptionsProps>) {

    const [isOpen, setIsOpen] = useState(false);
    const [unparsedRecord, setUnparsedRecord] = useState('');
    const columns: GridColDef[] = [
…
           <DataGrid columns={columns} rows={exceptions}/>
…
           <AlertMessage open={isOpen} setOpen={setIsOpen}
➥ message={unparsedRecord} title="Unparsed Record" />
…

使用我们新的异常组件,我们可以通过调用 /file/{fileId}/exceptions 端点将其包含在现有的 fileDetails 页面上,以获取我们正在查看的文件的异常列表,并将结果传递给我们的新组件。以下列表显示了所需的更改。

列表 8.39 使用我们的新组件
…
import Exceptions from "@/app/components/Exceptions";
import {AchExceptionsResponse} from "@/app/interfaces/AchExceptionsResponse"; 
…  axios.get<AchExceptionsResponse[]>(`${apiUrl}/files/
➥${fileId}/exceptions`, {
            headers: {
                'Content-Type': 'application/json'
            }
        })
            .then(response => {
                console.log(`Response data ${JSON.stringify(response.data)}`);
                setExceptions(response.data); #1
            })
            .catch(error => {
                console.log(error);
 });

 }, []);
…
 <Exceptions exceptions={exceptions} /> #2
…

1 设置返回的异常

2 包含了我们新的组件,该组件将异常传递给它

我们非常喜欢通过创建组件,使其变得如此容易在任何需要的地方导入和使用。我们唯一想强调的是要小心组件可能需要或获取的数据。我们可能必须评估我们是否想传递数据给它或让它自己请求数据。正如我们之前在仪表板中看到的那样,有时我们可以通过传递所需数据来节省冗余的 API 调用。我们确实将未解析记录的获取留在了组件内部,因为这些是偶尔的调用,最好在组件中封装。

8.8.2 批量统计组件

我们现在可以处理批量统计组件。这个请求的目的是使用户快速了解批次的某些重要方面,如图 8.5 所示。

统计截图,描述自动生成

图 8.5 批量统计组件

虽然图表和图形提供了很好的可视化效果,可以帮助比较大量数据,但我们将使用简单的列表来构建此组件,因为我们只需要提供一些概述的总数。

我们将继续使用现有的 AchBatch-EntriesResponse 为此组件。使用简单的列表和 MUI 中的项目,我们可以创建我们的组件。我们使用 ListItemText 来显示主要和次要文本。我们还使用 createListItems 创建了一些元素并将它们包含在渲染中。以下列表显示了我们的基本组件。

列表 8.40 AchBatchStatistics 组件
export default function AchBatchStatistics({entries}:
➥ Readonly<AchBatchStatisticsProps>) {

    const computedBatchStatistics = #1
➥ computeBatchStatistics(entries);  #1
    const computedBatchTotals =  #1
➥ computeBatchTotals(entries);  #1
    const listItems =  #1
➥ createListItems(computedBatchStatistics);  #1

    return (
        <>
           <Title>Batch Statistics</Title>
           <List dense={true}>
              <ListItem key="creditTotal">
                 <ListItemText 
                    primary="Credit Total" 
                secondary={`${formatCurrency(computedBatchTotals.credit)}`} 
                 />
              </ListItem>
              <ListItem key="debitsTotal">
                 <ListItemText
                    primary="Debit Total" 
                secondary={`${formatCurrency(computedBatchTotals.debit)}`} 
                 />
              </ListItem>
              <ListItem key="otherTotal">
                 <ListItemText 
                    primary="Other Total" 
                 secondary={`${formatCurrency(computedBatchTotals.other)}`} 
                 />
              </ListItem>
                 {listItems} #2
              </List>
        </>
    );
}

1 辅助函数,用于收集、汇总和按需格式化数据

2 包含任何额外的 ListItem 元素

在以下列表中,我们创建了 computeBatchTotals 函数,以提供我们的借方、贷方和其他金额的总和。我们将支持额外的交易代码,但到目前为止,我们只考虑交易代码 22 和 27。此外,由于 AchBatchEntriesResponse 已格式化金额,我们需要移除这些字符。理想情况下,我们希望返回并不要有格式化金额的响应,但这可能并不总是实用的。

列表 8.41 computeBatchTotals 函数
function computeBatchTotals
➥(entries: AchBatchEntriesResponse[]): BatchTotals {
    const totals: BatchTotals = {  #1
        debit: new Decimal(0),  #1
        credit: new Decimal(0),  #1
        other: new Decimal(0)  #1
    };  #1
    entries.forEach(entry => {
        const cleanAmount = entry.amount.replace(/[$,]/g, ''); #2
        const amount = new Decimal(cleanAmount);
        if (entry.transaction_code === 22) {
            totals.debit = totals.debit.plus(amount);
        } else if (entry.transaction_code === 27) {
            totals.credit = totals.credit.plus(amount);
        } else {
            totals.other = totals.other.plus(amount);
        }
    });
    return totals; #3

}

1 初始化 BatchTotals 对象

2 我们需要移除货币格式

3 返回 BatchTotals 对象

在列表 8.42 中,我们还创建了包含每个交易的计数和金额的 computeBatchStatistics 函数。为了代码和测试的简便,我们创建了一个另一个函数,它会遍历批次的条目。我们根据描述对项目进行分组,因为我们将在显示时使用它。

列表 8.42 computeBatchStatistics 函数
function computeBatchStatistics
➥(entries: AchBatchEntriesResponse[]):
➥ Map<string, BatchStatistics> {
    const countMap = new Map<string, BatchStatistics>(); #1
    entries.forEach(entry => {  #2
        const cleanAmount = entry.amount.replace(/[$,]/g, ''); #3
        const stats = countMap.get(entry.transaction_description)  #4
➥ || { count: 0, amount: new Decimal(0) }; 
        stats.count += 1; #5
        stats.amount = stats.amount.plus(cleanAmount); #6
        countMap.set(entry.transaction_description, stats); #7
    });
    return countMap;
}

1 定义一个新的 BatchStatistics 映射

2 批次中的每个条目都将被处理。

3 从金额中移除格式

4 尝试获取描述条目;如果未找到,则初始化一个

5 增加计数

6 使用 Decimal 包的加法方法来维护金额

7 保存条目

我们已经准备好将返回的 BatchStatistics 添加到我们的组件中。我们通过一个名为 createListItems 的函数动态创建我们的 ListItem 元素,该函数接受我们的项目映射并返回一个 React 元素数组,如下所示列表所示。使用这种方法,我们可以创建所需数量的元素,并一次性返回它们。当然,我们需要小心不要创建太多元素,否则可能会创建出不会提供有意义用户体验的东西。

列表 8.43 createListItems 函数
…
interface BatchStatistics { #1
    count: number;  #1
    amount: Decimal;  #1
}  #1
…
function createListItems
➥(entries: Map<string, BatchStatistics>): React.ReactNode[] {
    const listItems: React.ReactNode[] = []; #2
    entries.forEach((entry: BatchStatistics, key: string) => {
        const listItem = (<ListItem key={key}> #3
            <ListItemText primary={key}   #3
                  secondary={`Count: ${entry.count} Amount:  #3
${formatCurrency(entry.amount)}`} />  #3
        </ListItem>); 
        listItems.push(listItem); #4
    });
    return listItems; #5
}

1 定义一个接口,以便我们可以轻松引用我们的数据

2 创建一个空的节点数组

3 创建一个 ListItem 元素

4 将其添加到数组中

5 返回数组

最后,我们可以为此组件添加一些单元测试。虽然我们可以从这种方法开始,但我们还希望展示在开发的其它阶段也可以添加单元测试。通常,我们在意识到我们需要额外的东西来处理返回的数据时添加这些单元测试。为了使我们的测试更容易,我们需要导出组件内使用的函数。这可能提供了重构代码以进一步从组件中移除逻辑的机会,但就目前而言,我们不会走那么远。

列表 8.44 computBatchTotals的 Jest 测试
…
describe('Compute Batch Totals', () => {
   it('Has a debit for 1', () => {
      const achBatchEntriesResponse: #1
➥ AchBatchEntriesResponse[] = [{  #1
         id: 'test1',  #1
         transaction_code: 27,  #1
         application: "Checking",  #1
         transaction_description: "Debit",  #1
         amount: "1.00",  #1
         individual_name: "John Doe",  #1
         account_number_last_4: "*********1234",  #1
         addenda_count: 0 #1
      }]  #1
      const result = computeBatchTotals #2
➥(achBatchEntriesResponse) 
      expect(result.debit.toNumber())   #3
                         .toBe(new Decimal("1").toNumber()) 
})

1 创建模拟数据

2 将其传递给我们的 computeBatchTotals 例程

3 验证结果

从那里,我们可以为相同的交易代码、不同的交易代码以及我们可能感兴趣测试的任何其他内容添加额外的条目。这是测试可能需要我们故意提供数据的边缘情况的好方法,特别是如果我们需要测试异常条件。有了这个基本测试,我们可以在进行下一个增强请求之前创建额外的测试并开始评估测试覆盖率。

8.8.3 添加应用程序

一个相对直接的要求是添加一个应用程序列,如图 8.6 所示。虽然从交易代码中确定应用程序以获得更好的用户体验是可能的,但我们可以在返回的数据中提供映射。

图 8.6

图 8.6 添加了列的批量输入屏幕

这种类型的请求使我们能够处理我们应用程序的所有部分,让我们再次体验使用 BDD 的全栈体验。我们可以创建一个名为ach_entries_endpoint.feature的特性文件,包含场景,如下所示。

列表 8.45 我们的特性文件和场景
Feature: The /files/{fileId}/batches/{batchId}/entries endpoint
  Test returning entries for a batch

  Scenario: I want to check the entries for a Checking Application
    Given that I have a clean database
    And that I have posted the file "ppd-single-credit.ach"
    When I request entries for a file and batch
    Then I should have a response that includes all
➥ applications of "Checking"

该场景与测试文件端点的方式有一些相似之处;然而,我们选择创建一个单独的步骤定义文件来支持这个功能。这个决定使我们能够将特定场景的步骤保持隔离,并赋予我们仅测试这个特定功能的能力。以下列表显示了支持运行该场景的一些步骤定义。

列表 8.46 我们特性的步骤定义
…
scenarios("../features/ach_entries_endpoint.feature")
…
@when('I request entries for a file and batch')
def request_unparsed_record(api_response):
    response = client.get("/api/v1/files/")
 assert response.status_code == 200, response.text
 file_id = response.json()[0]["id"]
 response = client.get(f"/api/v1/files/{file_id}/batches")
 assert response.status_code == 200, response.text
  batch_id = response.json()[0]["id"]
    response = client.get(f"/api/v1/files/{file_id}/
➥batches/{batch_id}/entries")
    assert response.status_code == 200, response.text
    api_response["response"] = response.json()

@then(parsers.parse('I should have a response
➥ that includes all applications of \"{application}\"'))
def response_that_includes_file(application, api_response):
    assert all(
        response["application"] == application for
➥ response in api_response["response"]
    ), f"Expected {application} in" f" {api_response['response']}"
…

在能够测试端点之后,我们现在可以着手确保应用程序将作为 API 的一部分返回。我们可以以几种不同的顺序处理这些步骤,但我们选择首先更新AchBatchEntriesResponse以包括应用程序字段,如下所示。这个字段不是来自数据库,并且它不是技术上的 ACH 文件布局的一部分。因此,我们将该字段的最大长度限制为我们计划拥有的最长的单词,即“checking”。

列表 8.47 更新的AchBatchEntriesResponse
…
application: str = Field(
   default=...,
   description="The application that the entry is associated with.",
   title="Application",
   max_length=8, #1
)
…

1 将此字段的长度设置为预期最长的文本

接下来,我们更新我们的查询,使用 CASE 语句根据交易代码返回应用程序名称,如列表 8.48 所示。如前所述,在应用程序命名方面有一些自由度。我们可能选择将支票和储蓄账户都归入“活期存款账户”(DDA)的名称下,这是金融行业使用的另一个术语。应用程序的实际名称将是业务提供给我们的名称。我们喜欢在应用程序名称中拥有更多粒度,因此我们选择了使用“储蓄”、“支票”、“GL”(总账),“贷款”和“未知”(用于识别我们可能遗漏的交易代码或数据库中不正确的交易代码)。

列表 8.48 更新查询以支持 transaction_code
CASE 
   WHEN aepd.transaction_code IN (31, 32, 33,
➥ 34, 36, 37, 38, 39) #1
      THEN 'Savings' #1
   WHEN aepd.transaction_code IN (21, 22, 23, #1
➥ 24, 26, 27, 28, 29)   #1
      THEN 'Checking' #1
   WHEN aepd.transaction_code IN (41, 42, 43, 46, 47, 48)   #1
      THEN 'GL' #1
   WHEN aepd.transaction_code IN (51, 52, 53, 55, 56 )  #1
      THEN 'Loan' #1
   ELSE 'Unknown' #1
END AS application,  #2

1 将交易代码映射到应用程序

2 将结果列命名为应用程序

最后,我们想要更新我们的 DataGrid 以添加列。由于服务器已经完成了所有繁重的工作,我们只需添加列并更新界面以包含应用程序即可。

列表 8.49 添加了列和字段
const columns: GridColDef[] = [
…
   { field: 'application', #1
 headerName: 'Application', #1
 width: 150}, 
…
export interface AchBatchEntriesResponse {
…
    application: string;
…
}

1 在我们的列定义中包含应用程序列

现在,我们应该能够导航到我们的仪表板,并深入查看应用程序列被填充。这个小例子是说明我们为什么喜欢全栈开发的一个很好的方式。我们能够从事单元测试、Python、API 和 Typescript 的工作!

8.8.4 使我们的 UI 更具响应性

目前,我们的仪表板组件是堆叠在一起的。我们为组件使用的 MUI 包提供了控制布局的选项,这将有助于使我们的仪表板更具响应性。我们用 Grid 容器和 Grid 项目替换了我们包裹组件的 Box 元素。此外,还有 xssmmdlgxl 等属性,可以根据屏幕大小提供断点。这意味着我们可以为所有不同的屏幕尺寸自定义我们的布局。以下列表显示了使用具有各种断点的网格的示例。当屏幕较小时,给定项目将显示为列,当展开时,每行将包含更多项目,当屏幕足够宽以容纳它们时,所有九个项目将位于单行中。

列表 8.50 响应式网格的示例
import React from 'react';
import { Paper, Typography } from '@mui/material';
import Grid from '@mui/material/Unstable_Grid2'; #1

const ResponsiveCards = () => {
    return (
        <Grid container spacing={2} sx={{pl: 1, pr: 1}}>  #2
            {[1,2,3,4,5,6,7,8,9].map((value) => ( #3
                <Grid xs={12} sm={6} md={4} 
➥lg={2} xl={1} key={value} > #4
                    <Paper elevation={3} sx={{ padding: 2 }}>
                        <Typography variant="h5" component="h3">
                            Card {value}
                        </Typography>
                        <Typography component="p">
                            This is some content
➥ inside card number {value}.
                        </Typography>
                    </Paper>
                </Grid>
            ))}
        </Grid>
    );
};
export default ResponsiveCards;

1 使用最新的网格元素

2 定义容器网格

3 定义了九个元素

4 网格元素的大小根据断点而不同。

在探索布局的响应性之后,我们可以将相同的方法应用到我们现有的组件上。使用 Grid 元素清理我们的一些现有布局,我们将纸张元素移动到我们的组件中,使得我们的 page.tsx 组件中只剩下布局元素(不包括 API 调用和其他需要的函数)。以下列表显示了批量详情屏幕的结果页面.tsx 的示例。

列表 8.51 批次详情的 page.tsx
return (
   <Box sx={{ display: 'flex' }}>
      <CssBaseline />
      <StandardNavigation />
      <Grid container spacing={{ xs: 2, md: 3 }} #1
            columns={{ xs: 4, sm: 8, md: 12 }}   #1
            sx={{mt: 8, pl: 1, pr: 1}}>  #1
         <Grid xs={12} sm={6} key={1} > #2
            <AchTransactionCodesPieChart entries={entries} />  #2
         </Grid> #B #2
         <Grid xs={12} sm={6} key={2} >  #2
            <AchBatchStatistics entries={entries}/>  #2
         </Grid>  #2
         <Grid xs={12} key={3} >  #2
            <Paper sx={{ minWidth: 1075 }}>  #2
               <Title>Batch Entries</Title>  #2
               <DataGrid columns={columns} rows={entries} />  #2
            </Paper>
         </Grid>
      </Grid>
   </Box>
);

1 定义一个容器

2 将我们的每个组件放置在网格元素中

断点也可以与其他属性一起使用。例如,如果我们想调整margin-top的量,我们可以根据屏幕大小设置它,如以下列表所示。注意,对于较小的屏幕,边距设置为较小的值 4,而对于较大的屏幕,则设置为 12。

列表 8.52 基于margin-top的屏幕大小
mt: {
   xs: 4,
   sm: 4,
   md: 12,
   lg: 12,
   xl: 12
},

断点提供了一种强大的方式来帮助我们进行布局和设计。我们可以更新每个断点的默认值(像素),甚至可以重命名它们,如下面的列表所示。

列表 8.53 自定义断点
const theme = createTheme({
  breakpoints: {
    values: {
      mobile: 0,
      tablet: 640,
      laptop: 1024,
      desktop: 1200,
    },
  },
});

本章涵盖了大量内容,重点是提供在上传格式不正确的文件时的反馈。异常处理提供了一种优雅地处理软件中意外情况的方法,给我们机会提醒用户并适当地恢复。

我们首先探讨了如何扩展我们的文件创建语法以支持添加这些异常。扩展生成文件的能力使得文件创建更加容易和健壮,这反过来又为我们提供了更多的文件可以加载到我们的仪表板中。

在 ACH 处理和 Pydantic 方面,我们关注了如何定义我们的字段为我们提供了验证,而无需大量代码。我们能够限制字段长度,使用正则表达式验证格式,甚至回数据库验证条目。我们还添加了对异常处理的支持,这让我们有机会再次体验全栈开发——通过更新后端,提供 API 以允许访问数据,最后提供 UI 元素来查看数据。

最后,我们有机会通过添加新组件、重构现有组件和查看如何使用 MUI 中的断点来操纵布局来扩展我们的 UI。

摘要

  • MVP 发布后,用户反馈突出了积极的接受和改进领域,特别是处理更广泛的 ACH 相关异常的需求。

  • 规划增强功能涉及根据用户反馈优先排序功能,并制定时间表以实现 ACH 异常和搜索功能。

  • ACH 处理中的异常可以分为错误、异常和警告,每种都有不同的严重性和处理含义。

  • 创建带有异常的 ACH 文件涉及扩展 BDD 语法以支持文件、批次和条目级别的异常,以实现更健壮的测试和处理。

  • 在 ACH 解析中增强异常处理包括使用 Pydantic 进行字段验证和在数据库中存储详细的异常信息。

  • 异常 API 开发侧重于启用有效的报告和搜索功能,提供对文件特定错误的洞察。

  • UI 已更新以显示异常,包括用于查看和过滤错误的组件,以及如 DataGrid 等用于交互式数据展示的功能。

  • 其他 UI 增强包括创建可重用组件、批次的统计数据,以及使用 MUI 网格系统改进布局响应性。

  • 利用单元测试和 BDD 有助于确保处理 ACH 异常时的健壮性和灵活性,同时 UI 更新提升了用户体验和数据交互。

第九章:搜索和审计

本章涵盖

  • 对我们的 ACH 仪表板的另一轮迭代

  • 为 ACH 交易添加搜索功能

  • 用户行为审计

在前面的章节中,我们构建了最小可行产品(MVP),经历了一个迭代过程,我们收到了反馈并利用它来推动下一组增强功能。本章将继续使用相同的概念。随着对异常处理和多种 UI/UX 调整的扩展支持发布,我们应该期待对这些功能和用户所需的其他功能反馈。

用户希望能够根据几个标准搜索交易,因为他们需要处理客户关于交易何时处理的投诉。此外,其他利益相关者要求能够审计与仪表板的交互,以衡量客户参与度并设置未来的用户跟踪。

9.1 迭代计划

在敏捷世界中,团队需要在开始任何工作之前为迭代做准备。我们已经有了关于这个迭代需要完成什么工作的想法,但还必须定义故事,以确切知道任何给定故事中期望的内容,并确定可能与之相关的任何验收标准。我们继续遵循我们故事的标准格式:“作为[角色],我[想要],[以便]”。例如,为了向我们的产品添加搜索功能,我们可能有一个类似这样的故事:“作为用户,我想要能够搜索交易,以便我能够更好地研究客户关于他们交易的询问”。请随意练习剩余任务中附加用户故事的措辞(即使只是在脑海中),我们将在本章中涵盖这些任务。

我们花点时间使用另一个 Gantt 图来可视化这些变化。同样,图表展示了我们变化的时序,可以向任何对项目感兴趣的利益相关者展示。根据我们的经验,这是在项目即将完成时与利益相关者沟通最有效的方法之一。通常,我们团队之外的利益相关者可能不熟悉更敏捷的进度跟踪方式,他们只想知道我们的代码何时可用。一个简单的 Gantt 图可以给他们一个关于工作进展情况的想法。以下列表显示了 PlantUML 中的 Gantt 图定义。

列表 9.1 PlantUML Gantt 图定义
@startgantt    #1
saturday are closed  #2
sunday are closed    

header ACH Dashboard Enhancements - Round 2  #3

Project starts 2024-08-15
-- Searching Enhancement --    #4
[Search API Design] starts 2024-08-15 and lasts 2 days   #5
[UI Design/Changes] starts at \   #5
            [Search API Design]'s end and lasts 4 days   #5

-- Auditing Enhancement -- #5
[Database Design] starts at 2024-08-15 and lasts 2 days #5
[Review APIs] starts at [Database Design]'s #5
➥ end and lasts 1 days #5
[Add Auditing logic] starts at [Review APIs]'s end #5
➥ and lasts 3 days #5
[UI Support for Auditing] starts at \   #5
            [Add Auditing logic]'s end and lasts 3 days  #5

[Sprint Complete] happens at 2024-08-28   #6

@endgantt  #7

1 开始 Gantt 图定义

2 定义应跳过的天数

3 我们图表的标题

4 定义搜索增强的时序;注意,长行可以使用反斜杠字符拆分为多行。反斜杠后面的任何尾随空格都可能导致解析错误。

5 定义审计增强的时序

6 定义里程碑

7 结束 Gantt 图定义

图 9.1 展示了生成的甘特图。它不仅为我们提供了我们在开发过程中的路线图,而且创建图表有助于我们开始思考任何给定增强的步骤和要求。

日历截图  自动生成的描述

图 9.1 甘特图显示时间框架

我们现在应该对需要采取的步骤有一个概念,可以立即开始工作。

9.2 搜索 ACH 交易

哪个文件包含了那笔交易?那笔交易是什么时候加载的?这是我们希望通过这次增强能够回答的问题类型。在这本书的整个过程中,我们一直关注预先安排的支付和存款(PPD)批次,其中包含了工资、公用事业费和抵押贷款支付等。如果客户的公用事业费处理不当,他们的水/电被切断,我们可以肯定我们会听到关于这件事的消息。当然,这是一个极端情况。更有可能的是,客户已经收到特定公司的通知,他们的付款尚未收到,他们必须支付滞纳金。无论如何,当客户打电话来时,他们都不会太高兴。

我们需要考虑添加在加载的文件中搜索名称和金额的能力。这个功能将需要更改 UI,因为我们需要能够输入搜索信息,并需要一个新 API 来支持实际的搜索和返回结果。我们当前的关注点是能够在我们的有限数据集中找到交易。在实践中,ACH 记录应从收据日期起保存六年,并且每年有数十亿笔支付通过 ACH 网络流动,因此我们可能会拥有大量的数据集需要搜索。在项目准备投入生产之前,我们需要评估其他搜索机制,除了我们的实时方法之外。

9.2.1 BDD 测试搜索

在组合一个功能以帮助验证新的(尚未编写的)搜索 API 之前,我们首先必须创建一个我们可以用于我们功能的文件。我们可以使用我们现有的文件创建功能(ach_file_creation.feature)并添加一个新的场景来为特定用户创建一个已知金额的文件。虽然我们可以在现有的文件中进行搜索,但将文件与功能分开以保持更清洁,这样我们的测试将更加稳健。以下列表展示了为顾客萨莉·萨弗创建一个文件,其中包含一笔单美元的交易,这应该足以让我们开始。

列表 9.2 为萨莉·萨弗创建文件
Scenario: Create an ACH file with a single transaction
➥ for customer "Sally Saver"
   Given I want to create an ACH file named "sally_saver.ach"
   And I want to have an immediate destination of "990000013"
   And I want to have an immediate origin of "987654321"
   And I want to have 1 batch with ACH credits only and
➥ a standard entry class code of "PPD"
   And I want 1 entries per batch with random amounts between 100 and 100
   And I want to use individual names of "Sally Saver"
   And I want to have company name "My Company" and company id "1234567890"
   When my ACH is created
   Then I should have a file of the same name
   And there should be 1 batch in the file
   And there should be 1 entries in the file

在创建完文件后,我们现在可以开始设置我们的 BDD 测试来搜索交易。这应该会让我们对整个过程的工作方式有一些了解。例如,我们知道我们想要能够通过至少个人名称和金额进行搜索。我们项目中的各种利益相关者可能需要讨论的一些问题可能包括

  • 我们是否将在 API 端将这些搜索分开(例如,允许customeramount的查询参数)?还是我们将允许一个搜索字段,并让后端尝试查询正确的字段?

  • 字段是否互斥?如果我们正在搜索客户,我们也能搜索账户吗?

  • 如果我们允许使用多个字段进行搜索,这些字段将使用 AND 还是 OR 组合?这应该由用户决定吗?

提出这些问题(以及它们的答案)将有助于指导我们的设计过程。目前,我们将采用一种方法,即向用户展示一个单独的搜索框,该搜索框将支持我们想要执行的任何搜索。我们还将把决定用户想要搜索什么的责任放在服务器端,而不是用户界面端。另一种方法是将 UI 分割字段,并允许 API 接受不同的查询参数。在考虑了我们的搜索方法后,我们可以创建一个场景来测试我们的 API 搜索,使用客户名称作为测试。

列表 9.3 按个人名称搜索
Scenario: Search by individual name
   Given that I have a clean database
   And that I have posted the file "sally_saver.ach"
   When I search for transactions with search criteria "Sally Saver"
   Then I should receive a list of "1"
➥ transaction with the amount of "1.00"

需要添加新的语法来支持WhenThen子句,以便支持我们的交易搜索。代码如下所示。

列表 9.4 更新 test_file_api.py
…
scenarios("../features/search_ach_transactions.feature") #1
…
@when(parsers.parse('I search for transactions #2
➥ with search criteria \"{search_criteria}\"')) 
def search_request_for_transactions(api_response, #3
➥ search_criteria):  #3
    response =  #3
 client.get(f"/api/v1/files/transactions/search?  #3
➥ criteria={search_criteria}")  #3
    assert response.status_code == 200, response.text #3
  api_response["response"] = response.json()  #3
…
@then(parsers.parse('I should receive a list of #4
➥ \"{transaction_count}\" transaction with the amount of #5
➥ \"{transaction_amount}\"')) 
def has_expected_transaction_and_amount(api_response, transaction_count, transaction_amount): #5
    assert len(api_response["response"]) == 1, f"Expected #5
{transaction_count} transaction, but got #5
➥ {len(api_response['response'])}"  #5
    response = api_response["response"][0]["amount"]  #5
    assert transaction_amount == response,  #5
➥ f"Expected {transaction_amount} in {response}"  #5

1 确保新场景已加载

2 使用@when 注解方法并解析行

3 定义一个函数来调用我们的方法并保存响应

4 使用@then 注解方法并解析行

5 定义一个函数来验证 API 响应的结果

现在我们有了测试背后的支持代码,如果我们运行它,我们应该期待看到什么?没错——会返回一个404 错误!这是可以预料的,因为我们还没有定义端点。在下一节中,我们将通过添加 API 来解决这个问题。

9.2.2 搜索 API

在这一点上,我们希望有一个 API,它从用户那里获取一些输入,然后在数据库中搜索交易。如前所述,我们只将搜索个人名称和金额。我们期望 API 使用一个名为criteria的单个查询参数被调用。API 将确定参数是否适用于个人名称或金额。

这并不是我们唯一可能采取的方法。我们还可以将搜索条件拆分为单独的字段,这样会使服务器端更容易一些,但可能从用户的角度来看搜索会更加繁琐。另一种方法是将指定搜索条件与关键词(例如,允许搜索如individual name = "user" and amount = $1.00)相结合。然后我们需要进行更多的解析以获取我们想要使用的字段。另一个考虑因素是我们要使用GET还是POST。如前所述,我们的示例使用GET,并传递一个查询参数,这是我们将会搜索的内容。这种方法是可行的,但带有查询参数的 URL 可能会出现在网络服务器、防火墙、应用程序和第三方日志中。如果我们启用搜索可能敏感的信息,如账户和税务识别号码,我们可能需要重新考虑这种方法,因为我们不希望这样的敏感信息出现在日志中。没有错误的答案——根据我们的受众和我们所期望的搜索内容,我们可能会被告知采用特定的方法。

我们首先构建一个简单的端点来处理当前返回的404 HTTP状态码。以下列表显示了端点的初始实现。

列表 9.5 搜索的初始端点定义
@router.get(   #1
    path="/transactions/search",  #1
    response_model=list[str],  #1
    summary="Retrieve ACH Transactions",  #1
    description=  #1
      "Retrieve the transactions matching #1
➥ the specified criteria.",  #1
    response_description="ACH Transactions.", #1
    response_model_exclude_none=True, #1
    tags=["ACH Files"], #1
) #1
async def get_ach_transactions(criteria: str = Query(..., #2
  description="Search criteria for transactions")) #2
➥ -> list[str]: 
    return [f"ACH Transactions for {criteria}"] #3

1 定义带有支持文档的 GET 请求

2 定义指定查询参数的方法

3 返回包含查询参数的硬编码数组条目

虽然这段代码可能帮助我们绕过404 错误,但它并没有为我们创建一个有用的端点做出太多贡献。为此,我们需要构建 SQL 查询和返回数据的响应对象。我们首先创建一个TransactionSearchResponse类来存储我们的数据。以下列表显示了TransactionSearchResponse和预期的字段。如果我们发现我们缺少某些内容,我们总是可以回来添加。

列表 9.6 TransactionSearchResponse
from decimal import Decimal   #1
from typing import Annotated #1
from pydantic import BaseModel, Field, UUID4

class TransactionSearchResponse(BaseModel): #2
    file_id: UUID4 = Field( #3
        ..., description=  #3
➥"Unique identifier for the ACH file.",  #3
        title="File ID"  #3
    )  #3
    batch_header_id: UUID4 = Field(  #3
        ...,  #3
        description=  #3
➥"Unique identifier for the ACH Batch Header.",  #3
        title="Batch Header ID",  #3
    )  #3
    entry_id: UUID4 = Field(  #3
        ...,  #3
        description=  #3
➥"Unique identifier for the ACH Entry Detail.",  #3
        title="Entry Detail ID",  #3
    )  #3
    filename: str = Field(  #3
        ..., description="The name of the ACH file.",   #3
        title="Filename", max_length=255 #3
    )  #3
    individual_name: str = Field(  #3
        ...,  #3
        description=  #3
➥"The name of the individual or company for the entry.",  #3
        title="Individual Name",  #3
        max_length=22,  #3
    )  #3
    amount: Annotated[  #3
        Decimal,  #3
        Field(  #3
            ...,  #3
            description="The amount of the entry.",  #3
            title="Amount",  #3
            max_digits=10,  #3
            decimal_places=2,  #3
        ),  #3
    ]  #3
 #3
    class Config:  #3#4
        json_schema_extra = { 
            "example": { 
                "file_id": 
➥"123e4567-e89b-12d3-a456-426614174000", #5
  "record_type_5_id": 
➥ "123e4567-e89b-12d3-a456-426614174001", 
                "record_type_6_id":  
➥"123e4567-e89b-12d3-a456-426614174002", 
                "filename": "test.ach", 
                "individual_name": "John Doe", 
                "amount": "100.00", 
            } 
        } 

1 我们的标准导入语句用于 Pydantic 字段

2 使用 Pydantic 的 BaseModel 作为基类定义类

3 定义我们类的各种字段和约束

4 提供用于文档的响应对象示例

5 提供用于文档的响应对象示例

现在我们有了存储响应的地方,我们需要填充它。我们通过使用一个新的类TransactionSearchSql来完成,这个类将存储任何与交易搜索相关的代码。我们希望在我们的第一次搜索尝试中保持简单,所以我们处理以下三种情况:

  • 通过单个金额进行搜索

  • 通过金额范围进行搜索

  • 通过个人名称进行搜索

我们创建了一个名为 get_transactions 的方法,如下所示。在这里,我们有两个正则表达式来确定是否传递了金额或使用了金额范围。否则,我们默认使用名称。对于大多数人来说,代码中最棘手的部分可能是正则表达式的使用。

列表 9.7 get_transaction 方法
def get_transactions(self, criteria: str) -> #1
   list[TransactionSearchResponse]: 
   amount_pattern = r"^\d+\.\d{2}$" #2
   multiple_amounts_pattern = #3
➥ r"(^\d+\.\d{2})\s+(\d+\.\d{2})$" 
   if re.match(amount_pattern, criteria): #4
      return self._get_transactions_using_amount(criteria) 
   elif match := re.match(multiple_amounts_pattern,  #5
➥ criteria): 
      begin_amount, end_amount = match.groups() #6
      return self._get_transactions_using_amount_range #7
➥(begin_amount,➥ #8
 end_amount) 
   else: #8
      return self._get_transactions_using_➥ #9
➥individual_name(criteria) 

1 定义了一个返回交易列表的函数

2 匹配单个金额的模式

3 匹配两个金额的模式

4 将标准与单个金额进行比对并调用相应的方法

5 使用 walrus 运算符存储 multiple_amounts_pattern 的匹配结果

6 使用 match 的 groups() 方法检索金额

7 使用检索到的参数调用方法

8 否则,没有匹配的金额,我们应该使用单个名称。

在继续之前,让我们分解正则表达式以确保你理解它们的用法。以下是一些重要点:

  • 每个模式字符串都以前缀 r 开头,表示原始字符串,这防止了我们不得不转义任何字符串。否则,我们不得不使用 \\ 而不是 \

  • 字符 ^$ 是输入边界断言,分别表示字符串的开始和结束。这些帮助我们消除包含我们感兴趣模式之外字符的匹配字符串。

  • \d 匹配任何数字(0–9),而 + 是一个量词,表示匹配前一个原子(正则表达式的最基本单元)的一个或多个出现。

  • \. 匹配实际的点字符,因为单个点否则会匹配任何字符。

  • \d 匹配任何数字(0–9),而添加 {2} 是另一个量词,指定要匹配的原子数量。

  • \s 匹配任何空白字符。同样,我们看到加号量词,它允许两个金额之间至少有一个字符,并且可以有任何数量的空白字符。

  • 将正则表达式的一部分括起来创建一个捕获组。然后,你可以稍后引用这些组,正如我们在使用 match.groups() 提取找到的值时所看到的那样。

正则表达式不仅限于金融行业,你将在整个职业生涯中找到它们。虽然它们可能难以掌握,但生成式 AI 工具是帮助分解正则表达式并提供更多见解的绝佳方式。熟能生巧,为了获得更多正则表达式的实践经验,你可能想查看 David Mertz 的 正则表达式谜题和 AI 编码助手(2023,Manning)。

在此基础上,让我们看看根据提供的搜索条件实际检索交易的函数。在我们的案例中,查询本身保持不变,只是每个查询的 Where 子句是唯一的。以下列表显示了 _get_transactions_using_amount 函数,该函数查找精确金额并返回任何结果。

列表 9.8 通过金额检索事务
    def _get_transactions_using_amount(
        self, criteria: str
    ) -> list[TransactionSearchResponse]:
        with get_db_connection(
            row_factory=class_row(TransactionSearchResponse) #1
        ) as conn:
            sql = self.get_sql_selection_query() #2
            sql += "WHERE aepd.amount = %s" #3
            result = conn.execute(sql, [criteria]) #4
            return result.fetchall() #5

1 我们声明该行将为 TransactionSearchResponse 类型。

2 我们试图选择的常规列

3 为查询添加 Where 子句

4 使用我们想要传递给查询的标准

5 获取所有行并返回响应

我们已经将 SQL 查询的公共部分提取到 get_sql_selection_query 方法中,这使得可以在查询中添加 Where 子句。当通过金额搜索事务时,我们使用了精确匹配。当使用金额范围时,我们使用 Postgres 语法并使用关键字 BETWEEN

列表 9.9 使用 BETWEEN 关键字
sql += "WHERE aepd.amount BETWEEN %s AND %s" #1

1 使用 BETWEEN 在两个金额之间进行搜索;通常比多个条件更简洁、更易读

类似地,当搜索单个名称时,我们使用 ILIKE 关键字,如列表 9.10 所示。使用 ILIKE 允许我们进行不区分大小写的搜索——否则,我们是在搜索相同的字符串。请注意,ILIKE 是 Postgres 特有的命令。其他数据库如 MySQL 使用 LIKE 关键字,但默认情况下不区分大小写。仍然,其他数据库如 Oracle 是区分大小写的,并且需要在比较中使用 UPPER 函数来实现不区分大小写的搜索。所以,始终要意识到你正在使用的数据库。

我们可以考虑在标准中添加一个百分号(%),这是一个 SQL 通配符,如果其后有任何内容出现,它将匹配单个名称。或者,我们可能可以将字符串中找到的任何空格替换为通配符以扩展我们的搜索结果。如果需要额外的搜索功能,这些只是基本起点。

列表 9.10 不区分大小写的搜索
sql += "WHERE aepd.individual_name ILIKE %s" #1

1 使用 ILIKE 对单个名称进行不区分大小写的搜索。

以下列表显示了检索事务信息的查询的公共部分。除了需要将记录连接起来以深入事务细节之外,我们还重命名了字段以确保它们可以存储在我们的响应对象中。

列表 9.11 常见事务查询选择标准
def get_sql_selection_query(self):
   return """
          SELECT art1.ach_files_id AS file_id, #1
                 art5.ach_records_type_5_id AS batch_header_id,  #2
                 art6.ach_records_type_6_id AS entry_id,   #2
                 af.file_name AS filename,   #2
                 aepd.individual_name AS individual_name,  #2
                 aepd.amount AS amount   #2
            FROM ach_files af #2
      INNER JOIN ach_records_type_1 art1 #2
➥ USING ( ach_files_id )  #2
      INNER JOIN ach_records_type_5 art5 #2
➥ USING ( ach_records_type_1_id )  #2
      INNER JOIN ach_records_type_6 art6 #2
➥ USING ( ach_records_type_5_id )  #2
      INNER JOIN ach_entry_ppd_details aepd #2
➥ USING ( ach_records_type_6_id )  #2
   """

1 我们对象所需的选取标准。字段前缀有助于确定它们所属的文件。当名称唯一时,这并不总是必要的,但对于维护代码是有帮助的。

2 连接各种记录

当查询运行并返回我们的数据时,我们现在应该看到所有测试都已通过。接下来,我们看看如何将搜索功能添加到我们的用户界面中。

9.2.3 UI 搜索页面

要将搜索功能添加到我们的页面中,我们首先在侧边栏导航中添加一个图标。这意味着我们可以更新 NavButtons.tsx 来添加一个带有图标的按钮。

列表 9.12 更新 NavButtons.tsx
…
import {CloudUpload, Logout, Error, Search} from "@mui/icons-material"; #1
…
            <ListItemButton onClick={() => route.push("/search")}>
                <ListItemIcon>
 <Search/> #2
                </ListItemIcon>
                <ListItemText primary="Search"/>
            </ListItemButton>
…

1 导入搜索图标

2 包含搜索图标

当然,如果我们尝试点击按钮,我们会收到一个404 NOT FOUND错误消息,因为我们还没有定义我们的实际页面。与之前的页面一样,我们将创建一个简单的页面,这将允许我们解决404错误消息,并为我们开发页面的其余部分提供一个起点。以下列表显示了src/app/search/page.tsx

列表 9.13 搜索页面草稿
export default function SearchPage() { #1

    return (
        <ThemeProvider theme={defaultTheme}>
            <Box sx={{display: 'flex'}}>
                <CssBaseline/>
                <StandardNavigation/>
                <Box
                    component="main"
                    sx={{
                        backgroundColor: (theme) =>
                            theme.palette.mode === 'light'
                                ? theme.palette.grey[100]
                                : theme.palette.grey[900],
                        flexGrow: 1,
                        height: '100vh',
                        overflow: 'auto',
                    }}
                >
                    <Toolbar />
 <Typography>Search Page</Typography> #2
                </Box>
            </Box>
        </ThemeProvider>
    );
}

1 创建一个简单的搜索页面

2 用来识别页面,以便我们知道我们已经导航到该页面

在我们的页面草稿完成后,我们可以点击搜索按钮,应该会看到我们的导航和搜索页面。现在,我们可以添加必要的组件,以创建一个能够显示结果的搜索页面。最难的部分是决定如何布局页面。从之前的 API 调用(例如第八章中返回的异常),我们已经知道如何从 API 获取数据并在 DataGrid 组件中显示它。我们将遵循相同的步骤:定义一个用于存储数据的对象,一个用于显示数据的组件,然后将它添加到我们的搜索页面中。

我们首先定义一个接口AchTransactionSearchResponse来存储 API 响应将传递回的数据。以下列表显示了字段及其数据类型。

列表 9.14 AchTransactionSearchResponse 接口
export interface AchTransactionSearchResponse { #1
    file_id: string;  #2
    batch_header_id: string;  #2
    entry_id: string;  #2
    filename: string;  #2
    individual_name: string;  #2
    amount: string;  #2
}

1 导出AchTransactionSearchResponse使其在所有地方可用

2 API 返回的字段

一旦我们有一个地方来存储检索到的数据,我们就可以创建一个组件来显示这些数据。这个AchTransactionSearchResults组件将类似于我们创建的其他组件,我们将通过AchTransactionSearchResultsProps的结果对象将 API 调用的结果传递给组件。我们利用列定义上的renderCell属性添加链接,允许用户直接跳转到特定搜索结果的文件或批次。此外,由于我们的结果没有 ID 列,我们必须使用getRowId属性在 DataGrid 组件中定义一个。entry_id可以作为我们的 ID 列,因为对于每个结果,entry_id是交易的 UUID,它是唯一的。以下列表显示了组件的更重要的部分。

列表 9.15 AchTransactionSearchResults 组件
interface AchTransactionSearchResultsProps {
    results: AchTransactionSearchResponse[];
}

export default function AchTransactionSearchResults #1
➥({results}:  #1
Readonly<AchTransactionSearchResultsProps>) {  #1

    const route = useRouter(); #2

    const columns: GridColDef[] = [
        {field: 'filename', headerName: 'Filename', #3
➥ width: 150}, 
        …
        {field: 'viewFile', headerName: '',  #4
                 sortable: false, width: 150,   #4
                 renderCell: (params) => (   #4
            <Link onClick={() =>  #4
                route.push(`/fileDetails/${params.row.file_id}`)}  #4
                sx={{ cursor: 'pointer' }}>Jump to file...</Link>  #4
            )},  #4
        {field: 'viewBatch', headerName: '', #5#6
                 sortable: false, width: 150,   #6
                 renderCell: (params) => (  #6
            <Link onClick={() =>   #6
route.push(`/fileDetails/${params.row.file_id}/   #6
➥batchDetails/${params.row.batch_header_id}`)}   #6
              sx={{ cursor: 'pointer' }}>Jump to batch...</Link>  #6
            )}  #6
    ];

    return (
…
       <DataGrid columns={columns} 
                 rows={results} 
                 getRowId={(row) => row.entry_id} #7
       />
…
    );
}

1 导出搜索结果函数并声明参数为只读

2 使用路由进行导航目的

3 开始定义要显示的列

4 定义一个列,该列将创建一个链接,点击后将导航到文件详情页面

5 定义一个列,该列将创建一个链接,点击后将导航到批量详情页面

6

7 由于没有名为 id 的具体参数,我们指定哪个字段可以使用。

现在我们有了存储数据的地方和展示数据的方式,剩下要做的就是调用 API 并将其传递给我们的组件。为此,我们更新了我们的搜索页面,添加了一个文本输入字段和一个搜索按钮来执行实际的 API 调用。我们使用 TextFieldonChange 事件将用户输入的文本存储到 searchCriteria 字段中。当用户点击搜索按钮时,将触发 onClick 事件,并执行 handleSearchhandleSearch 调用我们的 API 并存储结果。更新的搜索页面的重要部分如下所示。

列表 9.16 更新的搜索页面
…
export default function SearchPage() {
    const [searchCriteria, setSearchCriteria] = #1
➥ useState<string>(''); 
    const [results, setResults] = #2
useState<AchTransactionSearchResponse[]>([]); 

    const handleChange = (event: { target: { value: #3
React.SetStateAction<string>; }; }) => {  #3
        setSearchCriteria(event.target.value);  #3
    };  #3

    const handleSearch = async () => { #4
        …        axios.get<AchTransactionSearchResponse[]> #5
➥(`${apiUrl}/files/transactions/search?criteria=${searchCriteria}`, { 
…
    return (
…
       <Box sx={{display: 'flex', 
                 flexDirection: 'column', 
                 alignItems: 'center', gap: 2}}>
          <TextField id="search" #6
                     label="Search"  #6
                     variant="standard"   #6
                     onChange={handleChange}   #6
                     sx={{ width: '40%' }} />  #6
          <Button variant="outlined" #7
                  color="primary" onClick={handleSearch}>  #7
             Search #7
          </Button> 
       </Box>
       <AchTransactionSearchResults results={results}/> #8
…
    );
}

1 将搜索标准初始化为空字符串

2 将结果初始化为空字符串

3 handleChange 函数将输入的文本保存以供以后使用。

4 handleSearch 函数调用 API 并使用 setResults 保存结果。

5 使用 axios 获取响应

6 定义一个用于用户输入搜索字符串的 TextField 元素

7 定义一个将搜索输入字符串的按钮元素

8 一个用于显示结果的组件

现在我们有一个可以执行基本交易搜索并提供直接跳转到找到交易的文件或批次的搜索页面。接下来,我们通过 Playwright 加强测试,以获取我们搜索功能的一些集成级别测试。

9.2.4 使用 Playwright 进行集成测试

在我们使搜索我们的交易成为可能之后,添加一个集成测试将是一个很好的选择,我们可以用它来进行集成和回归测试。我们设置了一个测试,需要我们

  1. 上传文件以解析

  2. 导航到搜索页面

  3. 填充搜索页面

  4. 等待 ACH 响应

  5. 验证结果是否已填充

  6. 拍摄屏幕截图

我们创建了一个名为 test_search_page.py 的 Python 脚本来执行所有这些必要的步骤。此外,我们希望有选项可以在浏览器窗口显示的情况下调试我们的测试;为了实现这一点,我们需要创建一些 Pytest 固定装置。

列表 9.17 Playwright 的 Pytest 固定装置
@pytest.fixture(scope="session") #1
def browser(): #2
    with sync_playwright() as p:  #3
        browser = p.chromium.launch(headless=False) 
        yield browser #3
        browser.close() #4

#5
@pytest.fixture(scope="function") 
def page(browser): #6
  context = browser.new_context()  #7
 pages = context.pages
 page = pages[0] if pages else context.new_page() #7
  yield page #8
    context.close() #9

1 定义一个具有会话范围的固定装置,这将导致固定装置在测试会话期间持续存在

2 定义一个名为 browser 的函数,用于创建浏览器实例。我们使用 headless=False 以逐步进行测试会话并查看浏览器正在做什么。使用 headless=True 将隐藏浏览器窗口,这是运行单元测试的默认方式。

3 yield 浏览器命令将返回浏览器对象。

4 当所有测试完成后,关闭浏览器。

5 定义一个具有函数范围的固定装置,这是默认范围

6 创建一个用于测试的页面

7 如果存在,则使用默认选项卡;否则,创建一个新的选项卡

8 使用 yield 返回页面,以便在测试中可用

9 测试运行后清除上下文

这就足够设置 Playwright 结构了。现在,我们想要确保我们的数据库为空,并在开始集成测试之前加载我们的 ACH 文件。

列表 9.18 清空数据库并加载我们的文件
@pytest.fixture(autouse=True) #1
def setup_teardown_method():
    SqlUtils.truncate_all() #2

    ach_file = "./data/sally_saver.ach" #3
    file_path = get_absolute_path("./data/sally_saver.ach")  #3
    parser = AchFileProcessor()  #3
    ach_files_id = SqlUtils.create_ach_file_record #3
➥(ach_file, str(randint(1, 99999999)))  #3
    parser.parse(ach_files_id, file_path)  #3
    yield #3

def get_absolute_path(relative_path): #4
    return current_file_dir / relative_path

1 定义一个设置为 autouse 为 True 的 fixture。这会导致它默认包含在每个测试中。

2 清空我们的数据库

3 将测试文件加载到数据库中

4 确保路径正确,基于我们从哪里运行

现在设置完成,我们可以开始使用 Playwright 进行实际测试。

列表 9.19 Playwright 测试
def test_dashboard(page: Page):
    page.goto("http://localhost:3000/search") #1
    page.expect_navigation(wait_until="load") 
    expect(page).to_have_url("http://localhost:3000/search") #2
    search_criteria = page.get_by_role("textbox") #3
    search_criteria.fill("sally saver") 
    search_button = page.locator("#searchbtn") #4
    with page.expect_response("**/files/transactions/search*") #5
➥ as response_info:  #6
        search_button.click() 

    response = response_info.value #6
    assert response.status == 200
    search_result = page.locator('div[title="Sally Saver"]') #7
    expect(search_result).to_be_visible() 
    page.screenshot(path="screenshots/search_results.png") #8

1 导航到搜索屏幕并确保页面已加载

2 验证我们是否在正确的页面上

3 找到文本框并输入文本“sally saver”

4 找到搜索按钮

5 预期当点击搜索按钮时会有 API 响应

6 检查响应状态码为 200

7 我们应该能够看到一个包含文本“Sally Saver”的条目。

8 为永存性拍摄屏幕截图

现在你已经知道了如何执行一些集成测试,你可能需要调用 API 并验证结果。在下一节中,我们将探讨许多行业中开发的一个关键方面:保持审计跟踪。

9.3 审计用户交互

互联网安全中心提出的几个关键安全控制之一是审计日志管理(mng.bz/gaGE)。虽然我们不会涵盖所有方面,但我们将至少从确保我们收集有关应用程序使用情况的数据开始。记住,收集数据是第一步。如果我们没有监控、审查和必要时接收警报的策略,日志就毫无用处。

许多时候,我们解决过的问题最初是因为有人识别到日志中发生错误而被引入了错误的方向。进一步的研究表明,错误已经发生数月(远至日志容易获取的时候)并且不是当前问题的源头。有时,在生产的紧急事件中,由于需要快速解决问题,你很容易陷入这种陷阱。通过适当的审计日志管理,你可以更好地理解基线应用程序行为,并监控应用程序滥用,以及其他许多好处。

尽管有许多现成的商业工具(COTS)如 Splunk、Dynatrace、DataDog 和 Sentry 可以帮助进行日志记录和可观察性,但我们主要关注将数据记录到数据库中,并在必要时从那里扩展。

9.3.1 数据库设计

如果我们想要开始进行审计,我们需要创建一个新的数据库表来存储我们的数据。这将基于您的应用程序需求以及您试图实现的目标。在我们的案例中,我们将跟踪进入系统的 API 请求。我们引入了一种新的数据类型来处理主机和网络——ip_address字段的INET数据类型。虽然使用字符串数据类型对于ip_address来说当然也可以工作,但 Postgres 提供了处理INET的额外比较和函数,这可以使生活更加轻松。例如,我们可以通过使用WHERE ip_address << '192.168.1.0/24'来搜索给定范围内的所有地址。如果我们想查看来自我们怀疑滥用我们应用程序的地址列表的活动,这种搜索可能会有所帮助。以下列表显示了表的创建。

列表 9.20 audit_log
CREATE TABLE audit_log ( #1
    audit_log_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    created_at TIMESTAMP NOT NULL DEFAULT NOW(), #2
    user_id VARCHAR(25) DEFAULT NULL,
    ip_address INET DEFAULT NULL, #3
    user_agent VARCHAR(255) DEFAULT NULL,
 http_request VARCHAR(10) DEFAULT NULL,
  url VARCHAR(255) DEFAULT NULL,
    message TEXT NOT NULL
);

1 创建名为 audit_log 的表

2 当记录使用 TIMESTAMP 数据类型创建时保存。我们还使用 NOT NULL,以确保字段必须始终被填充。最后,我们默认使用 Postgres 中的 NOW()函数,该函数将在记录插入时填充字段,如果没有提供值。

3 创建一个名为 ip_address 的字段,其数据类型为 INET,这是一个方便处理网络地址的数据类型

在表就绪后,我们可以创建编写这些审计记录所需的代码。

9.3.2 为审计进行的 TDD 测试

我们首先为 API 创建单元测试,然后添加必要的代码以使测试通过。我们也应该考虑 BDD 风格的测试吗?这个答案取决于业务如何选择使用审计日志。如果日志主要作为开发者使用的东西,那么业务可能不需要与我们合作来开发测试。相反,如果业务计划使用审计日志来拉取活动以帮助确定指标,我们可能需要与他们合作来开发测试。我们可以很容易地看到业务提出那种类型的要求,并需要更新我们的测试以读取类似WHEN I REQUEST A LIST OF FILES THEN I SHOULD SEE AN AUDIT LOG RECORD INDICATING FILES WERE SHOWN的内容。现在,我们将坚持我们的标准单元测试。单元测试定义了一个记录然后检索它,以便我们可以比较字段。我们目前有一个user_id字段,但由于我们目前没有跟踪用户,我们将留空。

列表 9.21 审计日志的单元测试
class TestAuditLog:
    @pytest.fixture(autouse=True) #1
    def setup_teardown_method(self):  #1
        SqlUtils.truncate_all()  #1
        yield #1

    def test_insert_record(self): 
        log_record = AuditLogRecord( #2
            ip_address="192.168.1.0/24",  #2
            user_agent="Mozilla/5.0",  #2
            http_request="GET",  #2
            http_response=200,  #2
            url="/api/v1/files",  #2
            message="Test audit log insert",  #2
        )  #2
        logger = AuditLog() #3
        audit_record_id = logger.log_record(log_record)  #3
        retrieved_record =   #3
➥logger.get_log_record(audit_record_id)  #3
        excluded_fields = {"audit_log_id", "created_at"} #4
        assert SqlUtils.get_row_count_of_1("audit_log") is True
➥, "Expected 1 record" 
        assert retrieved_record.dict(exclude=excluded_fields) 
➥ == #D
     log_record.dict( 
            exclude=excluded_fields
        ), f"Expected {log_record}, 
➥ but got {retrieved_record}" 

1 每次清除数据库

2 创建一个虚拟记录

3 使用 AuditLog 类创建和检索记录

4 从字典中排除指定的字段,因为这些字段由数据库填充且不影响比较;然后断言记录匹配

单元测试需要一个 AuditLogRecord 对象,这是一个标准的 Pydantic 模型,用于存储我们的数据。在这个模型中,我们使用了两种新的数据类型——IPvAnyNetworkAnyUrl(两者都可作为从 Pydantic 导入的导入项)。IPvAnyNetwork 对于表示我们在表中定义的 INET 字段效果很好,并且对于 IP 地址提供了预期的验证,无论它们是单个地址(例如,127.0.0.1)还是一系列地址(例如,192.168.1.0/24)。当然,我们不会期望在这个字段中看到地址范围。AnyUrl 字段类似地提供了对 URL 的验证,使我们免于编写正则表达式来验证字段。列表 9.22 显示了我们的 AuditLogRecord 类,为了简洁起见,删除了一些不太有趣的字段。

列表 9.22 AuditLogRecord
class AuditLogRecord(BaseModel):
…
    ip_address: Optional[IPvAnyNetwork] = Field( #1
        None,
        title="IP Address",
        description="IP address from which the request originated.",
    )
…
    url: Optional[AnyUrl] = Field( #2
        None, 
        title="URL", 
        description="URL accessed by the request.", max_length=255
    )
…
    @field_validator("url", mode="before") #3
    @classmethod #3
    def convert_url_to_str(cls, v: AnyUrl) -> str:  #3
        return str(v) 

1 将 ip_address 定义为 IPvAnyNetwork 类型,这允许 Pydantic 验证 IP 地址和网络

2 将 URL 定义为 AnyUrl 类型,这允许 Pydantic 验证 URL 是否格式正确

3 定义了一个字段验证器,以便将 URL 转换为字符串以存储在数据库中

如列表 9.22 所示,我们也使用字段验证器将 URL 字段作为字符串返回。这是因为当我们尝试将其插入数据库时,我们会收到错误,因为 URL 数据类型与字符串不兼容。我们可以在负责插入记录的审计日志类中明确编码它,但这种方法可以很好地封装一切,其他开发者不必担心它,这有助于减少认知负荷。

能够写入记录并使这个单元测试通过的最后一步是生成一个实际要插入数据库的类。在以下列表中,我们创建了一个包含 log_recordget_log_record 方法的 AuditLog 类。对于其他数据库记录,我们特别使用了 Sql 作为名称的一部分。在这种情况下,我们并不一定想将其绑定到数据库。

列表 9.23 AuditLog
class AuditLog:
    @staticmethod
    def log_record(log_record: AuditLogRecord):
        with get_db_connection(row_factory=dict_row) as conn: #1
            log_record_dict = log_record.dict()  #1
            log_record_dict["url"] = str(log_record_dict["url"]) 
            result = conn.execute( #2
                """  #2
           INSERT INTO audit_log (user_id, ip_address,  #2
                       user_agent, http_request, http_response,  #2
  url, message)  #2
           VALUES (%(user_id)s,  #2
                %(ip_address)s,  #2
                %(user_agent)s,  #2
                %(http_request)s,  #2
                %(http_response)s,  #2
                %(url)s,  #2
                %(message)s #2
                )  #2
           RETURNING audit_log_id #2
            """,  #2
                log_record_dict,  #2
            )  #2
 #2
        return result.fetchone()["audit_log_id"] #3

    @staticmethod
    def get_log_record(audit_log_id: str):
        with get_db_connection(row_factory=
➥class_row(AuditLogRecord)) as conn:
            result = conn.execute(
                """
                SELECT *
                FROM audit_log
                WHERE audit_log_id = %(audit_log_id)s
                """,
                {"audit_log_id": audit_log_id},
            )

        return result.fetchone()

1 定义了一个要使用的字典

2 定义了要插入的字段;我们将返回从数据库中获得的 audit_log_id。

3 因为使用了 dict_row 作为 row_factory,我们可以直接引用 audit_log_id。

在此基础上,我们的单元测试通过了,我们可以在任何想要的地方添加审计记录。现在,让我们看看如何将我们所学的内容与 FastAPI 框架集成。

9.3.3 审计逻辑

向我们的应用程序添加审计的最明显方式是通过调用 log_record 方法并传递一个 AuditLogRecord,正如我们在单元测试中所看到的。我们当然可以取我们的现有端点之一并添加调用,最终得到以下类似列表的内容,这允许我们写入消息,但并没有捕获我们为定义的字段所定义的一些其他可能有用的信息。

列表 9.24 添加我们的 API 记录的一种方式
async def read_files() -> list[AchFilesResponse]:
    AuditLog().log_record( #1
        AuditLogRecord(  #1
            message="User retrieved ACH files -test"  #1
        )  #1
    )  #1
    return AchFileSql().get_files_response()

1 在 API 执行时手动调用 log_record 方法,但我们遗漏了许多有趣的字段

我们可以通过包括请求字段来增强前面的列表。然后我们可以从请求对象中提取额外的信息。

列表 9.25 在我们的 API 中包含请求信息
async def read_files(request: Request) -> #1
➥ list[AchFilesResponse]: 
    AuditLog().log_record(
        AuditLogRecord(
            ip_address=request.client.host, #2
            message="User retrieved ACH files -test"
        )
    )
    return AchFileSql().get_files_response()

1 更新方法以包括请求对象

2 我们现在可以访问一些有趣的字段

虽然这样可行,但它要求开发者添加比我们更愿意的更多样板逻辑。由于我们主要对审计 API 的使用感兴趣,我们可以采取另一种方法。

9.4 使用中间件进行日志记录

为了记录 API 请求,我们可以考虑添加一个具有唯一日志记录请求目的的中间件函数。我们可以使用请求和响应,这使我们能够捕获响应代码。虽然我们也可以考虑配置我们的 Nginx 日志以捕获生产环境中的信息,但了解如何在 FastAPI 中实现这一点也是有帮助的,因为它可以广泛应用于其他需求。我们使用@app.middleware注解来编写我们的日志记录。

列表 9.26 日志记录的中间件层
@app.middleware("http") #1
async def log_requests(request: Request, call_next): #2
    response = await call_next(request) #3
    log_message = getattr(request.state, 'log_message', #4
                  "Default audit log message") 
    log_record = AuditLogRecord( #5
        ip_address=request.client.host,  #5
        user_agent=request.headers.get #5
➥('user-agent', 'unknown'),  #5
        http_request=request.method,  #5
 http_response=response.status_code, #5
 url=str(request.url), #5
 message=log_message, #5
  )
    logger = AuditLog() #6
    logger.log_record(log_record) 
    return response #7

1 定义了一个中间件组件,该组件为每个 HTTP 请求执行

2 定义一个日志请求的方法

3 将请求传递给下一层

4 获取在请求.state 中定义的 log_message,如果不存在则默认

5 使用我们期望的请求字段构建一个 AuditLogRecord

6 编写日志消息

7 返回响应

插入该中间件层并使其执行日志记录相对容易。然后我们可以记录所有传入的调用,这消除了样板代码的需求。此外,它使我们的代码更简洁,让开发者专注于他们的代码。唯一的缺点是添加消息需要更多的努力。当然,消息将反映REST API调用正在做什么。因此,从某种意义上说,记录消息是多余的(并且是空间的浪费)。例如,当日志显示他们在/api/v1/files上执行GET操作时,我们是否需要知道有人查看 ACH 文件?当然,我们可能需要出于业务原因这样做,因为当被审查时,对于不熟悉数据的人来说,解释消息更容易。与其浪费存储空间,我们可能决定有一个实用方法,可以在提取期间执行该解释并插入适当的消息。用例可能各不相同,我们只想说明一些替代方案。在这种情况下,我们确实想在数据库表中存储文本,我们将使用装饰器来完成这项工作。以下列表显示了如何使用我们定义的log_message注解在请求状态中包含消息。

列表 9.27 log_message注解
def log_message(message: str):
    def decorator(func):
        @wraps(func)
        async def wrapper(*args, **kwargs):
            request: Request = kwargs.get("request", None)
            if request: #1
                request.state.log_message = message
            return await func(*args, **kwargs)

        return wrapper

    return decorator

1 如果有请求,它将保存传递的消息。

最后,我们只需要对 API 进行一些小的更改。通过使用我们新的注解,我们可以自定义写入数据库的消息。以下列表显示了我们对 API 调用的最终更改。

列表 9.28 日志的最终更改
@log_message("Retrieving ACH files") #1
async def read_files(request: Request) -> #2
➥ list[AchFilesResponse]: 
    return AchFileSql().get_files_response()

1 为我们的每个 API 调用添加一个独特的消息

2 确保请求对象是方法的一部分

添加这部分逻辑就完成了我们的 API 消息记录。现在,任何我们用@log_message注解的 API 都会写入日志消息。这只是追踪和审计应用程序的开始。通过探索诸如 OpenTelemetry、OpenTracing 或 Prometheus 等项目,您可以极大地扩展在应用程序中收集数据的能力,仅举几个例子。

9.5 查看审计日志

现在我们有了应用程序的日志 API 请求,让我们添加在应用程序中查看日志的能力。这个页面将像第八章中的异常页面一样工作。我们需要检索审计记录并将它们放入 DataGrid 中以便查看。当然,我们可以根据需要扩展功能,但到目前为止,仅仅返回数据就是我们需要完成的任务。

9.5.1 创建页面

我们在src/app/audit下创建page.tsx页面。这个页面将负责向 API 发出请求,然后将返回的数据传递给一个可以显示记录的组件。以下列表显示了基本页面。

列表 9.29 审计页面
export default function AuditPage() {

    const [entries, setEntries] = useState<AuditResponse[]>([]);

  useEffect(() => {
        const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? '';
        axios.get<AuditResponse[]>(`${apiUrl}/audits`, { #1
            headers: {
                'Content-Type': 'application/json'
            }
        })
            .then(response => {
                console.log(`Response data ${JSON.stringify(response.data)}`);
                setEntries(response.data);
            })
            .catch(error => {
                console.log(error);
            });
    }, []);

    return (
…
       <AuditRecords records={entries}/> #2
…
    );
}

1 从数据库获取审计记录

2 显示审计记录

为了完成这一页,我们还想创建AuditResponse接口,如列表 9.30 所示。我们保持名称与 API 中的名称一致,因为这样我们工作量更小。然而,在前几章中,我们已经展示了将字段映射到数据内部表示的过程,这一点在这里仍然有效。请记住,我们通过精确匹配 API 所承担的权衡——最大的问题是当 API 发生变化或当我们迁移到另一个供应商(字段名称可能发生变化)时。这些更改必须在整个应用程序中传播,而不是在单个点上。我们建议始终尝试使用一层抽象来防止这类问题。没有人愿意花费时间将created_at更改为created_on,并需要在可能触及的每个地方重新测试。

列表 9.30 AuditResponse接口
export interface AuditResponse {
    audit_log_id: string;
    created_at: Date;
    user_id: string;
    ip_address: string;
    user_agent: string;
    http_request: string;
    http_response: number;
    url: string;
    message: string;
}

9.5.2 创建页面组件

我们将数据传递给我们的 AuditLogRecords 组件,以便处理展示逻辑。在这个阶段,我们希望看到组件如何帮助我们将代码分解成更小、更易于管理的部分。我们试图让页面负责检索数据,而页面上的组件则负责展示这些数据。在应用程序最初开发时,我们往往会有更多重复的代码。这通常是因为开发者试图赶在截止日期前完成任务。随着代码的成熟(以及开发者对应用程序有更好的理解),我们通常可以开始创建更通用的代码。将我们的代码分解成组件有助于我们更快地达到这一点。下一个列表显示了展示审计记录的代码。

列表 9.31 AuditLogRecords 组件
interface AuditRecordsProps {
    records: AuditResponse[];
}

export default function AuditRecords({records}: Readonly<AuditRecordsProps>) {

    const [isOpen, setIsOpen] = useState<boolean>(false); #1
    const [auditDetails, setAuditDetails] = #2
                   useState<AuditResponse | null>(null); 
    const columns: GridColDef[] = [
        {field: 'view', headerName: 'View',
➥ sortable: false, width: 10, renderCell: (params) => (
                <IconButton
                    onClick={(e) => { #3
                        e.preventDefault();  #3
                        setAuditDetails(params.row);  #3
                        setIsOpen(true);  #3
                    }}
                    color="primary"
                >
                    <InfoIcon />
                </IconButton>
            )},
        {field: 'created_at', headerName: 'Date',
➥ width: 150, valueGetter: (params) => 
convertDateFormat(params.value)},
➥        {field: 'ip_address', 
➥headerName: 'IP Address', width: 150, valueGetter: (params) => 
➥ stripSubnet(params.value)},
        {field: 'message', headerName: 'Audit Message', width: 300},
    ];

    return (
        <ThemeProvider theme={defaultTheme}>
            <Box sx={{display: 'flex'}}>
                <CssBaseline/>
                    <Container maxWidth="lg" sx={{mt: 4, mb: 4, ml: 4}}>
                        <Paper
                            sx={{
                                p: 2,
                                display: 'flex',
                                flexDirection: 'column',
                                height: 480,
                                width: '100%'
                            }}
                        >
                            <DataGrid columns={columns} #4
➥ rows={records} getRowId={(row) => row.audit_log_id} /> 
                        </Paper>
                    </Container>
            </Box>
            <AuditModal open={isOpen} #5
➥] onClose={setIsOpen.bind({}, false)} auditData={auditDetails} /> 
        </ThemeProvider>
    );
}

1 定义一个字段来跟踪模态

2 定义模态的审计详情内容

3 当按钮被点击时,将信息传递给模态并设置它现在是打开的

4 使用 DataGrid 元素来显示文本

5 包含 AuditModal 组件以显示详细内容

我们还构建了一个额外的实用方法作为组件的一部分——stripSubnet 方法。此方法的代码如下所示。注意它只移除尾部的 /32,那么为什么我们有移除它的函数,它究竟在哪里?

列表 9.32 stripSubnet 实用方法
export function stripSubnet(ipWithSubnet: string): string {
  const [ip, subnet] = ipWithSubnet.split('/'); #1

  if (subnet === '32' ) { #2
    return ip; #3
  } else {
    return ipWithSubnet; #4
  }

}

1 在斜杠处分割 IP 地址

2 如果子网为 32,则表示单个 IP 地址。

3 仅当 IP 地址为单个 IP 时返回 IP

4 否则,返回传入的字符串

/32 是无类域间路由(CIDR)表示法的一部分,它是一种定义 IP 地址范围的方式。/32 表示给定的地址是一个单独的地址,它只是明确表示这一点的一种方式。为了说明,我们可以用 192.168.1.0/24 来表示 192.168.1.0192.168.1.255 之间的地址。这解释了 /32 是什么,但没有解释为什么它在那里。我们看到 /32 是因为我们选择在 Pydantic 模型中以 IPvAnyNetwork 的形式表示 IP 地址。

Pydantic 支持许多网络类型。目前我们主要关注的是IPvAnyAddressIPvAnyNetwork。使用INET作为我们的ip_address列的决定是问题的催化剂。INET数据类型支持使用 CIDR 表示法存储 IP 地址。因此,我们选择在 Pydantic 中使用一个紧密模拟该功能的类型,尽管我们表中应该只有一个 IP 地址。我们使用这个例子来说明设计决策如何产生下游影响。列的INET要求可能是因为有查询 IP 地址范围的需求,即使审计记录不需要包含多个 IP 地址,使用INET数据类型也是有意义的。也许,这些决策(无论好坏)在 UI 组件到达我们手中之前就已经做出了。业务不希望用户看到 IP 地址中的/32部分,因为大多数用户不会理解这种表示法,所以我们需要纠正显示。

下面的列表展示了帮助说明 Pydantic 类型IPvAnyAddressIPvAnyNetwork之间差异的一些额外的单元测试。

列表 9.33 网络类型的单元测试
class IpModel(BaseModel):
    ip_any_address: IPvAnyAddress
    ip_any_network: IPvAnyNetwork

class TestPydanticNetworkTypes:

    def test_valid_any_address(self):
        my_address = IpModel(ip_any_address="127.0.0.1",
➥ ip_any_network="127.0.0.1")
        assert str(my_address.ip_any_address) == "127.0.0.1"

    def test_invalid_any_address(self):
        with pytest.raises(ValueError):
            IpModel(ip_any_address="127.0.0.256",
➥ ip_any_network="127.0.0.1")

    def test_valid_any_network(self):
        my_address = IpModel(ip_any_address="127.0.0.1",
➥ ip_any_network="127.0.0.1")
        assert str(my_address.ip_any_network) == "127.0.0.1/32"

    def test_invalid_any_network(self):
        with pytest.raises(ValueError):
            IpModel(ip_any_address="127.0.0.1",
➥ ip_any_network="127.0.0.256")

我们还使用了一个自定义组件AuditModal。当点击审计记录的详细信息时,我们希望显示一个格式化的窗口,包含审计信息(列表 9.34)。这是对审计记录的更完整视图,因此我们不需要在初始视图中添加杂乱。AuditModal是一个相对简单的组件。因为我们已经返回了整个记录,这只是一个数据的展示,与上一章中我们在用户点击查看图标时特别检索 ACH 记录数据的情况不同。记住,区别在于我们不想在没有用户请求的情况下返回可能的 NPI 数据。现在我们有了查看审计信息的能力,监控用户请求 NPI 数据,并解决任何潜在的数据滥用(无论是有意还是无意)。

列表 9.34 AuditRecords组件
interface AuditRecordsProps {
    records: AuditResponse[];
}

export default function AuditRecords({records}:
➥ Readonly<AuditRecordsProps>) {

    const [isOpen, setIsOpen] = useState<boolean>(false);
    const [auditDetails, setAuditDetails] =
➥ useState<AuditResponse | null>(null);
    const columns: GridColDef[] = [
        {field: 'view', headerName: 'View', sortable: false,
➥ width: 10, renderCell: (params) => (
                <IconButton #1
                    onClick={(e) => { 
                        e.preventDefault(); 
                        setAuditDetails(params.row); 
                        setIsOpen(true); #2
                    }} 
                    color="primary" 
                > 
                    <InfoIcon />
                </IconButton>
            )},
        {field: 'created_at', headerName: 'Date', width: 150,
➥ valueGetter: (params) => convertDateFormat(params.value)},
➥        {field: 'ip_address', headerName: 'IP Address',
➥ width: 150, valueGetter: (params) => stripSubnet(params.value)},
        {field: 'message', headerName: 'Audit Message', width: 300},
    ];

    return (
        <ThemeProvider theme={defaultTheme}>
            <Box sx={{display: 'flex'}}>
                <CssBaseline/>
                    <Container maxWidth="lg" sx={{mt: 4, mb: 4, ml: 4}}>
                        <Paper
                            sx={{
                                p: 2,
                                display: 'flex',
                                flexDirection: 'column',
                                height: 480,
                                width: '100%'
                            }}
                        >
                            <DataGrid columns={columns}
➥ rows={records} getRowId={(row) => row.audit_log_id} /> #3
                        </Paper>
                    </Container>
            </Box>
            <AuditModal open={isOpen} onClose=
➥{setIsOpen.bind({}, false)} auditData={auditDetails} />  #4
        </ThemeProvider>
    );
}

1 可点击的按钮,显示审计记录的详细信息

2 可点击的按钮,显示审计记录的详细信息

3 显示所有审计记录结果的 DataGrid 元素

4 当点击图标时显示的 AuditModal 组件

在能够拉取和查看审计数据的能力之后,我们缺少拼图中的关键一块——让我们添加 API 端点来检索我们的数据。

9.5.3 添加审计日志的 API

虽然我们构建了检索记录的初始 SQL 调用和显示数据的 UI 层,但我们尚未构建 API 本身。我们现在需要确保我们可以调用 API 并返回数据。下面的列表显示,完成这项任务只需要几行代码,其中大部分都是帮助文档化 API 供他人使用。我们创建了名为routers/audit.py的单独文件来创建审计 API。因为这个端点与 ACH 文件无关,我们希望将其分离。

列表 9.35 审计路由器
@router.get(
    path="",
    response_model=list[AuditLogRecord],
    summary="Retrieve Audit Information",
    description="Retrieve audit records for the ACH API.",
    response_description="The overview of the requested audit records.",
    tags=["Audit"],
)
@log_message("Audit Information")
async def read_audit_information(request: Request) -> list[AuditLogRecord]:
    return AuditLog().get_all_log_records() #1

1 简单调用以获取和返回所有审计记录

由于我们为了便于维护而分离了代码,API 将无法访问,直到我们使用include_router方法添加它。我们最初是为router/files.py文件这样做,直到这一点,我们所有的 API 都放入了那个文件,这意味着现在没有必要重新访问main.py文件。下一个列表显示了访问我们的 API 所需的附加行。

列表 9.36 添加路由器
app = FastAPI()

app.include_router(files.router)
app.include_router(audit.router) #1

app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,  # List of origins that should be allowed
    allow_credentials=False,  # Allows cookies to be included
    allow_methods=["*"],  # List of HTTP methods allowed for CORS
    allow_headers=["*"],  # List of HTTP headers allowed for CORS
)

1 处理审计 API 请求的附加路由器

那就是最后要放置的部分。现在我们应该拥有完整的审计功能。我们可以通过额外的日志记录和更具体的业务场景日志记录来扩展这一点,例如无效输入和其他我们可能希望记录的不仅仅是 API 访问确认的场景。

摘要

  • 每一次连续的迭代都有助于提升我们应用程序的功能。我们致力于解决最终用户提出的要求/关注点、业务所需的新功能以及技术债务。

  • 能够进行搜索是我们应用程序的一个必要功能。交易在一天中的不同时间通过不同的文件加载。如果客户对交易何时加载有疑问,我们需要能够快速提供答案。

  • 无论我们是致力于保护我们的应用程序免受入侵者或不满的员工侵害,还是为了更深入地了解应用程序,这一切都始于适当的审计,以保障安全和合规,并伴随一般分析。

  • 我们谈到了不仅记录日志的重要性,而且拥有一个成功的日志管理策略将在我们的成功中扮演一部分。我们不应相信应用程序正在运行或未被攻击——相反,我们必须有数据并对其进行分析。

  • 您已经了解到,根据用户反馈扩展异常处理和增强 UI/UX 的重要性,以提升整体用户满意度和系统功能。

  • 在有效解决客户投诉并确保更好的用户体验中,通过多个标准启用交易搜索的重要性得到了强调。

  • 利益相关者对审计功能的需求强调了跟踪仪表板交互以了解用户参与度和规划未来系统改进的价值。

  • 我们关注了敏捷冲刺计划的重要性,包括定义清晰的用户故事和验收标准,以确保对齐和成功交付冲刺目标。

  • Gantt 图在可视化项目时间线方面非常有效,有助于利益相关者的沟通和期望管理。

  • 在大型数据集中对强大搜索功能的需求强调了高效搜索 API 的必要性,这促使我们探索用户界面更改和实时搜索方法。

  • 开发 BDD 测试说明了测试在验证新搜索功能中的关键作用,确保它们满足指定的场景和用户需求。

  • 实施搜索 API 涉及理解各种处理标准的方法,突出了灵活且用户友好的搜索功能的重要性。

  • 对日志和审计的探索强调了在监控 API 使用和用户交互中实施安全实践的重要性,以确保安全和问责制的系统行为。

  • 设计审计日志数据库强化了捕获详细请求信息(如用户代理和 IP 地址)的需求,以实现全面的安全和分析。

  • 在 FastAPI 中使用中间件演示了如何简化日志过程,这减少了冗余代码并增强了代码的可维护性。

  • 将审计日志视图集成到应用程序中,展示了透明度和监控系统活动以支持持续改进和问责制的价值。

第十章:公司信息

本章节涵盖

  • 添加支持公司仪表板的功能

  • 添加公司信息组件

  • 添加最近 ACH 批次组件

  • 添加预期文件组件

  • 添加公司限制组件

本章节探讨了我们在程序增量过程中的另一个迭代。业务部门之前已经规划了关于存储和显示公司信息的附加功能。基本上,业务部门现在要求一个公司级别的仪表板。此外,用户还提供了一些关于他们希望看到实施的附加功能的反馈,这些功能最初不在项目范围内,业务部门要求我们也将其包括在内。

10.1 赛跑计划

在这次赛跑中,我们创建了一个关注公司层面数据的仪表板。除了表格和 API 之外,我们的团队在此期间必须创建以下四个额外组件:

  • 公司信息

  • 最近批次

  • 预期文件

  • 公司限制

公司信息表在我们的赛跑中被识别为阻碍因素,因为它们必须在开始构建组件之前设计和构建。可能有可能从一些其他组件开始,几乎不需要重做,因为许多增强功能的表——如预期文件组件——只需要回到公司 ID 的外键要求。在这种情况下,可能可以开始工作而不需要外键,但其他更改,如显示客户信息,将需要构建到一定程度的表。图 10.1 显示了使用 PlantUML 创建的甘特图。

图片

图 10.1 ACH 仪表板增强时间线

10.2 公司信息

如前所述,这次赛跑完全是关于与公司合作。每个 ACH 文件都包含各种条目和附加记录的批次。这些批次与公司相关联。如果这些公司是我们金融机构的客户,我们就有机会提供各种关于它们的报告和控制。重要的是要理解,这些批次不一定来自与我们机构相关的公司——它们可能来自其他公司或我们与之合作的伙伴,以将交易交付给机构。我们为该仪表板构建的公司仪表板和组件将为我们的客户提供额外的 ACH 处理服务。虽然这些组件对公司来说可能不可或缺,因为它们提供了额外的审计、警报和控制的可能性,但它们并不是 ACH 标准的一部分。

我们必须创建数据库表和 API 来支持在数据库中存储和访问公司信息。这些公司是我们金融机构的商业客户,因此我们希望为他们提供超出通常可能需要的个人客户之外的信息和控制 ACH 文件。例如,为我们个人客户处理 ACH 批次通常会导致发布工资和其他预先安排的支付或借记。从个人的角度来看,除了批次内的交易正确记入其账户之外,他们对批次信息和统计数据没有兴趣。

如果我们将此与商业客户(公司)进行对比,他们可能非常关注批次信息,因为他们将能够知道批次内所有交易的总额。与个人客户管理单个交易的账户余额类似,公司需要了解批次内的所有交易,因为这些交易会影响公司。

虽然我们可能会从不是我们金融机构客户的公司接收和处理 ACH 批次,但在他们是客户的情况下,我们希望提供关于批次的额外详细信息。通过为这些公司创建数据库表,我们也打开了除了接收要处理的文件外,还可以生成文件的可能性,正如我们在整个项目中一直在做的那样。生成 ACH 文件超出了当前开发的范围,但它是我们可以考虑的,以增强我们的项目,作为构建一个完全功能的 ACH 系统的逻辑下一步。我们将用虚拟数据填充我们的公司表,以便我们在本章中有所作为。

10.2.1 数据库设计:公司信息

除了第九章(列表 9.20)中的audit_log和第八章中的bank_routing_numbers表之外,我们不得不添加许多与 ACH 无关的表。有了支持公司信息,我们现在可以添加更多表和支持类型。首先,从公司表开始,看看我们需要跟踪什么是有意义的。

列表 10.1 向我们的数据库中引入了几种自定义类型。对于tax_id_typeindustry字段,我们创建了新的自定义类型以提供一些验证并协助数据录入。这些自定义类型有助于强制执行我们期望在字段中存在的数据值,从而防止输入错误数据。

tax_id_typetax_id_number是相关的,因为在美利坚合众国,个人被分配的不仅仅是社会保险号(SSN),例如雇主识别号(EIN)和个人税务识别号(ITIN)等。因此,我们给字段一个更通用的名字,而不是简单地称之为ssnein。我们默认为EIN,因为公司更有可能使用 EIN 号码。

行业字段用于报告目的,以便各种报告和营销活动可以在用户希望按行业钻取或创建报告时过滤行业。logo字段用于存储公司的图像,以便在需要时将图像加载到公司页面上,并且仅用于使页面和报告看起来更美观。我们使用了一种新的类型BYTEA,可以存储二进制数据。虽然将标志存储在数据库外对于 UI 性能会有所帮助,但能够将所有内容保持在数据库中可以防止我们引入其他技术,如 AWS S3 或 CDN。

列表 10.1 显示了create table语句。ach_company_id用于在 ACH 中识别公司。此字段对应于 ACH 系统中批量头记录(类型 5)上的公司识别字段。虽然通常使用 EIN 号码或其变体,但使用它不是强制性的。我们只需要记住该字段长度为 10 位,在创建文件时应使用前导零进行填充,以保持文件格式。

列表 10.1 存储公司详情的表
CREATE TABLE company (
    company_id UUID PRIMARY KEY 
                     DEFAULT uuid_generate_v4(),
    name VARCHAR(255) NOT NULL,
    tax_id_type TIN_TYPE NOT NULL DEFAULT 'EIN',  #1
    tax_id_number VARCHAR(9) NOT NULL,
    ach_company_id NUMERIC(10) NOT NULL, #2
    duns NUMERIC(9) DEFAULT NULL,  #3
    logo BYTEA DEFAULT NULL,   #4
    website VARCHAR(255) DEFAULT NULL,
    industry INDUSTRY_TYPE DEFAULT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

1 定义了具有默认值 EIN 的tax_id_type,因为,很可能是将使用这种类型的值

2 在 ACH 类型 5 记录(批量头)上使用的公司识别号

3 许多公司有一个 DUNS 号码,在作为公司注册时,许多服务都需要这个号码。

4 我们将标志存储为 BYTEA,这样我们就可以将图像上传到数据库。通常,这是一个存储图像位置的 URL。将图像存储在我们的数据库中只是为了方便。

之前列表中的自定义类型需要在创建表之前定义。创建类型是一个简单的过程,并且拥有这些自定义类型可以帮助我们在数据库和字段上增加一个额外的验证级别。当然,这种方法可以根据我们的应用程序需求而变化。例如,将这些值作为字符串存储在数据库中,或者可能作为数值存储,同时将标签存储在另一个位置,这也是可以接受的。

根据几个因素(公司政策、团队成员、应用程序、数据库等),可能更倾向于将值存储在另一个表中,而不是在数据库中定义类型,以避免数据库变更请求。由于我们的值有限,而且它们不需要经常更改,所以我们很乐意将它们定义为类型。以下列表显示了我们的industry_typetin_type的创建。

列表 10.2 industry_typetin_type
CREATE TYPE industry_type AS ENUM ('basic materials', …, 'utilities');

CREATE TYPE tin_type AS ENUM ('SSN', 'EIN', 'ITIN', 'ATIN', 'PTIN');

如所示,创建一个存储值的类型很简单。我们可能关心的另一个问题——这也是团队成员可能争论使用单独表的原因——是提取有效值列表的能力(例如,如果我们想在 UI 端构建 tin_type 的动态下拉列表)。以下列表显示了如何提取 ENUM 类型的标签。

列表 10.3 在 Postgres 中返回 ENUM 的标签
SELECT e.enumlabel AS label  #1
  FROM pg_type AS t #2
  JOIN pg_enum AS e   #2
    ON t.oid = e.enumtypid #2
 WHERE t.typname = 'tin_type'; #3

1 返回我们的 ENUM 的标签

2 需要返回 ENUM 标签的 Postgres 表

3 我们感兴趣的特定 ENUM 类型

一旦我们建立了公司表,我们就可以创建一个地址表。它将显示两个地址——邮寄地址和街道地址——我们使用 address_type 来定义。表中的其余部分在需要存储的内容方面是标准的。我们允许地址有四行,以覆盖公寓/楼层编号或其他信息,以及城市、州和邮政编码等标准字段。

列表 10.4 address_type 及其表
CREATE TYPE address_type AS ENUM ('mailing', 'street'); #1

CREATE TABLE company_address (
    company_address_id UUID DEFAULT uuid_generate_v4(),
    company_id UUID NOT NULL REFERENCES company(company_id)    #2
                           ON DELETE CASCADE ON UPDATE CASCADE, 
    address_type ADDRESS_TYPE NOT NULL DEFAULT 'mailing',  #3
    address_line_1 VARCHAR(255) NOT NULL,     #4
    address_line_2 VARCHAR(255) DEFAULT NULL,  #4
    address_line_3 VARCHAR(255) DEFAULT NULL,  #4
    address_line_4 VARCHAR(255) DEFAULT NULL, 
    city VARCHAR(255) NOT NULL,   #5
    state VARCHAR(2) NOT NULL,     #5
    zip_code NUMERIC(5) NOT NULL,   #5
    zip_code_4 NUMERIC(4) DEFAULT NULL, 
    created_at TIMESTAMP NOT NULL DEFAULT NOW(), #6
    updated_at TIMESTAMP NOT NULL DEFAULT NOW(), 
    PRIMARY KEY (company_id, address_type)  #7
);

1 定义可能的邮寄地址类型

2 此记录与公司相关联,因此定义了外键。

3 默认地址类型为邮寄地址

4 标准地址行,留有部门或楼层编号的空间

5 标准地址行

6 可帮助审计的字段

7 通过定义一个同时使用地址类型的唯一主键来防止公司有超过一个地址

我们还创建了一个名为 company_phone 的表来存储公司的电话号码。同样,我们定义了一个 phone_type 来标识不同的电话类别。我们还定义了公司和电话类型的唯一主键,以便每个公司只能定义一种电话类型。另一个限制是确保 phone_numberextension 的唯一性,以便电话号码不能被重复使用。

列表 10.5 company_phone 类型及其表
CREATE TYPE phone_type AS ENUM ( 'main', 'direct', 'department', #1
'fax', 'toll-free', 'mobile', 'home', 'other');   

CREATE TABLE company_phone (
    company_phone_id UUID DEFAULT uuid_generate_v4(),
    company_id UUID NOT NULL REFERENCES company(company_id) #2
                             ON DELETE CASCADE ON UPDATE CASCADE, 
    phone_type VARCHAR(25) NOT NULL,  #3
    phone_number NUMERIC(10) NOT NULL,  #3
    extension NUMERIC(5) DEFAULT NULL,  #3
    created_at TIMESTAMP NOT NULL DEFAULT NOW(), #4
    updated_at TIMESTAMP NOT NULL DEFAULT NOW(), 
    PRIMARY KEY (company_id, phone_type), #5
    UNIQUE (phone_number, extension) #6
);

1 定义了电话类型的可能值

2 该表通过外键与公司表相关联。

3 电话号码字段

4 审计字段

5 防止每个公司有超过一种电话类型

6 防止使用超过一个电话号码

现在我们应该有足够的数据库结构来支持我们在 ACH 仪表板中的公司。当处理 ACH 文件时,其中大部分信息并非绝对必要,但额外的信息将有助于我们扩展项目以实现不同的功能。

10.2.2 使用生成式 AI 生成模拟数据

我们可以使用生成式 AI 通过为我们的公司创建一些测试数据来帮助我们提高生产力。使用我们为 SQL 类型 industry_type 定义的行业,我们可以指示 ChatGPT 执行以下操作:

图片 给定以下行业列表“‘基础材料’,……‘公用事业’”,为每个行业创建虚假的公司名称、地址信息和标志。

我们提供了公司名称,例如 Elemental Resource Inc.,地址为 123 Mineral Lane,Boulder,CO 80301。我们还要求随机生成的 EIN 和 DUNS 号码。也有可能看到 ChatGPT 能否与我们分享一些有趣的电话号码,我们可以将其包含在我们的数据中。例如,如果你团队里有喜欢 Phil Collins 或 Hall and Oates 的人,这里有两个有趣的号码:

  • 424-888-PHIL (7445),被称为 Phil Call-Ins 热线,在这里你可以听到 Phil Collins 的歌曲。

  • 719-266-OATES (2837),被称为 Callin Oates 热线,在这里你可以听到 Hall and Oates 的各种歌曲。

虽然一些有趣的测试数据可以让我们的日子变得愉快,但我们还需要将这些数据全部整合到 SQL 中。到目前为止,我们一直将所有 SQL 保存在 Dockerfile 中的 init.sql 中,以创建表和数据。由于需要输入生成的公司数据,我们想要进行一些结构调整。我们创建了一个名为sql的子文件夹,并将目前存在于 init.sql 中的所有SQL命令移动到该文件夹中,同时将它们拆分成不同的文件。现在应该更容易管理和更新我们的 SQL,根据需要。以下列表显示了我们的 Docker 文件夹的新结构。

列表 10.6 数据库的 Docker 文件夹结构
docker/
├─ db/
│  ├─ sql_scripts/
│  │  ├─ sql/
│  │  │  ├─ create_tables.sql
│  │  │  ├─ general_ach_data.sql
│  │  │  ├─ elemental_resources_inc.sql
│  │  ├─ init.sql

接下来,我们更新 init.sql 文件,使其引用新的文件。

列表 10.7 更新 init.sql 文件
…
\i /docker-entrypoint-initdb.d/sql/create_tables.sql #1
\i /docker-entrypoint-initdb.d/sql/general_ach_data.sql #2
\i /docker-entrypoint-initdb.d/sql/ #3
➥elemental_resources_inc.sql
…

1 包括创建表的操作。

2 包括一般的 ACH 数据,如异常信息。

3 每家公司可以存储在自己的文件中。

每家公司都使用以下 SQL 语句添加。

列表 10.8 添加公司
DO
$$
    DECLARE
        saved_uuid UUID; #1
    BEGIN
        WITH company_insert AS (        #2
            INSERT INTO company (name, tax_id_number, #3
                                duns, industry, logo)  #4
                VALUES ('Elemental Resources Inc.', 459876543,  #4
        123456789, 'basic materials', 'data:image/webp;base64,...') 
                RETURNING company_id #4
        ) 
        SELECT company_id INTO saved_uuid FROM company_insert; #5

        INSERT INTO company_address (saved_uuid, address_line_1, #6
                                     city, state, zip_code)   #7
        VALUES (saved_uuid, '123 Mineral Lane',  #7
               'Boulder', 'CO', 80301); 

        INSERT INTO company_phone (saved_uuid, phone_number) #7
        VALUES (saved_uuid, '7192662837'); 
    END
$$;

1 创建一个名为 saved_uuid 的变量,因为我们需要在其他插入操作中用它来满足外键约束。

2 使用 CTE(公用表表达式)来插入公司。

3 插入包含我们的测试数据的记录。

4 返回数据库分配的 company_id。

5 使用我们的 CTE 将 company_id 保存到我们的 saved_uuid 中。

6 使用存储的公司 ID 插入地址。

7 使用存储的公司 ID 插入电话。

我们可以告诉 ChatGPT 使用之前的列表作为模板来插入它之前生成的公司数据。让 ChatGPT 生成一些这样的设置代码,使得将一些默认公司添加到数据库中变得更容易。我们当然可以自己完成它,但这会有些繁琐且容易出错,因为我们复制/粘贴了数据。

10.2.3 API 设计

我们从一个简单的单元测试开始,加入一些新变化以确保我们可以得到200 HTTP响应码,这将表明我们已成功到达端点。正如我们所见,当我们的容器启动时,我们默认的公司会被加载到数据库中。通常,我们会在每次测试中使用SqlUtils().truncate_all()来清空我们的数据库,如果我们想避免截断某个表,我们需要更新那个方法以排除那个特定的表。以这种方式运行我们的测试也要求容器处于运行状态。

我们现在将创建一个测试,使用 Testcontainers 启动和停止我们的容器,以便每次都能收到一个新的数据库。这使得测试自包含,这对于我们必须将项目移入 CI/CD 环境来说非常完美。

列表 10.9 使用 Testcontainers 的初始单元测试
…
@pytest.fixture(scope="class") #1
def client():  #1
    return TestClient(app)  #1

@pytest.fixture(scope="module") #2
def docker_compose():   #2
    absolute_path = Path("../../../../docker/db").resolve()  #2
    with DockerCompose(absolute_path) as compose:  #2
        compose.start()  #2
        yield compose #2
        compose.stop()  #2

class TestCompanyApis:

    @pytest.fixture(autouse=True) #3
    def mock_client_host(self):  #3
        with patch(  #3
            "fastapi.Request.client",  #3
            new_callable=lambda: type("Client", (),   #3
                             {"host": "127.0.0.1"}),  #3
        ):  #3
            yield #3

    def test_get_companies(self, client, docker_compose): #4
        response = client.get("/api/v1/companies")  #5
        assert response.status_code == 200, response.text #5

1 定义一个用于测试调用的工具

2 使用 Docker Compose 定义容器,因为我们一直在整个项目中使用它。一切如预期设置。

3 定义了我们记录的 IP 地址

4 定义了一个测试,验证我们是否成功返回;还可以对响应进行进一步验证。

现在我们有了一种在隔离环境中运行测试的方法。我们越能隔离测试并保持其自包含,其他团队成员在自己的环境中运行它们就会越容易。这样,我们可以避免“在我的机器上工作”(WOMM)问题——这是软件开发中一个常见的主题。

我们现在遵循与之前 API 相同的公式。我们首先创建一个可以返回的记录,其中包含我们将显示为概览的一些公司信息。在这个阶段,我们只想能够显示公司列表,用户可以进一步深入了解。在下面的列表中,我们定义了一个 IndustryType 枚举。

列表 10.10 IndustryType 枚举
class IndustryType(Enum):
    BASIC_MATERIALS = "basic materials"
    CONSUMER_GOODS = "consumer goods"
    CONSUMER_SERVICES = "consumer services"
    FINANCIALS = "financials"
    HEALTHCARE = "healthcare"
    INDUSTRIALS = "industrials"
    OIL_AND_GAS = "oil & gas"
    TECHNOLOGY = "technology"
    TELECOMMUNICATIONS = "telecommunications"
    UTILITIES = "utilities"

我们定义了一个 CompanyOverviewRecord,如下面的列表所示。虽然你之前已经看到了大部分代码,但我们包括了一个正则表达式来验证图像是页面显示的有效格式。

列表 10.11 CompanyOverview 记录
class CompanyOverviewRecord(BaseModel):
    company_id: Optional[UUID4] = Field(
        None,
        title="Company ID",
        description="Unique identifier for the company.",
    )
    name: Optional[str] = Field(
        None,
        title="Name",
        description="Name of the company.",
        max_length=255,
    )
    industry: Optional[IndustryType] = Field( #1
        None,
        title="Industry",
        description="Type of industry the company operates in.",
    )
    logo: Optional[str] = Field(
        None,
        title="Logo",
        description="Base64 Image of the Logo.", #2
        pattern=r"^data:image\/[a-zA-Z]+; #3
➥base64,[A-Za-z0-9+/=]+$" 
  )

1 我们使用 IndustryType 来帮助进行验证。

2 声明标志应为 Base64 编码

3 使用正则表达式强制执行正确的格式

接下来,我们创建了一个类来处理与公司相关的任何 SQL 调用。下面的列表显示了使用 row_factory 的简单 SELECT 语句 class_row。在这种情况下,我们只是返回数据而不进行数据操作,我们也可以同样容易地使用 dict_row——选择实际上取决于我们的应用程序需求。

列表 10.12 CompaniesSql
class CompaniesSql:

    def get_all_companies(self) -> list[CompanyOverviewRecord]:
        with get_db_connection(row_factory= #1
➥class_row(CompanyOverviewRecord)) as conn: 
            result = conn.execute(
                """
                SELECT company_id, name, industry, logo FROM companies #2
                """,
                [],
            )

        records = result.fetchall() #3

        return records

1 获取数据库连接并将 row_factory 设置为 class_row 以用于 CompanyOverviewRecord

2 限制我们返回的字段;我们更喜欢特定的字段而不是“*”。

3 返回所有记录

我们几乎完成了!现在我们需要添加端点。我们从底部开始构建,因为我们觉得这样在开发 API 时会更合理。然而,我们也可以从定义端点开始,深入到检索数据的细节。

列表 10.13 获取所有公司的端点
router = APIRouter(prefix="/api/v1/companies") #1
…
@router.get(  #2
    path="",  #2
    response_model=list[CompanyOverviewRecord],  #2
    summary="Retrieve an Overview of Companies",  #2
    description="Return a list of defined companies.",  #2
    response_description="An overview of the company.",  #2
    tags=["Company"],  #2
)
@log_message("Company Overview") #3
async def get_company_overview(request: Request) ->   #4
list[CompanyOverviewRecord]: 
    return CompaniesSql().get_all_companies() #5

1 我们为所有端点分配一个前缀。

2 API 的标准文档

3 我们使用的注解为 API 提供一些审计信息

4 定义了函数,它将返回一个 CompanyOverviewRecords 列表

5 获取所有公司的 SQL 调用

最后,我们只需要将路由器添加到我们的 main.py 中。我们进行了一些重构,将路由器添加到数组中并通过循环而不是多个include_router语句来处理。以下列表显示了重构的结果。

列表 10.14 重构app.include_router
routers = [files.router, audit.router, #1
➥ companies.router] 
for router in routers: #2
    app.include_router(router) 

1 定义路由器数组

2 遍历它们并将每个都包含在内,这防止了有些冗余的 include_router 语句

这样,我们通过了测试,但不应止步于此。验证返回的数据同样重要,而不仅仅是收到有效的响应代码200。我们应该花点时间思考还需要验证什么,以便在通过测试后自信地发布代码。

在这种情况下,我们可能会收到200响应代码,但没有收到任何公司。如果数据库已经填充了公司数据,我们应该检查这些公司名称是否存在?或者可能是一部分这些公司?我们只想强调测试的目的是对所编写的代码有信心。即使我们无意在成功自动测试后发布代码到生产环境,拥有一个覆盖并彻底测试我们代码的测试套件,使我们能够自信地发布它,也是一个有价值的目标。

顺便提一下,除了从数据库中读取公司的能力外,对于一个完全功能的仪表板,我们还需要添加、更新和删除的能力。这将是休息一下、拉取项目的一个版本、忙于一些全栈开发的好时机!

10.2.4 用户界面

创建用户界面的第一步是在 NavButtons.tsx 中添加一个按钮,以便我们可以导航到新页面。找到合适的图标应该是这个过程中最困难的部分。幸运的是,Material UI 提供了足够多的图标供选择。

列表 10.15 NavButtons.tsx
<ListItemButton onClick={() => route.push("/companies")}> #1
   <ListItemIcon> #2
 <Store/> #2
 </ListItemIcon> 
  <ListItemText primary="Companies"/> #3
</ListItemButton>

1 当导航按钮被点击时,我们导航到公司页面。

2 定义要显示的图标

3 设置文本为“公司”

当导航到公司页面时,将提供公司列表。我们使用与其他页面相同的模式。page.tsx 调用 API 检索公司信息,并将其传递给另一个组件以实际显示它。

列表 10.16 CompanyListing页面
export default function CompaniesOverviewPage() { #1

    const [companies, setCompanies] =  #2
useState<CompaniesListingResponse[]>([]); 

    useEffect(() => {
        const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? ''; #3
        axios.get<CompaniesListingResponse[]> #4
➥(`${apiUrl}/companies`, { 
…
            .then(response => { #5
                setCompanies(response.data); 
            })
…
                    <CompaniesListing companies={companies}/> #6
…

1 定义页面函数

2 存储公司的变量;默认为空数组

3 API 的 URL;当环境变量未定义时默认为空字符串

4 API 调用

5 当响应成功时,从响应数据中设置公司

6 将公司传递给 CompaniesListing

下面的列表中显示的 CompaniesListing 组件添加了几个我们没有使用的新项目。DataGrid 元素的 onRowClick 允许我们在行的任何地方点击并导航到该公司的详细信息,以及行高属性,这样我们可以更好地查看公司图像。我们还创建了一个 ImageBase64 来封装显示图像所需的一些组件。

列表 10.17 CompanyListing 组件
interface CompaniesListingProps {  #1
    companies: CompaniesListingResponse[];  #1
}  #1

export default function CompaniesListing({companies}: #2
Readonly<CompaniesListingProps>) { 

    const route = useRouter(); #3
    const columns: GridColDef[] = [ #4
        {field: 'view', headerName: '', sortable: false,
            width: 100, renderCell: (params) => (
            <ImageBase64 base64={params.row.logo} #5
                         alt={params.row.name}  #5
                         width="100%" maxWidth="200px" />  #5
            )},
        {field: 'name', headerName: 'Name', width: 300 },
        {field: 'industry', headerName: 'Industry', width: 150 },
    ];

    const handleRowClick = (params: { id: any; }) => { #6
        const companyId = params.id;  #7
        route.push(`/companies/${companyId}/dashboard`);  #7
    };  #7
…
       <DataGrid
          rowHeight={100}
          columns={columns} 
          rows={companies} 
          getRowId={(row) => row.company_id}
          onRowClick={handleRowClick} #7
       />
…

1 定义此组件的属性

2 函数将属性定义为 Readonly。

3 获取一个路由对象,以便我们可以按需导航

4 DataGrid 的列定义

5 一个可以显示公司标志的图像元素

6 定义当行被点击时执行的行点击方法,并带我们到公司仪表板

7 使用 onRowClick 处理用户在 DataGrid 中点击行

ImageBase64 很简单——我们有一个程序来确保图像处于正确的格式,如下面的列表所示。这允许我们执行验证,如果没有正确格式的字符串,我们返回一个空字符串。

列表 10.18 Base64 图像
const isBase64Image = (base64: string) => {  #1
    const regex = /^data:image\/[a-zA-Z]+;base64,/;  #1
    if (!regex.test(base64)) {  #1
        return '';  #1
    } else {  #1
        return base64;  #1
    }  #1
}  #1

export default function ImageBase64({base64, #2
      alt = 'Image',  width = '100%',  maxWidth = '100px'}:  #3
➥ Readonly<ImageProps>) {  #3

    const imageSource = isBase64Image(base64); #3

    return (
        <Box  #4
            component="img"  #4
            src={imageSource}  #4
            alt={alt}  #4
            sx={{  #4
                width: width,  #4
                maxWidth: maxWidth,  #4
            }}  #4
        />  #4
    );
}

1 一个函数,确保我们有一个格式正确的 src 字符串以在源标签中显示图像。如果没有,我们返回一个空字符串,这将显示图像的 alt 标签。

2 定义组件以允许有多个属性和默认值,如果未传递则使用默认值

3 将函数调用的结果分配给 isBase64Image 并赋值给 imageSource

4 没有 img 组件;相反,适当的参数传递给一个 Box 元素。

我们当然可以在我们的组件或页面上直接使用 Box 元素,但将元素包装并作为其自身的组件处理有一些好处。我们可以在多个区域使用这种策略,并在整个代码中强制一致性。拥有一个专门的 ImageBase64 可能过于具体。我们可以创建一个更通用的元素,并传递一个类型给它,该类型确定我们使用的 src 类型并执行必要的验证。

到目前为止,我们有了存储公司信息的数据库结构和访问公司信息的 API。我们还创建了一个能够列出我们公司的组件,如图 10.2 所示。

计算机屏幕截图  自动生成的描述

图 10.2 列出公司的组件

现在我们可以在数据库中查看公司,我们开始组装我们的公司仪表板。

10.3 公司仪表板

在放置了导航到公司列表的方法之后,我们希望能够显示一个公司级别的仪表板。这个功能将允许我们创建一些其他组件——尽管这些组件不一定仅针对公司(我们可以在文件级别集成这些组件的版本)——可以提供特定于公司的信息,并为我们的应用程序的增长提供其他方向。

10.3.1 公司仪表板的导航

通过查看所有公司的方法,我们希望能够深入挖掘并到达公司仪表板。支持公司列表和仪表板的所需结构在列表 10.19 中显示。

列表 10.19 公司的 UI 结构
src/
├─ app/
│  ├─ companies/
│  │  ├─ [companyId]/
│  │  │  ├─ dashboard/
│  │  │  │  ├─ page.tsx #1
│  │  ├─ page.tsx #2

1 此页面将包含特定公司的仪表板;[companyID] 将是公司的 UUID。

2 这是显示在图 10.2 中的公司列表页面。

在页面结构设置完成后,我们只需要创建一个空白的仪表板页面,该页面可以填充特定公司的组件。我们可以使用我们的文件级仪表板作为模板。

列表 10.20 空公司仪表板
…
export default function CompanyDashboard({params}: any) {

    const companyId: string = params.companyId; #1
    const [company, setCompany] 
                     = useState<Company | undefined>(undefined); #2
…
    useEffect(() => {   #3
        const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? ''; 
axios.get<CompanyDetailResponse>(`${apiUrl} 
➥/companies/${companyId}`, { 
… 
            .then(response => { 
        console.log(`Response data ${JSON.stringify
➥(response.data)}`); 
… 
    }, []); 

    return (
…
                    <Typography>Empty Company Dashboard</Typography> #4
…
    );
}

1 从 URL 中获取公司 ID;这是由于我们使用的基于文件的路由和定义的 [companyId] 目录所实现的。

2 存储公司

3 获取公司详细信息

4 仪表板占位符以验证导航

虽然我们现在可以导航到仪表板并获取公司详细信息,但仪表板略显空旷。然而,在我们开始创建仪表板组件之前,我们需要为公司准备 ACH 批次。下一节将展示如何使用公司特定数据生成 ACH 文件。

10.3.2 为我们的公司创建 ACH 文件

我们已经创建了用于生成仪表板 ACH 文件的 BDD 风格测试。幸运的是,我们可以使用相同的代码来为我们的公司创建特定文件。以下列表显示了我们编写的用于创建构建公司仪表板测试文件的示例场景。我们仅使用 ChatGPT 获取一些可能适合公司客户的原始名称。

列表 10.21 为 Elemental Resources Inc. 创建文件
  Scenario: Create an ACH file for company "Elemental Resources Inc"
    Given I want to create an ACH file named "elemental_resources.ach"
    And I want to have an immediate destination of "990000013"
    And I want to have an immediate origin of "987654321"
    And I want to have 2 batch with ACH credits and debits
➥ and a standard entry class code of "PPD"
    And I want 10 entries per batch with random amounts
➥ between 100 and 5000
    And I want to use individual names of "Rocky Gravel, Sandy Stone,
 Clay Earthman, Mason Bricks, Gemmy Quartz, Rusty Ironwood, 
Flint Stoney, Dusty Boulderson, Shelly Shale"
    And I want to have company name "ElementalResourcesInc"
➥ and company id "459876543"
    When my ACH is created
    Then I should have a file of the same name
    And there should be 2 batch in the file
    And there should be 20 entries in the file

在这里,我们只需确保我们的公司名称不要太长,因为这可能会影响记录。在我们的原始代码中,我们确保当字段太短时,该字段本身被填充到 16 个字符。现在,我们遇到了相反的问题,我们输入了一个 22 个字符的字段。以下列表显示了一块简单的代码,可以用来截断公司名称。

列表 10.22 截断和填充公司名称
setup_info["company_name"] = company_name[:16].ljust(16, " ")

可能会诱使你遍历代码并找到所有看起来应该截断字符串的区域。如果你选择这条路,请确保为测试留出足够的时间。

是修复还是不修复?

我们之前遇到过类似的生产问题,并不得不决定是否需要扩大故事的范围(例如,在特定字段上报告了无效金额)。用户故事最初是为了修复报告为错误的单个字段而编写的;然而,在研究问题和找到根本原因后,我们发现问题模式在程序的其他几个地方也被使用。由于导致向第三方发送无效数据的问题,我们决定更新故事并扩大范围(即增加故事点),以便全面解决问题。

将公司数据和特定的公司 ACH 文件加载到数据库后,我们可以开始构建仪表板。

10.4 公司信息组件

我们想要构建的第一个组件是一个包含更多公司详细信息的屏幕。这些信息将只是一些可以按需编辑的附加信息。

我们创建了所需的Pydantic类,用于返回数据。我们在CompanyDetailRecord中返回比我们需要显示更多的信息,因为数据还将传递到一个所有数据都可以更新的表单中。以下列表显示了返回AddressPhone对象数组的CompanyDetailRecord。公司对于AddressPhone类型有一个一对多关系,因此可能有多行。

列表 10.23 CompanyDetailRecord字段
class CompanyDetailRecord(BaseModel):
    company_id: Optional[UUID4] = Field(
        None,
        title="Company ID",
        description="Unique identifier for the company.",
    )
    name: Optional[str] = Field(
        None,
        title="Name",
        description="Name of the company.",
        max_length=255,
    )
…
    addresses: Optional[list[Address]] = Field( #1
        None,                    #1
        title="Addresses",  #1
        description="List of Addresses.",  #1
    )  #1
    phones: Optional[list[Phone]] = Field( #2
        None,  #2
        title="Phones",  #2
        description="List of Phone Numbers.",  #2
    )  #2

1 使用数组存储公司地址的 Address 对象

2 使用数组存储公司电话号码的 Phone 对象

由于地址和电话记录是数组,我们发现构建CompanyDetailRecord比我们之前的数据检索要复杂一些。这是因为,以前,我们只是从数据库中拉取一个记录并将其映射到对象。现在,我们需要处理来自多个表的多行数据。

我们可以遵循几种方法,但我们希望保持与过去一样的单一查询。然而,根据我们的查询设计,这可能会在公司表中重复字段。返回重复数据并不是世界末日;然而,这是一个表明有改进空间的指标。毕竟,我们非常小心,没有简单地使用星号(这可能导致返回我们无意使用的字段)。重复数据可能会因为性能原因而引起关注,也可能导致开发人员之间的混淆。

这可能并不明显,但对于每个地址和电话号码,列表 10.24 中的查询将返回一行。因此,如果我们有两个地址和三个电话号码,我们将从查询中接收到五行。考虑到预期的地址和电话号码数量,这一点在此阶段并不极端有问题。我们确实返回了相对大量的图像数据,但如前所述,将数据存储为Base64编码的字符串是出于方便考虑。

此外,我们使用LEFT JOIN,因为我们无法保证公司会有地址或电话信息。如果我们使用INNER JOIN,公司需要查询返回地址和电话号码。虽然没有地址/电话的公司记录不太可能,但并非不可能。也许最初的记录已经设置好了,但由于商业原因,地址没有填写(可能没有官方的地址文件),或者也许在转换或迁移过程中,字段没有正确传输。

列表 10.24 查询以返回公司详细信息
   SELECT c.company_id, c.ach_company_id, c.name, c.tax_id_type,#1
c.tax_id_number, c.duns, c.logo, c.website, c.industry, 
ca.company_address_id, ca.address_type, ca.address_type, #2
ca.address_line_1, ca.address_line_2, ca.address_line_3,  #2
ca.address_line_4, ca.city, ca.state,  #2
ca.zip_code, ca.zip_code_4,  #2
cp.company_phone_id, cp.phone_type, #3
cp.phone_number, cp.extension
FROM companies AS c
LEFT JOIN company_addresses AS ca USING (company_id) #4
LEFT JOIN company_phones AS cp USING (company_id) 
WHERE company_id = %s #5

1 我们根据各自的表组织了选定的字段,这些字段来自公司表。

2 这些字段来自公司地址表。

3 这些字段来自公司电话表。

4 我们使用 LEFT JOIN,因为记录不需要在这些表中。

5 通过公司限制我们的详细信息,其中%s 是一个占位符

我们有多个行,需要应用逻辑来确保重复的数据不会被返回。以下列表显示了完成此任务的全方法。我们必须遍历每个记录,并使用哈希来确保id之前没有被遇到。

列表 10.25 返回公司详细信息
def get_company_by_id(self, company_id: UUID) -> CompanyDetailRecord:
   with get_db_connection(row_factory=dict_row) as conn:
      result = conn.execute(
…
      )

      rows = result.fetchall() #1

      if not rows: #2
         raise KeyError(f"Record with id {company_id} not found") 

      addresses = {} #3
      phones = {} 
        for row in rows: #4
           if row["company_address_id"] not in addresses: #5
              addresses[row["company_address_id"]] = Address(  #5
              …  #5
              )  #5
           if row["company_phone_id"] not in phones: #6
               …  #6
              )  #6

        company_data = {
            "company_id": rows[0]["company_id"], #7
            "name": rows[0]["name"],  #7
            "tax_id_type": rows[0]["tax_id_type"],  #7
            "ach_company_id": rows[0]["ach_company_id"],  #7
            "tax_id_number": rows[0]["tax_id_number"],  #7
            "duns": rows[0]["duns"],  #7
            "logo": rows[0]["logo"],  #7
            "website": rows[0]["website"],  #7
            "industry": rows[0]["industry"],  #7
            "addresses": list(addresses.values()), #8
            "phones": list(phones.values()), 
        }

        return CompanyDetailRecord(**company_data) #9

1 获取我们 SQL 查询的所有行

2 如果没有找到详细信息,这是一个错误,并以此方式报告。

3 设置空的哈希来存储我们的数据

4 遍历返回的每一行

5 在需要时创建一个地址对象

6 当我们遇到一个之前未遇到的电话号码时创建一个电话对象

7 我们可以直接引用第一行的公司字段。

8 从我们的哈希中创建一个数组

9 使用公司数据创建一个对象

虽然这段代码按预期工作,但我们可以通过使用更复杂的 SQL 来压缩结果数据集。我们已经在之前的示例中探讨了 Postgres 中的 CTE,并且可以使用 Postgres 中的一些 JSON 处理功能。权衡是,我们的 SQL 可能变得不太便携,但 JSON 处理在 MySQL 等主要数据库中也是可用的,只需稍作修改。以下列表显示了更新的 SQL。

列表 10.26 使用 Postgres CTE 获取公司数据
WITH company_info AS (
   SELECT c.company_id, c.ach_company_id, c.name, c.tax_id_type,
          c.tax_id_number, c.duns, c.logo, c.website, c.industry
     FROM companies AS c
    WHERE c.company_id = %s
),
addresses AS (
   SELECT ca.company_id, #1
          JSON_AGG(   #1
             JSON_BUILD_OBJECT(  #1
                'company_address_id', ca.company_address_id,  #1
                'address_type', ca.address_type,  #1
                'address_line_1', ca.address_line_1,  #1
                'address_line_2', ca.address_line_2,  #1
                'address_line_3', ca.address_line_3,  #1
                'address_line_4', ca.address_line_4,  #1
                'city', ca.city,  #1
                'state', ca.state,  #1
                'zip_code', ca.zip_code,  #1
 'zip_code_4', ca.zip_code_4 #1
  )  #1
          ) AS addresses #1
     FROM company_addresses AS ca  #1#2
     WHERE ca.company_id = %s #3
     GROUP BY ca.company_id #4
),
phones AS ( #5
   SELECT cp.company_id, 
          JSON_AGG( 
             JSON_BUILD_OBJECT( 
                'company_phone_id', cp.company_phone_id, 
                'phone_type', cp.phone_type, 
                'phone_number', cp.phone_number, 
                'extension', cp.extension
             ) 
          ) AS phones
     FROM company_phones AS cp #6
     WHERE cp.company_id = %s
     GROUP BY cp.company_id
) 
SELECT ci.company_id, ci.ach_company_id, ci.name,
ci.tax_id_type,  ci.tax_id_number, ci.duns, 
ci.logo, ci.website, ci.industry, 
COALESCE(a.addresses, '[]'::json) AS addresses, #7
COALESCE(p.phones, '[]'::json) AS phones
FROM company_info AS ci
LEFT JOIN addresses AS a ON ci.company_id = a.company_id
LEFT JOIN phones AS p ON ci.company_id = p.company_id;

1 使用JSON_AGGJSON_BUILD_OBJECT创建公司的地址数组

2 使用company_addresses

3 仅选择我们公司的记录

4 按公司 _id 对记录进行分组

5 使用JSON_AGGJSON_BUILD_OBJECT创建公司的电话数组

6 使用 JSON_AGG 和

JSON_BUILD_OBJECT 创建公司的电话数组

7 使用 COALESCE 在表中没有记录时返回一个空的 JSON 数组

应用程序变得更加流畅,如下面的列表所示。我们可以使用 ** 将返回的 JSON 解包成传递给相应对象的键值参数,从而创建一个元素列表。

列表 10.27 返回数据的 Python 代码
        row = result.fetchone()

        if not row:
            raise KeyError(f"Record with id {company_id} not found")

        addresses = [Address(**address) for #1
➥ address in row["addresses"]]  #2
        phones = [Phone(**phone) for phone in row["phones"]] 

        company_data = {
            "company_id": row["company_id"],
             …
            "addresses": addresses, #2
            "phones": phones, 
        }

        return CompanyDetailRecord(**company_data) #3

1 遍历返回的数据以创建适当的数组

2 将它们分配给 company_data 哈希

3 使用我们创建的哈希创建一个 CompanyDetailRecord 对象

在选择一条路径而不是另一条路径时,应始终考虑几个因素。此选择可能取决于应用程序和数据。对应用程序进行配置文件分析以及在 SQL 查询上执行 EXPLAIN ANALYZE 是确定哪种方法表现更好的几种方法之一。还涉及人为因素;它考虑了哪个版本更清晰且更容易维护。无论如何,我们现在可以检索并显示客户数据!

公司信息表挑战

由于我们正在预填充公司信息,并且完成表单涵盖了之前章节中布置的大部分材料,我们认为这是另一个很好的机会停下来,制作一个表单来显示公司信息。你可以从头开始创建,找到一个模板,或者使用生成式 AI 来看看是否可以通过这种方式更快地完成。使用生成式 AI 是一个很好的开始方式。尝试向 SQL 表或为字段定义的接口提供数据,看看它是否可以生成样板代码。

在基本表单就位后,我们转向显示最近公司批次的组件。

10.5 最近批次组件

当我们导航到公司仪表板时,我们希望能够了解公司的活动情况。查看为公司找到的批次是一种很好的查看活动的方式。能够提取批次信息将需要我们了解 ACH 批次布局的某些知识,因为我们需要从批次标题(类型 5)和批次控制(类型 8)记录中收集一些信息。图 10.3 显示了我们将要构建的组件示例。

公司简介截图  自动生成的描述

图 10.3 最近公司批次

要构建最近批次组件,我们遵循三个简单的步骤:

  1. 在 FastAPI 中创建一个 API 端点。

  2. 编写 SQL 查询以收集所需数据。

  3. 在 MUI DataGrid 中显示返回的数据。

10.5.1 实现最近批次 API

虽然我们可以构建一个与公司其他功能相关的端点,但我们将实现这个功能作为搜索。虽然这样做确实是我们个人方法的偏好,但我们认为,而不是有像/companies/{company_id}/batches这样的东西,如果我们只创建一个类似于上一章中交易搜索的端点,我们将获得更多的灵活性。因此,我们创建了一个/api/v1/files/batches/search的端点并使用它。下面的列表来自 files.py 生成了我们的 API 端点。

列表 10.28 批次搜索端点
@router.get(
…
)
@log_message("Retrieving ACH batches")
async def search_ach_batches(
    request: Request,
    criteria: str = Query(..., description="Search criteria for batches"),
) -> list[BatchSearchResponse]:
    return BatchSearchSql().get_batches(criteria)

到现在为止,端点应该是直接的。get_batches例程遵循与我们交易搜索中非常相似的流程。我们从一开始就支持几种不同的搜索方法,因为我们忽略了第三章中的 YAGNI 原则。虽然这通常是我们要避免的事情,但规则是为了被打破的。因为这个例程是基于交易搜索的,所以将其编码成可用形式不会花费太多精力(当然,测试又是另一回事)。下面的列表显示了初始的get_batches方法。虽然这些模式将来可能会很有用,但现在我们感兴趣的是当我们不匹配任何之前的模式时,我们将使用公司的 UUID 进行搜索。回想一下,当我们处于公司仪表板时,我们在 URL 中就有公司的 UUID。所以,这将很容易作为查询参数传递给搜索。

列表 10.29 get_batches方法
    def get_batches(self, criteria: str) -> list[BatchSearchResponse]:
        amount_pattern = r"^\d+\.\d{2}$"
        multiple_amounts_pattern = r"(^\d+\.\d{2})\s+(\d+\.\d{2})$"
        company_id_pattern = r"^\d+$"
        company_name_pattern = r"^[a-zA-Z1-9\s]+$"
        if re.match(amount_pattern, criteria): #1
            return self._get_batches_using_amount(criteria)  #2
        elif match := re.match(multiple_amounts_pattern, criteria):  #2
            begin_amount, end_amount = match.groups()  #2
            return self._get_batches_using_amount_range(begin_amount,  #2
 end_amount)  #2
        elif re.match(company_id_pattern, criteria):  #2
            return self._get_batches_using_company_id(criteria)  #2
        elif re.match(company_name_pattern, criteria):  #2
            return self._get_batches_using_company_name(criteria)  #2
        else:  #2
            return self._get_batches_using_company_uuid(criteria)  #2

1 我们创建了使用许多不同方法搜索批次的能力,尽管我们现在只对通过 UUID 进行搜索感兴趣。

_get_batches_using_company_uuid的调用很简单。就像我们之前的搜索查询一样,我们已经将选择逻辑从查询中提取出来到一个共享方法中,因为每个这些搜索方法都共享相同的选择逻辑,只是在过滤结果的方式上有所不同。

当然,选择标准将是这次搜索工作的最大和最重要的部分。查询需要汇总几个不同的表来构建搜索响应。列表 10.30 显示了我们所使用的 SQL 语句。请注意,大多数表都是使用INNER JOIN,但公司表使用的是LEFT JOIN,因为无法保证公司名称存在于我们的数据库中。

我们知道 ACH 文件可以包含来自可能或可能不是我们客户的公司的批次。因为完全有可能我们的搜索结果可能返回一个指定公司未在我们的数据库中定义的批次,所以查询可能无法返回任何结果。我们使用 Postgres 的COALESCE命令从公司表中提取名称。如果它是NULL,我们将使用批次标题中的记录,最后是一个空字符串。下面的列表显示了 SQL 查询。

列表 10.30 SQL 查询
SELECT art1.ach_files_id AS file_id,
       art5.ach_records_type_5_id AS batch_header_id,
       af.file_name AS filename,
       COALESCE(c.name, abh.company_name, '') AS company_name, #1
       abh.company_identification AS company_identification,
       abcr.total_credit_entry_dollar_amount 
                          AS total_credit_entry_dollar_amount,
       abcr.total_debit_entry_dollar_amount 
                          AS total_debit_entry_dollar_amount,
       abcr.entry_addenda_count AS entry_addenda_count
  FROM ach_files af
INNER JOIN ach_records_type_1 art1 USING ( ach_files_id )
INNER JOIN ach_records_type_5 art5 USING ( ach_records_type_1_id )
INNER JOIN ach_records_type_8 art8 USING ( ach_records_type_5_id )
INNER JOIN ach_batch_headers abh USING ( ach_records_type_5_id )
INNER JOIN ach_batch_control_records abcr USING ( ach_records_type_8_id )    
 LEFT JOIN companies c ON #2
 abh.company_identification = c.ach_company_id #3

1 我们使用我们在数据库中拥有的名称,就像在批次标题上显示的那样,或者一个空字符串。

2 我们使用 LEFT JOIN,但必须使用 ON 而不是 USING,因为我们想要连接的字段名称并不相同。

我们查询的结果存储在api/batch_search_response.py中的BatchSearchResponse对象中,它是一个标准的 Pydantic 数据模型,我们不会详细说明,因为我们已经定义了相当多的这些模型。

10.5.2 显示最近批次

这个 UI 组件很简单,负责通过 API 调用获取数据并在 DataGrid 中显示结果。需要考虑的主要问题是,我们是否真的希望组件负责进行 API 调用。我们在处理不同组件时改变了我们的方法。当多个组件在页面上并且使用相似的数据时,我们将数据传递给组件以最小化服务器调用,希望减少延迟并提供更好的用户体验。在这种情况下,数据是针对此组件的,所以我们保持所有内容都在组件中。我们可以设想,如果我们允许用户在仪表板上自定义组件,这可能会成为问题,但需要进行一些重新设计,我们将在到达那里时再解决。

列表 10.31 展示了 UI 组件的基本知识。我们使用 MUI DataGrid 的renderCell属性来提供可点击的视图链接,这些链接可以直接将用户带到文件或批次。我们还在需要格式化货币时使用formatCurrency方法。此外,请注意我们如何使用带有传递的companyId参数的我们的搜索 API。因此,在这种情况下,搜索是由程序驱动的,而不是像搜索交易时那样通过用户输入来完成的。

列表 10.31 RecentCompanyBatches组件
interface RecentCompanyBatchesProps {
    companyId?: string;
}

export default function 
➥RecentCompanyBatches({companyId}: Readonly<RecentCompanyBatchesProps>) {

    const router = useRouter(); #1
    const [results, setResults] =  #2
➥ useState<AchBatchSearchResponse[]>([]); 

    useEffect(() => {

        if (!companyId) {
            return;
        }

        const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? '';
        axios.get<AchBatchSearchResponse[]>(`${apiUrl}/files/ #3
➥batches/search?criteria=${companyId}`, {  #3
…  #3
    }, [companyId]); 

    const columns: GridColDef[] = [
       {field: 'file_id', headerName: 'File', 
        sortable: false, width: 50, renderCell: (params) => (
            <Link onClick={() => #4
router.push(`/fileDetails/${params.row.file_id}`)}  #5
    sx={{ cursor: 'pointer' }}>View</Link> 
            )},
        {field: 'batch_header_id', headerName: 'Batch',
         sortable: false, width: 50, renderCell: (params) => (
            <Link onClick={() => #5
router.push(`/fileDetails/${params.row.file_id}/batchDetails/  #5
   ${params.row.batch_header_id}`)}   #5
         sx={{ cursor: 'pointer' }}>View</Link>  #5
…
        {field: 'total_credit_entry_dollar_amount', 
            headerName: 'Total Credit', width: 125, 
            renderCell: (params) => ( #6
               formatCurrency(Number(  #6
                  params.row.total_credit_entry_dollar_amount))  #6
            )},  #6
        {field: 'total_debit_entry_dollar_amount',
         headerName: 'Total Debit', width: 125, 
         renderCell: (params) => (
            formatCurrency(Number(params.row.total_debit_entry_dollar_amount))
            )},
    ];

    return (
…
       <Title>Recent Company Batches</Title>
       <DataGrid rows={results}  #7
                 columns={columns}   #8
                 getRowId={(row: any) => row.batch_header_id}/>  #8
…

1 useRouter使组件能够导航到其他页面。

2 存储搜索响应

3 使用 Axios 从我们的端点检索数据

4 创建一个超链接,允许我们导航到包含批次的文件

5 创建一个超链接,允许我们直接导航到批次

6 重复使用我们的formatCurrency程序

7 将所需字段传递给创建我们的网格;我们还需要指定行的 ID,因为我们没有名为 ID 的字段。

现在,我们可以向用户展示公司的当前活动,这非常有用,并提供了一个一目了然的用户体验。如果客户需要 ACH 支持,他们很可能是关于最近发生的事情。能够快速跳转到特定的文件将非常重要。下一个组件将提供另一种帮助客户跟踪他们的 ACH 文件的方法。

10.6 预期文件组件

我们公司仪表板中另一个有用的组件是允许用户跟踪公司任何预期 ACH 文件的组件。公司可能希望金融机构按照一定的日程安排处理文件,如果特定的文件没有到达,那么就需要有人被提醒并跟进。例如,如果金融机构的客户期望在 15 号看到他们的工资单,那么银行最好确保在那天之前处理完毕。

此组件显示一个简单的列表和文件是否已加载的指示器。我们将使用文件名作为文件是否已加载的基础。对于金融机构、数据中心和类似机构来说,依赖命名约定来帮助促进各种检查和平衡是很常见的,因此在我们的组件中使用文件名并不是我们仪表板中独特的方法。当然,我们也可以扩展条件以匹配任何可能从 ACH 角度有用的其他约束——无论是公司 ID 还是批次的总数。

尽管我们是从公司角度来接近这个问题,但也会从更一般的角度(例如,金融机构也会期望按照一定的日程安排文件)来考虑。图 10.4 展示了组件的一个示例。

计算机屏幕截图,描述自动生成

图 10.4 期望文件组件

我们使用以下步骤构建期望文件组件:

  1. 定义数据库表和类型。

  2. 定义 API 端点。

  3. 使用 MUI DataGrid 显示数据。

10.6.1 存储期望文件

对于我们的数据库,我们创建了一个名为company_expected_files的另一个数据库表来存储文件名列表。我们之前也创建了一个SCHEDULE_TYPE来限制字段中的可能值。该表将只负责持有确定文件是否应该被找到所需的数据。可能存在外部作业会做出决定并在表中更新标志的情况。在我们的情况下,这不是必需的,我们可以依赖 API 调用返回额外的标志。

下面的列表显示了创建表格和类型的步骤。我们还使用了last_file_date,这个日期需要维护并调整。我们需要修改我们的文件上传逻辑来更新这个字段。

列表 10.32 创建期望文件的类型和表
CREATE TYPE schedule_type AS ENUM ('daily', 'weekly', 'bi-weekly',
       'monthly', 'quarterly', 'semi-annually', 'annually');

CREATE TABLE company_expected_files (
    company_expected_files_id UUID DEFAULT uuid_generate_v4(), #1
    company_id UUID NOT NULL REFERENCES companies(company_id)
    ON DELETE CASCADE ON UPDATE CASCADE,
    file_name VARCHAR(255) NOT NULL,
    schedule SCHEDULE_TYPE NOT NULL DEFAULT 'daily',
    last_file_date TIMESTAMP DEFAULT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
    PRIMARY KEY (company_id, file_name) #2
);

1 虽然不是必需的,但我们定义了一个 UUID,记录可以通过它来引用。

2 关键实际上是公司和文件名,因为我们只想每个公司有一个文件名。

我们采取类似的步骤向数据库中填充一些数据,以查看组件的实际运行情况。列表 10.33 显示了company_expected_files表的INSERT操作。我们可以通过使用SELECT语句根据公司的tax_id_number选择company_id字段来为每个公司插入这些记录。请记住,company_id是在记录插入时由数据库动态设置的。我们也可以选择使用已知的 UUID 插入记录,但使用前面的方法允许我们以我们预期在生产中看到的数据方式工作,并为我们没有在测试中插入已知字段的情况做好准备。

列表 10.33 向预期的文件表插入记录
INSERT INTO company_expected_files ( company_id, file_name,
            schedule, last_file_date )
VALUES (
   (SELECT c.company_id FROM companies AS c  #1
             WHERE tax_id_number = '345678901'), 
   'stellar_services.ach',
   'daily',
   NOW() - INTERVAL '1 day'
);

1 通过搜索公司的税号来获取company_id

在设置好数据库后,我们现在可以着手构建将驱动ExpectedFiles组件的逻辑。

10.6.2 实现预期的文件 API

当预期的文件数据加载到数据库中后,我们需要能够检索它,并且当文件上传时,我们还需要能够更新last_file_date

我们首先进行的是构建所需 SQL 逻辑的测试。同样,我们利用 Testcontainers 在测试期间运行我们的docker_compose容器。

列表 10.34 展示了我们的测试文件的基本结构。作为测试的一部分,我们创建了一个基本的CompanySearchSql类,用于通过公司的tax_id_numberach_company_id编号来搜索公司。需要此类的一个原因是因为数据库动态分配 UUID。为了避免范围蔓延,我们只构建了足够的功能来获取单个公司,但它可以很容易地集成到搜索页面或公司页面,这样当我们有数百或数千个客户时,我们不必逐页浏览它们。

列表 10.34 测试ExpectedFilesSql
@pytest.fixture(scope="module")
def docker_compose():
    absolute_path = Path("../../../../../docker/db").resolve()
    with DockerCompose(absolute_path, build=True, #1
                       services=["postgres"])  #2
 as compose:  #2
        compose.start() #2
        yield compose #2
        compose.stop() 
…
    def test_get_expected_files_for_company(self, docker_compose, sql):
        company_id =  #3
CompanySearchSql().get_companies("459876543").company_id #4
        assert company_id is not None #4
        results = sql.get_expected_files_for_company(company_id) #5
        assert len(results) == 2 #6
        assert any(  #7
            record.file_name == "elemental_resources.ach"  #7
                                            for record in results #7
        )  #7
        assert any(  #7
            record.file_name == "elemental_resources_daily_billpay.ach"  #7
                                            for record in results #7
        )  #7

1 使用 Testcontainers 和 DockerCompose 构建容器并仅启动 Postgres 服务。对于临时测试,无需启动 CloudBeaver。

2 确保容器为我们的测试启动和停止

3 使用CompanySearchSql获取公司 UUID

4 确保我们确实找到了某些内容

5 运行我们想要测试的 SQL

6 进行必要的断言以确保方法有效

在我们的单元测试就绪后,我们可以自由构建get_expected_files_for_company背后的逻辑。SQL查询创建了两个不直接存储在数据库中的额外字段。这些字段是一个布尔值,表示文件是否已加载,以及next_file_date,这是我们根据文件的计划预期下次看到文件的时间。目前,我们不考虑按日期过滤我们的结果,因为这会使测试变得稍微困难一些。因此,我们返回所有数据。这并不一定对组件有意义,因为当前的目标是显示今天预期的文件。然而,这足以展示功能意图。我们可以扩展此组件或创建一个新的组件,显示下周的文件,获取预期的文件数量,等等。SQL查询使用了INTERVAL函数,这使得计算给定的时间间隔变得容易。以下列表显示了SQL语句。

列表 10.35 为预期文件组件的 SQL 语句
WITH future_file_dates AS ( #1
   SELECT ef.company_expected_file_id,  #1
       ef.company_id,  #1
       ef.file_name,  #1
       CASE ef.schedule #1
          WHEN 'daily' THEN ef.last_file_date + INTERVAL '1 day' #1
          WHEN 'weekly' THEN ef.last_file_date + INTERVAL '1 week' #1
          WHEN 'bi-weekly' THEN ef.last_file_date + INTERVAL '2 weeks'  #1
          WHEN 'monthly' THEN ef.last_file_date + INTERVAL '1 month'  #1
          WHEN 'quarterly' THEN ef.last_file_date  #1
                                               + INTERVAL '3 months'  #1
          WHEN 'semi-annually' THEN ef.last_file_date   #1
                                               + INTERVAL '6 months'  #1
          WHEN 'annually' THEN ef.last_file_date + INTERVAL '1 year'  #1
          ELSE ef.last_file_date  #1
       END AS next_file_date  #1
  FROM company_expected_files AS ef  #1
 WHERE ef.company_id = %s  #1
)  #1
SELECT ef.company_expected_file_id, ef.file_name, ef.schedule, #1
    ef.last_file_date, ffd.next_file_date,
    CASE #2
       WHEN af.file_name IS NOT NULL   #2
            AND af.created_at::date = ef.last_file_date::date   #2
               THEN TRUE #2
       ELSE FALSE  #2
    END AS file_loaded  #2
   FROM company_expected_files AS ef
INNER JOIN future_file_dates AS ffd USING (company_expected_file_id)
 LEFT JOIN ach_files AS af ON ef.file_name = af.file_name
     WHERE ef.company_id = %s;

1 使用 CTE(公用表表达式)确定文件下次预期的日期

2 确定文件是否已加载

最后,我们修改文件的发布以更新表中的last_file_dateupdated_at字段。这种方法目前效果良好——当预期文件加载时,日期将相应增加,一切将正常工作。然而,当文件未上传时会出现问题。也许预期的文件那天不需要加载——可能那天是公司假日。因此,许多金融机构在周末不处理文件。当文件在周五加载时,可能需要将下次预期的日期调整为周一而不是周六。处理这些边缘情况或满足用户额外需求有助于使我们的软件更加健壮。以下列表显示了为预期文件更新必要日期的添加。

列表 10.36 更新预期文件的日期
    parser = AchFileProdates
    ach_file = AchFileSchema(file_name=file.filename, 
file_hash=file.content_type)
    ach_files_id = AchFileSql().insert_record(ach_file)
    try:
        print(f"Processing file {temp_file.name}")
        seq = parser.parse(ach_files_id, temp_file.name)
 ExpectedFilesSql().update_last_file_date(file.filename) #1
    except Exception as e:
        print(e)
        return {"error": "Invalid file"}
    return {
        "filename": file.filename,
        "content_type": file.content_type,
        "last_seq": seq,

1 一旦文件解析完成,我们就可以更新日期。

更新日期的逻辑目前可以保持简单。updated_at仅用于审计目的,用于确定文件上传的时间。文件上次上传的时间也是在更新时,因此我们使用NOW函数进行两次更新。以下列表显示了update_last_file_date方法使用的简单更新逻辑。

列表 10.37 简单的更新逻辑
   UPDATE company_expected_files
                       SET last_file_date = NOW(), #1
                           updated_at = NOW() 
                     WHERE file_name = %s

1 更新表以指示文件已上传

如果我们希望始终保持最后文件的日期设置为星期一,而处理工作落在周末,事情将会变得更加复杂。这是一个重要的问题,因为金融机构可能在周末(或联邦假日)不开放,而且在这些日子里处理文件可能并不合适。我们当然不希望只在周日午后享受时光,却突然收到大量警报,显示文件丢失!以下列表假定有一个日间隔,但我们可以根据需要轻松地改变这种动态。

列表 10.38 根据需要默认设置为星期一
WITH adjusted_date AS (
    SELECT
        NOW() + INTERVAL '1 day' AS initial_date
)
UPDATE company_expected_files
        SET last_file_date = (SELECT CASE #1
                           WHEN EXTRACT(DOW FROM initial_date) = 0 THEN   #2
initial_date + INTERVAL '1 day' #2
                           WHEN EXTRACT(DOW FROM initial_date) = 6 THEN  #2
initial_date + INTERVAL '2 day'  #2
                           ELSE initial_date  #2
                         END
                            FROM adjusted_date),
            updated_at = NOW()
      WHERE file_name = %s

1 如果日期落在星期日或星期六,则添加适当的天数以强制将其变为星期一。请注意,如果星期一是一个假日且金融机构关闭,这就不起作用了。这需要额外的努力。

虽然我们并不特别关注解决last_file_date字段,但我们应该开始看到与计划任务可能出现的某些问题,尤其是在我们必须考虑文件未加载时的情况。我们的应用程序可能不需要直接处理这个问题,而且——讽刺的是——可能还有另一个计划任务需要运行并清理数据库。目前,我们可以从数据库中检索所需的数据,因此让我们继续创建组件的 UI 部分。

10.6.3 显示预期文件

重量级的工作应该在后端完成。UI 组件只需显示信息和返回响应作为部分返回的file_loaded布尔值相关的图标。UI 组件最有趣的部分(考虑到我们现在已经做了很多次)是我们用来决定是否显示TaskAltRadioButtonUnchecked图标的条件表达式。否则,它就是典型的将组件添加到 page.tsx 页面并让组件检索必要数据并在 DataGrid 中显示数据的流程。以下列表显示了 UI 组件。

列表 10.39 ExpectedFiles组件
…
interface ExpectedFilesProps {
    companyId?: string;
}
export default function ExpectedFiles({companyId}:
➥ Readonly<ExpectedFilesProps>) {

    const [results, setResults] = useState<CompanyExpectedFilesResponse[]>([]);
…
  axios.get<CompanyExpectedFilesResponse[]>
➥(`${apiUrl}/companies/${companyId}/expect…

    const columns: GridColDef[] = 
        {field: 'file_loaded', headerName: 'Loaded', width: 75,
         renderCell: (params) => (
                params.value ? <TaskAlt sx={{color: "green"}} /> : #1
<RadioButtonUnchecked /> 
…
        {field: 'file_name', headerName: 'File Name', width: 200},
        {field: 'schedule', headerName: 'Schedule', width: 75},
…
            field: 'last_file_date', headerName: 'Last File Date',
            width: 100,
            renderCell: (params) => (
                params.value ? new Date(params.value as 
➥ string).toLocaleDateString() : ''
…
            field: 'next_file_date', 
            headerName: 'Next Expected Date', width: 150,
            renderCell: (params) => (
                params.value ? new Date(params.value as
➥ string).toLocaleDateString() : ''
…

    return (
…
                    <Title>Expected Files</Title>
                    <DataGrid rows={results} columns={columns}
                     getRowId={(row: any) =>
                     row.company_expected_file_id}/>

1 我们使用三元表达式来确定要显示哪个图标。

这就完成了预期文件组件。我们首先定义了所需的数据库表和自定义类型以支持预期文件的调度。然后我们使用 Testcontainers 创建了一个单元测试,以便每次运行都能获得一个新的数据库。这种方法使我们能够确定返回我们组件所需数据的 SQL。最后,我们从我们的 UI 中调用 API,并格式化数据以显示给用户。

接下来,我们希望添加另一个组件,可以帮助公司监控和控制可疑活动,使用公司限制。

10.7 公司限制组件

此组件围绕公司的主动监控。我们希望公司能够设置触发警报并可能阻止达到这些限制的交易的限制。这可以帮助公司识别和处理可能超出正常行为的交易或活动。

![信用卡截图 自动生成的描述

图 10.5 公司限制组件

作为一家企业,如果你预计一天内的借记活动少于,比如说,10,000 美元,而突然,你的公司有 15,000 美元的交易批次正在发布,你很可能希望被通知这一活动。原则上,这是一个简单的组件,列出了当天的总额,并将其与公司或银行设定的限制进行比较——这是需要在未来的冲刺中构建的功能。在实践中,我们需要处理向客户发出警报,允许在公司在超出限制的情况下进行处理,否则拒绝文件加载。图 10.5 显示了我们将要实施的公司限制组件。

我们按照以下步骤创建此组件:

  1. 创建数据库表。

  2. 定义 API 端点。

  3. 显示返回的信息

    供用户使用。

10.7.1 存储公司限制

我们需要一个新表来存储公司的限制。与之前的表一样,此表将使用外键,该外键引用来自 companies 表的公司 ID。记住,外键的使用帮助我们维护数据库的引用完整性。ON DELETE CASCADEON UPDATE CASCADE将确保在删除公司或相应地更新时删除记录。并非每个公司都需要限制,因此我们在创建使用此表的查询时要注意这一点。

列表 10.40 公司限制表
CREATE TABLE company_limits
(
    company_limit_id   UUID               DEFAULT uuid_generate_v4(),
    company_id         UUID      NOT NULL REFERENCES 
                                 companies (company_id) 
                   ON DELETE CASCADE ON UPDATE CASCADE,
    daily_debit_limit  NUMERIC(12, 2)     DEFAULT NULL,
    daily_credit_limit NUMERIC(12, 2)     DEFAULT NULL,
    created_at         TIMESTAMP NOT NULL DEFAULT NOW(), #1
    updated_at         TIMESTAMP NOT NULL DEFAULT NOW() 
);

1 跟踪记录的创建和更新时间可以帮助审计。

实现此功能有几种方法,我们将使用一种基本方法,可以根据需要扩展。基本方法将不需要我们存储总额。相反,我们依赖 API 来收集总额并确定是否超出。这意味着我们的逻辑将在公司仪表板显示时以及文件加载完成后执行,以便我们可以添加相关的异常。记住,从第八章中我们知道,如果我们想添加异常,我们必须将其添加到数据库中,如果需要,还可以添加任何恢复选项。以下列表显示了如何更新 general_ach_data.sql 以插入这些记录。

列表 10.41 设置异常数据
INSERT INTO ach_exception_codes (exception_code, #1
    exception_severity, exception_description)  #1
VALUES ('006', 'warning',   #1
 'Company Limits  Exceeded');  #1
…
INSERT INTO ach_recovery_options #2
       (exception_code, recovery_option)   #3
VALUES ('006', 'Alert company their limit has been exceeded'); 

1 我们可以为我们的公司限制条件添加一个新的异常代码。

2 当遇到异常时,为用户提供一些指导是有帮助的。

我们还设置了公司限制的数据,以便数据库中已有数据。以下列表显示了简单地将数据插入到数据库表中。

列表 10.42 插入公司限制记录
INSERT INTO company_limits 
   ( company_id, daily_debit_limit, daily_credit_limit)
VALUES (
        (SELECT c.company_id FROM companies AS c 
              WHERE tax_id_number = '459876543'),
        400.00,
        750.00
       );

这样我们就应该能够启动并运行公司限制了。下一节将展示如何更新 API,以便您可以与该表一起工作。

10.7.2 实现公司限制 API

花一分钟时间思考一下我们希望通过公司限制 API 实现什么。当然,我们需要能够检索出限制设置为何,但我们还希望确定公司当前的总数以及一个布尔标志来指示是否超过了限制。正如之前提到的,在设计数据库表时,我们决定不存储借方和贷方的当前总数,尽管我们当然可以创建这些字段并在加载时更新它们。

在这种情况下,一个潜在的缺点是,根据我们决定何时何地执行此检查,我们可能有限的能力来缩小用户的问题。我们应该花点时间思考异常可以写入的位置。例如,我们就在文件加载后立即添加了对预期文件的检查。我们应该把检查放在同一个地方吗?这可能就是我们的第一反应,因为我们刚刚添加了类似的代码。然而,把检查放在那里,我们就无法识别出导致公司限制超过的批次,因为我们当时没有访问记录号,这还使得在异常上放置实际的批次标题变得困难。以下列表显示了我们在处理批次控制记录时包含了异常。

列表 10.43 添加异常
case "8":
    ach_record = AchRecordType8Schema(
        ach_records_type_5_id=current_batch_header_id,
            unparsed_record=line,
            sequence_number=sequence_number,
         )
    ach_record_id = AchRecordsSqlType8().insert_record(ach_record)
    self.last_trace_number = None
    self._parse_batch_control(ach_record_id, line)
    if CompanyLimitsSql(). #1
➥file_exceeds_company_limits(ach_file_id): 
        AchExceptionsSql().insert_record( #2
            AchExceptionSchema( #3
                ach_files_id=ach_file_id,  #4
                ach_batch_id=current_batch_header_id,  #4
                record_number=sequence_number,  #4
                exception_code=  #4
                   AchExceptions.COMPANY_LIMITS_EXCEEDED.value,  #4
            )  #4
        ) 

1 检查是否超过了公司限制

2 从 AchExceptionsSql 调用 insert_record 方法来插入记录

3 这是我们标准的异常架构。这是我们第一次实际使用 ach_batch_id,尽管我们有很多异常需要它。

当文件超过公司限制时,我们记录新的COMPANY_LIMITS_EXCEEDED异常。这意味着我们需要定义CompanyLimitsSql类和一个方法来确定文件是否超过公司限制。我们可以从方法的使用中看出,它只需要返回一个布尔值来决定是否引发异常。该方法本身很简单,包括通常的获取连接、运行 SQL 查询和获取结果。以下列表显示了方法细节。

列表 10.44 确定文件是否超过公司限制
with get_db_connection(row_factory=class_row(CompanyLimitsRecord)) as conn:
   result = conn.execute(
   """
      WITH ach_batch_totals AS ( #1
         SELECT
                c.company_id,
                SUM(abcr.total_credit_entry_dollar_amount) #2
                                     AS current_credit_total,  #2
                SUM(abcr.total_debit_entry_dollar_amount)   #2
                                     AS current_debit_total 
           FROM ach_files AS af
     INNER JOIN ach_records_type_1 AS art1 USING (ach_files_id)
     INNER JOIN ach_records_type_5 AS art5 USING (ach_records_type_1_id)
     INNER JOIN ach_records_type_8 AS art8 USING (ach_records_type_5_id)
     INNER JOIN ach_batch_headers AS abh USING (ach_records_type_5_id)
     INNER JOIN ach_batch_control_records AS abcr 
        USING (ach_records_type_8_id)
     INNER JOIN companies AS c 
        ON c.ach_company_id = abh.company_identification
          WHERE af.ach_files_id = %s
       GROUP BY c.company_id
   )
   SELECT 1 #3
   FROM ach_files AS af
   INNER JOIN ach_records_type_1 AS art1 USING (ach_files_id)
   INNER JOIN ach_records_type_5 AS art5 USING (ach_records_type_1_id)
   INNER JOIN ach_records_type_8 AS art8 USING (ach_records_type_5_id)
   INNER JOIN ach_batch_headers AS abh USING (ach_records_type_5_id)
   INNER JOIN companies AS c ON c.ach_company_id = abh.company_identification
   INNER JOIN company_limits AS cl USING (company_id)
    LEFT JOIN ach_batch_totals AS abt USING (company_id)
        WHERE af.ach_files_id = %s  #4
          AND ( abt.current_credit_total > cl.daily_credit_limit #5
                OR abt.current_debit_total > cl.daily_debit_limit ) 
        LIMIT 1
   """,
   [file_id, file_id],
   )
return result.fetchone() is not None #5

1 创建一个 CTE 来选择批次总数

2 求借方和贷方的总和

3 我们只需要返回一些内容。

4 选择文件并求和

5 我们希望返回一个布尔值,所以“is not None”帮助我们返回 True/False。

之前的列表帮助我们确定是否需要编写异常,但我们还需要定义一个将用于 UI 的方法。这与之前的 SQL 类似,增加了实际的限制字段、当前总额和指示是否超过限制的标志。我们更喜欢返回显式的标志供 UI 使用,而不是让 UI 自己做出判断。以下列表显示了用于返回get_company_limits方法所需信息的 SQL。

列表 10.45 查询总额、限制和标志
WITH ach_batch_totals AS (
    SELECT
        c.company_id,
        SUM(abcr.total_credit_entry_dollar_amount) 
                                AS current_credit_total,
        SUM(abcr.total_debit_entry_dollar_amount) 
                                AS current_debit_total
    FROM ach_records_type_5 AS art5
    INNER JOIN ach_records_type_8 
                         AS art8 USING (ach_records_type_5_id)
    INNER JOIN ach_batch_headers 
                         AS abh USING (ach_records_type_5_id)
    INNER JOIN ach_batch_control_records 
                         AS abcr USING (ach_records_type_8_id)
    INNER JOIN companies AS c 
              ON c.ach_company_id = abh.company_identification 
             AND c.company_id = %s
    GROUP BY c.company_id
)
SELECT cl.company_limit_id,
       cl.daily_debit_limit,
       cl.daily_credit_limit,
       COALESCE(abt.current_credit_total, 0) AS current_credit_total, #1
       COALESCE(abt.current_debit_total, 0) AS current_debit_total, 
       CASE #2
           WHEN abt.current_credit_total > cl.daily_credit_limit   #2
           THEN TRUE #2
           ELSE FALSE #2
       END AS daily_credit_exceeded,  #2
       CASE  #2
           WHEN abt.current_debit_total > cl.daily_debit_limit   #2
           THEN TRUE  #2
           ELSE FALSE  #2
       END AS daily_debit_exceeded  #2
FROM company_limits AS cl
LEFT JOIN ach_batch_totals AS abt USING (company_id)
WHERE cl.company_id = %s

1 返回当前的借记/贷记总额;如果没有找到批量,则默认为零

2 返回标志以指示是否已超过限制

只需做一些简单的维护工作来创建 API 端点和包含 UI 响应的类,但已有大量现有代码可以参考这些任务。在下一节中,我们将完成将公司限制添加到仪表板的工作。

10.7.3 显示公司限制

正如我们在图 10.5 中看到的,组件本身很简单,我们只是试图用最简单的术语向用户传达是否已超过限制。我们确实想通过在超过限制时显示红色金额,否则显示绿色,为组件添加一些视觉吸引力。以下列表显示了设置此功能的基礎。

列表 10.46 公司信用和借记限制
…
        axios.get<CompanyLimitsResponse>(`${apiUrl}/companies
➥/${companyId}/limits`, …
   return (
…
      <Title>Company Limits</Title>
      <Typography component="p" variant="h4">
         Company Credits:
      </Typography>
      <Typography component="p" variant="h6">
      <Typography component="span" 
                  variant="h6" 
                  style={{color: response.daily_credit_exceeded ? #1
                                                'red' : 'green'}}> 
         {formatCurrency(response.current_credit_total)}
      </Typography>
         {" of "}
         {formatCurrency(response.daily_credit_limit)}
      </Typography>
      <Typography component="p" variant="h4">
         Company Debits:
      </Typography>
      <Typography component="p" variant="h6">
      <Typography component="span" 
                  variant="h6" 
                  style={{color: response.daily_debit_exceeded ? #2
                                              'red' : 'green'}}> 
      {formatCurrency(response.current_debit_total)}
   </Typography>
      {" of "}
      {formatCurrency(response.daily_debit_limit)}
…

1 当字段被超过时,将字段着色为红色

2 当字段在范围内时,将字段着色为绿色

公司界面中限制的其他方面已经就绪,因为我们正在使用现有的异常逻辑。这意味着在查看仪表板时,文件会自动标记为存在异常,并且在查看异常时,异常会被返回到 DataGrid 中。这确实为查看此公司限制的异常带来了一些问题。我们看到 ACH 批量控制记录(类型 8),但无法轻易判断它处理的是哪家公司。尽管我们在创建异常时保存了批量头 UUID 作为一部分,但这是我们第一次使用它。如果能更新我们的代码,在查看异常时包含一些批量头信息,那就太好了。让我们利用这个机会对异常和异常详情屏幕进行一些修改。

首先,最简单的更改是从异常屏幕中删除错误代码列。从开发者的角度来看,这个动作是有意义的,但它可能会造成混淆,并且对用户体验没有增加任何价值,特别是由于最终用户已经有了代码所转换的描述。

接下来,异常的详情仅包括未解析的记录,它使用了我们为其他目的创建的通用警报组件。现在,我们有一些创建模态来显示详情的经验,就像在第九章中显示审计详情一样,我们可以使用这些技能构建一个更详细的屏幕。我们可以处理显示我们已有的详情(如果我们想的话,可以将错误代码移动到这个屏幕上),以及我们尚未使用的信息,例如恢复操作。图 10.6 显示了我们最终得到的异常详情模态。

计算机屏幕的特写  自动生成的描述

图 10.6 异常详情模态

AchExceptionDetailsResponse 将需要更新,以便返回所需的信息——如果有的话,公司名称和恢复操作。如果我们有权访问记录,当然有机会返回更多信息,但出于对 YAGNI 原则(第三章)的考虑,以及作为 KISS(保持简单,傻瓜)的提醒,我们目前只返回所需的信息。

我们需要做的主要工作是更新 get_exception_detail_response 的 SQL 语句。我们必须引入额外的表来获取公司名称和恢复选项。请注意,我们无法保证有恢复选项,也无法保证有公司名称。这意味着我们将想避免使用 INNER JOIN。相反,我们将使用 LEFT JOIN,就像在之前的案例中,即使后续部分没有匹配的记录,我们也需要保留查询的一侧的行。以下列表显示了需要包含的附加 SQL 逻辑。请注意,由于我们已经保存了解析的标题记录 ID,我们不需要像在之前的查询中那样进行很多连接,那时我们必须从文件级别开始并向下钻取。

列表 10.47 更新以获取更多异常详情
SELECT
       ae.ach_exceptions_id AS id,
       af.created_at AS created_at,
       ae.exception_code AS exception_code,
 aec.exception_description AS description,
  acr.unparsed_record AS unparsed_record,
 COALESCE(c.name, abh.company_name, '') AS company_name, #1
 aro.recovery_option AS recovery_option
FROM ach_exceptions AS ae
INNER JOIN ach_exception_codes AS aec USING (exception_code)
INNER JOIN ach_files AS af USING (ach_files_id)
INNER JOIN ach_combined_records AS acr 
             ON ae.ach_files_id = acr.ach_files_id
             AND ae.record_number = acr.sequence_number
LEFT JOIN ach_recovery_options AS aro #2
 USING (exception_code) #3
LEFT JOIN ach_batch_headers AS abh  #3
 USING(ach_records_type_5_id) #3
LEFT JOIN companies AS c   #3
 ON abh.company_identification = c.ach_company_id #3
WHERE ae.ach_files_id = %s 
AND ae.ach_exceptions_id = %s   

1 这些字段对我们查询来说是新的,如果可用,则选择公司名称和任何恢复步骤。

2 连接新的表。同样,我们需要使用 LEFT JOIN,因为我们无法保证数据将存在于表中。

现在响应中包含了字段,我们可以更新 UI 以包括它们。

列表 10.48 使用 ExceptionsModal
const [exceptionData, setExceptionData] = 
        useState<AchExceptionDetailsResponse | null>(null);
…
            setExceptionData(response.data);

            <ExceptionsModal open={isOpen} #1
                             onClose={() => setIsOpen(false)}   #2
                             exceptionData={exceptionData} /> 
…

1 包含 ExceptionsModal 和所需的参数

ExceptionsModal 负责格式化 exceptionData,并基于我们在第九章中为 AuditModal 所做的操作。如果该组件是一个空字符串,我们不会显示公司名称,并且如果没有任何恢复选项,我们希望提供一个默认的恢复选项“未提供”。根据情况,有时显示一个确认或消息来验证这是一个预期的情况——有点像 21 世纪的“此页有意留白”消息。如果问题不是直接涉及公司(尽管可以提出将批次引用为任何条目异常的理由),则可能不包含公司名称是有意义的。然而,隐藏恢复选项可能会让用户认为出了问题,尤其是当它们可能依赖于参考恢复选项时。以下列表显示了模态的基本内容。由于查询返回异常详情使用的是 COALESCE 函数,我们保证至少返回一个空字符串作为公司名称,因此我们利用这一知识来检查标签和字段是否应该显示。此外,我们使用 ?? 在没有提供任何内容时默认恢复文本。

列表 10.49 ExceptionsModal 组件
…
interface ExceptionModalProps {
  open: boolean;
  onClose: () => void;
  exceptionData: AchExceptionDetailsResponse | null;
}
…
const ExceptionsModal: FC<ExceptionModalProps> = 
      ({ open, onClose, exceptionData }:
➥ Readonly<ExceptionModalProps>) => {
  return (
 …
        { exceptionData?.company_name !== '' && #1
            <Box>  #1
        <Typography id="audit-modal-description"   #1
           sx={{ mt: 2 }}  fontWeight={'fontWeightBold'}>  #1
          Company Name:  #1
        </Typography>  #1
        <Typography>{exceptionData?.company_name}</Typography>  #1
        </Box>  #1
        }  #1
  …
        <Typography> #2
          {exceptionData?.recovery_option ?? 'None Provided'}  #3
        </Typography> 
     <Button onClick={onClose} sx={{ mt: 2 }}>
          Close
        </Button>
…

1 条件性地包含公司名称,如果返回了某些内容

2 总是打印恢复步骤的内容,即使只是让用户知道他们可以自行处理

只需要对异常组件进行轻微调整,以确保数据正确传递到新的模态。以下列表显示了更新的异常组件。这里最大的变化仅仅是传递一个函数,当模态按钮被点击时,该函数将设置打开状态为 False

列表 10.50 异常组件
…    
const [isOpen, setIsOpen] = useState(false);
    const [exceptionData, setExceptionData] = 
              useState<AchExceptionDetailsResponse | null>(null);
        {field: 'file_name', headerName: 'Filename', width: 150},
        {field: 'created_at', headerName: 'Date', width: 150, 
           valueGetter: (params) => convertDateFormat(params.value)},
        {field: 'record_number', headerName: 'Record Number', width: 150},
 {field: 'exception_code', headerName: 'Code', width: 10}, #1
        {field: 'description', headerName: 'Description', width: 300},
…
            <ExceptionsModal open={isOpen}  #2
onClose={() => setIsOpen(false)}  #3
 exceptionData={exceptionData} />  #3
…

1 不再需要,因为它对用户没有提供任何有用的功能

2 包含异常模态

因此,我们现在已经构建了一个公司仪表板,可以提供确保正确/所需文件被加载的信息,显示公司的近期批次,并在活动不符合公司预期时发送警报。

摘要

  • 本章的冲刺计划专注于构建关键组件——公司信息、近期批次、预期文件和公司限制,强调了全面数据结构设计的重要性。

  • 创建了一个公司级仪表板,以解决在初始项目范围之外存储和显示详细公司信息的需求。

  • 强调了构建健壮的公司数据管理系统的重要性,该系统为需要详细批次信息的企业客户提供增强 ACH 处理服务的框架。

  • 将基础公司信息表设计为阻塞器,强调了它们对进一步开发的依赖性,并考虑在早期对某些组件进行工作以减少修改。

  • 生成式 AI 可以快速创建有意义的测试数据,通过生成比典型的姓名和地址信息更多的内容(包括标志),从而提高生产力。

  • 实施公司仪表板展示了组织 UI 结构以实现高效导航和针对特定公司指标的详细数据展示的必要性。

  • 为公司概述创建详细的 UI 组件突出了用户友好界面的重要性,这些界面有助于在公司层面实现无缝导航和交互。

  • 设计用于公司数据检索的 API 强调了结构良好的响应和验证机制对于与仪表板动态用户交互的重要性。

  • 开发最近批次组件突出了灵活的搜索功能对于批量数据检索的重要性,以满足多样化的业务需求。

  • 创建预期文件组件强调了警报机制和调度合规性的必要性,以确保及时处理关键文件并降低风险。

  • 探索对 ACH 文件进行主动监控,以从被动措施转变为主动措施,从而改善客户服务。

  • 公司限制组件强调了可配置警报和阈值,以防止未经授权或意外的财务交易。

  • 扩展异常功能,如警报生成,可以帮助我们在超过限制时通过各种渠道通知客户。

  • 利用 Testcontainers 通过确保隔离环境、消除对开发者特定配置的依赖,以及允许进行强大且可移植的测试,增强了测试可靠性。

第十一章:国际 ACH 交易和 OFAC 扫描

本章涵盖

  • IAT 批次

  • 增强项目以处理 IAT 批次

  • OFAC 列表

  • 扫描 ACH 文件以保持合规

本章涵盖了我们项目迭代的最后冲刺。当然,业务单元将再次聚集进行另一个 PI 规划会议,整个过程将重新开始。在这个冲刺中,我们负责增强项目,使其超越国内 ACH 交易,并处理包含国际 ACH 交易(IAT)的批次,这允许在不同国家的账户之间进行电子转账。除了 IAT 处理外,还需要确保金融机构不会向目前受到美国政府限制或制裁的个人或国家发送交易。具体来说,外国资产控制办公室(OFAC)提供了一份“特别指定国民”名单,也称为 SDN 列表,这是一个资产被冻结且与其交易被禁止的个人和公司注册。

11.1 冲刺规划

在这次最后的冲刺中,业务线提出了一个大的请求,即启用 IAT ACH 交易。在我们的项目原始范围内,我们被告知金融机构不会承担处理国际交易额外风险。因此,数据库没有设计来支持这些类型的交易。然而,最近,金融机构一直在试图吸引更大的商业客户,并且因为这类客户需要能够接收和转移国际资金的能力,所以吸引他们遇到了困难。

正如通常在满足客户需求时发生的那样,我们现在必须更新仪表板以支持这些类型的交易,这意味着向数据库中添加额外的表格,解析文件,以及扫描涉及的客户以保持合规。图 11.1 提供了与这次冲刺相关的拟议任务的进度时间线。能够向其他利益相关者提供时间线,在回答关于我们在冲刺期间将工作的任务和我们的时间表的问题时可能会有所帮助。

软件项目的图示  自动生成的描述

图 11.1 IAT ACH 交易处理时间线

在冲刺规划到位后,我们可以继续进行支持国际 ACH 交易的工作。

11.2 国际 ACH 交易

到目前为止,我们已经处理了预先安排的支付和存款入口(PPD)批次。正如你所知,PPD 批次通常用于工资和养老金的直接存款。我们可能也记得,ACH 在 20 世纪 70 年代初开始作为一种处理国内交易支付的方式。随着 ACH 系统的扩展,对 IAT 的支持始于 2009 年,ACH 的能力也扩展到了国内交易之外。那么,IAT 批次有什么不同之处,以至于我们需要专门一个冲刺来添加处理它们的支持呢?首先,IAT 批次必须符合对所有 ACH 记录施加的 94 个字符限制。正如我们将在本节中看到的,这影响了数据必须以何种方式传输。

11.2.1 IAT 批次:概述

在我们深入支持 IAT 批次之前,我们首先需要了解一个批次可能如何布局。图 11.2 展示了一个我们将要处理的样本批次,以更好地理解涉及的数据。这个批次代表了一个名叫 Elena Santiago 的西班牙毕尔巴鄂人,从她在伊比利亚全球银行的账户向她的朋友 David Wiliams 发送了 1.00 美元的礼物,他的账户在 Futuristic FinTech 的支票账户中。

白色背景,黑色数字和字母  自动生成的描述

图 11.2 样本 IAT 批次

批次中包含了相当多的信息,所以让我们花点时间来拆解它(图 11.3),然后我们可以进入代码部分。

如图 11.4 所示,批次被标记为 IAT,公司入口描述为 GIFT。FF3 是外汇指示符,而 FF 表示固定到固定。原始金额与接收的金额相同,而 3 表示外汇参考是空格。USDUSD 分别代表原始货币代码(USD)和目标货币代码(USD)。

入口记录包含传递的附加记录数量(图 11.5)。我们预计会看到七个(0007)附加记录被传递,这恰好也是类型 7。另一个显然重要的字段是金额——在这种情况下发送的 1.00 美元。

计算机屏幕截图  自动生成的描述

图 11.3 路由号和账户

带有文本的计算机屏幕  自动生成的描述

图 11.4 IAT 批次标题

计算机屏幕截图  自动生成的描述

图 11.5 IAT 入口记录

在处理 IAT 条目时,每条记录之间有 7 到 12 个附加记录。这与我们在解析 PPD 批次时遇到的不同,因为我们有一个可选附加记录的指示器。虽然这个附加记录指示器在 IAT 条目中仍然存在,但它总是设置为 1。

为了保持相对简单,我们只会考虑七个强制附加记录。它们都以 7 开头,表示附加记录,接下来是两个位置的附加记录类型代码,强制记录的范围从 710 到 716:

  • 71**0—外国支付金额和接收人姓名

  • 711—发起人姓名和街道

  • 712—发起人的城市、州、国家和邮政编码

  • 71**3—ODFI 名称、ID 和分行

  • 71**4—RDFI 名称、ID 和分行

  • 71**5—接收人的 ID 号码和街道

  • 71**6—接收人的城市、州、国家和邮政编码

拥有了新的知识,让我们创建一些 IAT 批次。

11.3 创建 IAT 批次

在我们开始修改和解析文件之前,我们必须有一个文件。我们在图 11.2 中提供了一个样本,结合我们已有的用于生成 ACH 文件的代码,应该足够构建了。如果你以achdevguide.nacha.org/ach-file-detailsmng.bz/eyOv作为指南,你会看到我们可能要处理几种不同的布局。

当我们深入研究细节时,我们会发现我们正在处理新的记录和格式。这意味着在创建特定文件时,我们需要考虑我们是否有 IAT 批次,并相应地创建文件。我们已经在 ach_file_creation.feature 和 test_create_ach_files.py 中有了处理具有指定标准录入类(SEC)代码(如And I want to have 1 batch with ACH credits and debits and a standard entry class code of "PPD")的批次的代码,这驱动了批次的创建。我们必须更新我们的create_batch_header方法,以考虑 SEC 代码。所以,如以下列表所示,我们首先默认(硬编码)一些值。像往常一样,当我们需要它们是动态的并在此点处理它们时,我们会回过头来。

列表 11.1 更新的create_batch_header
def create_batch_header(setup_info, batch_number):
…
    if setup_info["standard_entry_class"] == "IAT":
        batch_header = (
            f"5{setup_info['service_class_code']}"
            f"                "  #1
            f"FF"  #2
            f"3"  #3
            "               "  #4
            "US"  #5
            f"{setup_info['company_id']}"  #6
            f"{setup_info["standard_entry_class"]}"  #7
            "GIFT      "  #8
            "USD"  #9
            "USD"  #10
            f"{setup_info.get('effective_entry_date',  #11
➥today_yymmdd)}"  
            f"{setup_info.get('settlement_date',day_of_year)}"  #12
            f"{setup_info.get('originator_status_code','1')}"  #13
            f"{setup_info.get('odfi','06100001')}"  #14
            f"{setup_info.get('batch_number', batch_number)}\n"
        )
    else:
… #15
    return batch_header

1 IAT 指示符

2 外汇指示符

3 外汇参考指示符

4 外汇参考

5 ISO 目的地国家代码

6 发起人识别

7 标准录入类代码

8 公司录入描述

9 ISO 发起货币代码

10 ISO 目的地货币代码

11 有效录入日期

12 结算日期

13 发起人状态代码

14 发起 DFI 识别

15 批次头部的先前创建逻辑

由于 SEC 代码存储在我们的单元测试信息中,我们可以在必要时引用它,并且在编写 IAT 批次的条目时我们还需要它。如前所述,每个 IAT 记录的条目记录至少包含七个附加记录。我们遵循相同的模式,使用 SEC 代码创建一个新的方法,该方法将负责创建所需的条目和附加记录,大部分保持硬编码,并允许动态值。创建文件的代码现在已超过 500 行,我们应该开始考虑在哪里进行重构以清理代码。创建特定类型文件的逻辑是重构的良好候选者,因为这些细节不一定需要成为此过程的一部分。

然而,目前的代码应该足以满足我们初始文件创建的需求。以下列表中的功能应该看起来很熟悉,这是好事,这意味着我们的语法足够通用,可以处理一些变化。当然,我们还有一些工作要做,以真正使事情动态化,但我们有足够的信息来创建文件。

列表 11.2 创建 ACH IAT 文件
  Scenario: Create an ACH for for IAT
    Given I want to create an ACH file named "iat.ach"
    And I want to have an immediate destination of "990000013"
    And I want to have an immediate origin of "987654321"
    And I want to have 1 batch with ACH credits only
➥ and a standard entry class code of "IAT"
    And I want 1 entries per batch with random amounts between 100 and 100
    And I want to use individual names of "James Smith,
➥ Sarah Johnson, David Williams, Emma Martinez, Olivia Thomas"
    And I want to have company name "My Company"
➥ and company id "1234567890"
    When my ACH is created
    Then I should have a file of the same name
    And there should be 1 batch in the file
    And there should be 1 entries in the file

在有了包含 IAT 批次的 ACH 文件后,我们可以开始工作,以必要的表格和结构来支持存储该文件。

11.3.1 数据库变更

我们需要向数据库中添加至少九个表格来支持 IAT 处理。我们目前只考虑新的批次头格式、条目格式以及所需的附加记录。我们数据库中表格的布局模仿了 ACH 记录的布局。让我们看看一些表格。记住,我们的数据库结构依赖于拥有未解析和已解析的记录,其理念是,我们的系统最终将扩展以异步处理已上传的文件。由于没有引入新的记录编号,我们不需要对未解析的记录进行任何扩展,因为 IAT 将很好地适应该结构。如果我们想存储已解析的记录(我们确实想这么做),我们需要添加九个表格。

让我们看看列表 11.3 中的ach_iat_batch_headers表。注意它仍然有一个外键引用到ach_records_type_5表。此外,大多数字段存储为VARCHAR,因为作为初始迭代,我们希望对结构采取简单的方法。在未来的迭代中,字段如service_class_codeeffective_entry_date可以分别更新为NUMERICDATE。这些约束将有助于确保记录的完整性,并且值得处理。

列表 11.3 IAT 批次头表
CREATE TABLE ach_iat_batch_headers
(
    ach_records_type_5_id          UUID UNIQUE NOT NULL #1
         REFERENCES ach_records_type_5 (ach_records_type_5_id)  #2
                                             ON DELETE CASCADE   #2
                                             ON UPDATE CASCADE, 
    record_type_code               VARCHAR(1)  NOT NULL, #2
    service_class_code             VARCHAR(3)  NOT NULL, 
    iat_indicator                  VARCHAR(16) NOT NULL, 
    foreign_exchange_indicator     VARCHAR(2)  NOT NULL, #3
    foreign_exchange_ref_indicator VARCHAR(1)  NOT NULL, 
    foreign_exchange_reference     VARCHAR(15) NOT NULL, 
    iso_destination_country_code   VARCHAR(2)  NOT NULL, 
    originator_id                  VARCHAR(10) NOT NULL, 
    standard_entry_class_code      VARCHAR(3)  NOT NULL, 
    company_entry_description      VARCHAR(10) NOT NULL, 
    iso_originating_currency_code  VARCHAR(3)  NOT NULL, 
    iso_destination_currency_code  VARCHAR(3)  NOT NULL, 
    effective_entry_date           VARCHAR(6)  NOT NULL, 
    settlement_date                VARCHAR(3)  NOT NULL, 
    originator_status_code         VARCHAR(1)  NOT NULL, 
    originating_dfi_identification VARCHAR(8)  NOT NULL, 
    batch_number                   NUMERIC(7)  NOT NULL 
);

1 批次头的外键,对于删除和更新有 CASCADEs

2 支持解析 ACH 记录所需的其他字段。考虑使用更具体的数据类型,以确保通过在数据库级别强制正确的类型来正确格式化记录。

3 支持解析 ACH 记录所需的其他字段。考虑使用更具体的数据类型,以确保通过在数据库级别强制正确的类型来正确格式化记录。

其余的表格以类似的方式处理,但我们应该意识到在处理附加记录时可能需要考虑的一些设计选择和标准。例如,让我们使用包含发起人城市、州、国家和邮政编码的附加记录。这个附加记录的记录类型代码为 7(因为它是一个附加记录),附加类型代码为 12(因为这是 Nacha 的决定)。

首先,我们需要命名我们的表格。那么,我们应该使用 ach_iat_originator_address_infoach_iat_addenda_712_records 或其他类似名称吗?一般来说,这并不重要,除非我们选择一个极端的名称(即太长或太短且难以理解)。我们最初选择了 ach_iat_addenda_712_records,因为 712 将位于文件中此类附加记录的每一行的开头,我们可以在尝试记住表格名称时利用这一点。这也使我们免于知道我们正在处理哪种类型的附加记录(即这是发起人地址还是接收人地址)。当然,可能会有一些关于表格名称的投诉,例如

  • 它包含单词附加和一个 7,这是多余的,因为类型 7 的记录总是附加记录

  • 即使它们是两个单独的字段,它也没有用下划线分隔 7 和 12

  • 它使用单词记录,并且之前,使用“记录”的表格包含未解析的记录

我们指出这些是为了强调一致性和标准的重要性。我们越接近既定的标准,我们可能越一致。鉴于我们想要尽量遵守标准,我们实施了以下措施:

  • 对于包含解析记录的表格,使用 _details

  • 对于包含未解析记录的表格,使用 _records

  • 对于处理特定 ACH 格式的表格,使用前缀 ach_ppdach_iat 等,因为我们并不总是对表格中使用的名称 ppd 保持一致

因此,我们最终得到了像 ach_iat_entry_detailsach_iat_addenda_10_details 这样的名称。

接下来,让我们处理表格本身。列表 11.4 显示了 ach_iat_addenda_712_records 表。其中一些 IAT 附加记录是 ACH 标准的独特之处,因为它们包含在固定记录中分隔的字段。奇怪,对吧?

以前,其他格式严格是固定长度的记录。因此,我们可以说这 15 个字符是名称,并将存储在名称字段中。在这些附加记录中,我们有一个固定长度字段,例如 35 个字符的“发起城市及州/省”,它包含城市和州。数据元素由星号*分隔,反斜杠\是最后一个元素的终止符。这导致了一个具有七个字段(不包括 UUID)的表格,而记录有六个字段。请注意,其中一个字段是预留的,未使用,因此它未在表中表示。因此,这些解析字段在表中由单个字段表示。我们保留了每个单独字段的最大字段大小,因此尽管发起城市/州的名称都包含在一个 35 个字符的字段中,但我们仍然在表中保留了城市和州字段各 35 个字节,以避免混淆。

列表 11.4 IAT 附加类型 12 的表格
CREATE TABLE ach_iat_addenda_712_records
(
    ach_records_type_7_id        UUID UNIQUE NOT NULL 
            REFERENCES ach_records_type_7 (ach_records_type_7_id) 
            ON DELETE CASCADE ON UPDATE CASCADE,
    record_type_code             VARCHAR(1)  NOT NULL,
    addenda_type_code            NUMERIC(2)  NOT NULL DEFAULT 12, #1
    originator_city              VARCHAR(35), #2
    originator_state             VARCHAR(35),  #3
    originator_country           VARCHAR(35),  #3
    originator_postal_code       VARCHAR(35), 
    entry_detail_sequence_number NUMERIC(7)  NOT NULL
);

1 将附加类型代码默认设置为 12,因为此记录必须始终为 12

2 将这些记录保留为 VARCHAR(35),尽管我们可以考虑强制执行更严格的数据类型

此代码提供了如何处理所有 IAT 表格的模式。接下来,我们看看记录是如何解析的。

11.4 IAT 记录解析

为了解析记录,我们使用我们之前构建的文件创建步骤来创建一个示例文件,然后使用单元测试来构建解析各种记录并将其存储在数据库中的所需代码(列表 11.5)。我们有我们正在寻找解析的样本记录——预期结果。我们通过调用setup_iat_addenda_test来设置必要的测试记录,它只是将所需标题和条目记录添加到数据库中,以便所有外键都能按预期工作。然后,我们调用_parse_iat_addenda_712和类AchIat712AddendaSql。有时,当我们确信我们的逻辑是正确的时候,简单地根据返回值定义expected_result可能会很有诱惑力。我们建议不要这样做,并确保记录以另一种方式解析,无论是手动还是使用其他工具,以便结果得到独立验证。

列表 11.5 对 Type 12 附加的解析例程进行单元测试
class TestParsingIat712AddendaRecords:

    TABLE_NAME: str = "ach_iat_addenda_712_records"

    @pytest.fixture(autouse=True)   #1
    def setup_teardown_method(self):  #1
        SqlUtils.truncate_all()  #1
        yield  #1

    def test_parse_iat_addenda_712_records(self):
        sample_addenda_record = "712BILBAO*BIZKAIA\ #2
➥                    ES*48001\                        #2
➥                  0000001" 

record
        expected_result = { #3
            "record_type_code": Literal["7"],  #3
            "addenda_type_code": 12,  #3
            "originator_city": "BILBAO",  #3
            "originator_state": "BIZKAIA",  #3
            "originator_country": "ES",  #3
            "originator_postal_code": "48001",  #3
            "entry_detail_sequence_number": 1,  #3
        }  #3

        _, ach_records_type_7_id = #4
            SqlUtils.setup_iat_addenda_test(  #4
               sample_addenda_record  #4
            )  #4

        parser = AchFileProcessor() #5
        parser._parse_iat_addenda_712(ach_records_type_7_id,  #5
                                      sample_addenda_record) 

        sql = AchIat712AddendaSql() #6
        retrieved_record =  #7
           sql.get_record(ach_records_type_7_id).model_dump(  #7
               exclude={"ach_records_type_7_id"}  #7
           )  #7

        assert SqlUtils.get_row_count_of_1( #7
            self.TABLE_NAME  #7
        ), f"Expected 1 row in {self.TABLE_NAME}"  #7
        assert (  #7
            retrieved_record == expected_result  #7
        ), f"Expected {expected_result}, #7
➥ but got {retrieved_record}"  #7

1 确保数据库为空的固定值;由于我们已将 autouse 设置为 True,因此无需将其包含在我们的测试方法中,因为它将自动执行。

2 从我们的测试 ACH 文件中取出的样本附加记录

3 验证检索记录的预期结果字典

4 设置所需的数据库记录以满足约束条件

5 解析记录,这也会将其添加到数据库中

6 检索记录,排除 UUID 字段。由于 UUID 由数据库分配,我们无法在预期结果中硬编码它。

7 执行断言以确保只有一行,并且记录与之前定义的预期值匹配

之前的单元测试将失败,直到我们构建了所需的功能。我们将通过必要的代码片段来建立模式。所有这些单元测试都将遵循类似的方法。我们遇到的第一个缺失方法是_parse_iat_addenda_712方法,如下所示。尽管它很简单,但我们确实需要填充expected_record_types,这有助于解析器确定记录是否顺序错误。

我们在更新我们的 ACH 文件处理逻辑时,需要牢记验证,因为我们需要确定是否已收到所有必需的记录,并确保没有重复的附加记录。

列表 11.6 _parse_iat_addenda_712记录
def _parse_iat_addenda_712(self, ach_records_type_7_id: UUID, line: str):
     self.expected_record_types = ["6", "7", "8"] #1

     ach_iat_addenda_record = #2
        AchRecordProcessor().parse_iat_addenda_712(  #3
           ach_records_type_7_id, line  #3
        ) 
        AchIat712AddendaSql()#3
           .insert_record(ach_iat_addenda_record) 

1 可以传递给我们的预期记录类型

2 解析实际上是由 AchRecordProcessor 完成的。在这个时候,我们也可以考虑将一些解析例程移动到新的类中。

3 使用解析后的记录,我们需要将其插入到数据库中。

接下来,我们创建parse_iat_addenda_712方法。这项任务也可能涉及它自己的单独单元测试,因为该类的目的是双重的。首先,它将实际的解析逻辑集中到一个中央位置,这减少了我们的 ACH 文件处理器中的代码,并允许更清晰地理解 ACH 处理流程。其次,它允许我们在不需要在数据库中进行大量设置的情况下单独测试解析逻辑。

然而,解析并不过于复杂,并且将通过此整体过程进行测试,所以目前我们不会担心它需要自己的单元测试。以下列表显示了解析 IAT 附加记录的代码。

列表 11.7 解析 IAT 附加记录
def parse_iat_addenda_712(
        self, ach_records_type_7_id, line
    ) -> AchIat712AddendaSchema:
   regex = r"([^*]+)\*([^\\]+)\\" #1
   match = re.match(regex, line[3:38]) #2
   if not match:  #2
      raise ValueError("Error parsing originator  #2
➥ city and state")  #2
   originator_city, originator_state = match.groups()  #2

   match = re.match(regex, line[38:73]) #3
   if not match:  #3
      raise ValueError("Error parsing originator country   #3
                                and postal code")  #3
   originator_country, originator_postal_code = match.groups()  #3

   return AchIat712AddendaSchema( #4
      ach_records_type_7_id=ach_records_type_7_id,  #4
      record_type_code=line[0],  #4
      addenda_type_code=line[1:3],  #4
      originator_city=originator_city.strip(),  #4
      originator_state=originator_state.strip(),  #4
      originator_country=originator_country.strip(),  #4
      originator_postal_code=originator_postal_code.strip(),  #4
      entry_detail_sequence_number=line[87:94],  #4
   )  #4

1 创建一个正则表达式来解析记录中的分隔字段

2 确保我们有匹配项并从中提取城市和州

3 确保我们有匹配项并从中提取国家和邮政编码

4 解析记录并将其作为我们模式的一部分返回

接下来,我们需要定义用于这些记录的模式。如列表 11.8 所示,我们提供了一个最小布局,以匹配我们期望插入到数据库中的内容。

列表 11.8 IAT 附加 712 模式
class AchIat712AddendaSchema(BaseModel):
    ach_records_type_7_id: UUID
    record_type_code: str = Literal["7"]
    addenda_type_code: int = Literal[12]
    originator_city: str = Field(..., max_length=35)
    originator_state: str = Field(..., max_length=35)
    originator_country: str = Field(..., max_length=35)
    originator_postal_code: str = Field(..., max_length=35)
    entry_detail_sequence_number: int = Field(..., ge=0)

最后一步是创建一个类来处理插入和检索记录的 SQL 逻辑。

列表 11.9 IAT 附加类型 12 SQL
class AchIat712AddendaSql: #1
 #2
    def insert_record(self,  #2
                      ach_iat_addenda: AchIat712AddendaSchema): 
        with get_db_connection() as conn: #2
            conn.execute(                 #3
                """  #3
                   INSERT INTO ach_iat_addenda_712_records (  #3
                            ach_records_type_7_id, addenda_type_code,  #3
                            originator_city, originator_state,  #3
                            originator_country, originator_postal_code,  #3
                            entry_detail_sequence_number )  #3
                        VALUES ( %(ach_records_type_7_id)s,  #3
      %(addenda_type_code)s, %(originator_city)s,  #3
      %(originator_state)s,  #3
      %(originator_country)s, %(originator_postal_code)s,   #3
      %(entry_detail_sequence_number)s)  #3
                """,  #3
                ach_iat_addenda.model_dump(), #4
            )
… #5

1 定义一个类和方法来插入我们的记录模式

2 获取数据库连接

3 SQL 用于插入模型字段和值

4 为 INSERT 语句创建一个模式字典

5 其余的方法,特别是通过 UUID 获取记录

这样,我们应该能够回去确保所有导入语句都已就位,然后成功运行我们的单元测试。假设我们正确解析了记录(对于预期和实际记录),我们应该有一个通过的单位测试。

单元测试 IAT 挑战

这种模式需要为所有新的数据库记录重复,现在是休息一下并编写一些代码的好时机。我们有样本文件。现在的问题仅仅是处理剩余记录的解析。如果之前的代码示例仍然有点令人畏惧,我们也可以从小规模开始,为AchRecordProcessor创建单元测试,它只负责解析 ACH 记录,没有数据库交互。测试Ach-RecordParser的简单要求意味着设置我们的单元测试需要的工作量更少。一旦在该级别验证了解析,我们可以退后一步,看看更大的图景,并开始编写涉及数据库的单元测试,如本节所述。

虽然我们应该在这个阶段验证所有的解析,但我们仍然需要更新我们的ach_file_processor以处理 IAT 批次并利用我们刚刚编写的所有这些美好代码。

11.5 IAT 文件处理

到目前为止,我们已经构建了我们 IAT 处理的部分。我们应该相当自信,我们可以处理单个记录并对它们进行解析。现在,我们需要能够在文件中结合实际的 IAT 批次的解析。我们需要记住,我们在前面的章节中测试的是单个部分。例如,我们知道当我们调用_parse_iat_batch_header方法并传递一个 IAT 批次头记录时,它将被解析并存储在数据库中。然而,该方法在当前通过POST调用加载 ACH 文件的过程中没有被调用。当我们向解析器添加功能时,我们应该牢记一些目标和要求:

  • 解析 PPD 批次仍然按预期工作。

  • 补充记录全部存在。

  • 补充记录的顺序是正确的。

在下一节中,我们开始确保在开始对实际代码进行更改之前,我们有单元测试。

11.5.1 单元测试

我们希望很明显,在添加 IAT 批次后,我们仍然能够加载 PPD 批次。所以,不要忘记测试和验证我们没有因为添加 IAT 处理而破坏任何东西。这意味着我们想要确保我们实施一些回归测试——我们最不想做的事情就是花所有时间在新的处理上,而不验证之前的工作。

在这个前提下,首要任务是创建一个test_loading_pdd_files文件,并确保在加载 PPD 批次时我们拥有正确的记录计数。我们从简单的测试开始,以获取未解析记录的数量。我们测试单个记录的计数和记录总数,确保我们没有写入任何异常,如下所示。

列表 11.10 测试 PPD 批次
def test_good_unparsed_records_only(self, parser):
    filename = "ppd-mixed.ach"  #1
    dir_path = os.path.dirname(os.path.realpath(__file__)) #2
    file_path = os.path.join(dir_path, "data", filename) 

    expected_exceptions_result: int = 0 #3
    expected_total_records_result: int = 14 

    ach_file_id = SqlUtils. #4
➥create_ach_file_record(filename, "123456789")  #4
 #4
    parser.parse(ach_file_id, file_path)  #4
    exceptions = SqlUtils.get_exceptions()  #4

    with SqlUtils.get_db() as conn:   #5
        record_count_type1 = conn.execute(  #5
            "SELECT COUNT(*) FROM ach_records_type_1"  #5
        ).fetchone()[0]  #5
…                     #5
        record_count_type9 = conn.execute(  #5
            "SELECT COUNT(*) FROM ach_records_type_9"  #5
        ).fetchone()[0]  #5

        total_record_count = ( #6
            record_count_type1  #6
            + record_count_type5  #6
            + record_count_type6  #6
            + record_count_type7  #6
            + record_count_type8  #6
            + record_count_type9  #6
        )  #6

        assert record_count_type1 == 1, #7
                f"Expected 1, but got {record_count_type1}"  #7
…  #7
        assert (  #7
            total_record_count == expected_total_records_result  #7
        ), f"Expected {expected_total_records_result}, but got  #7
{total_record_count}"  #7
…  #7

1 定义一个文件名,因为它将在测试的几个不同地方使用

2 确保我们可以引用文件

3 设置一些初始预期值

4 设置测试,解析文件,并返回任何异常

5 查询数据库以获取存储未解析记录的每个表的记录数。这个测试包含大量重复的代码来获取每种类型的计数。

6 将记录计数相加以获取总数,但这有点冗余,因为我们也在单独测试计数。然而,如果没有这个,我们会被问多少次总数呢?

7 断言记录计数是正确的

前面的测试效果足够好,但随着我们的开发工作,我们重新利用了其他测试来创建新的测试,并最终复制/粘贴代码来获取额外的记录计数和assert语句。事实上,Copilot 足够好,为我们填写了一些代码,所以我们甚至不需要做太多的复制/粘贴。但是有一个问题——我们应该将测试代码视为一等公民,给予它与我们给予生产代码相同的关注。

让我们看看我们如何重构上面的代码,使其更简洁、更容易理解。以下列表中的更改将代码从 53 行缩短到 45 行,虽然这不是唯一的指标,但我们应该用它来判断代码是好是坏。在我们职业生涯的早期,我们的一位导师告诉我们,当他们删除代码而不是编写代码时,他们会感到更有生产力。

列表 11.11 对未解析记录的单元测试重构
with SqlUtils.get_db(row_factory=dict_row) as conn: #1
    record_counts = conn.execute(
    """
    SELECT 
        record_count_type1, #2
        record_count_type5,  #2
        record_count_type6,  #2
        record_count_type7,  #2
        record_count_type8,  #2
        record_count_type9,  #2
        record_count_type1 + record_count_type5 +  #3
        record_count_type6 + record_count_type7 +   #3
        record_count_type8 + record_count_type9   #3
                             AStotal_record_count  #3
    FROM ( #4
     SELECT 
       (SELECT COUNT(*) FROM ach_records_type_1)  
                          AS record_count_type1, 
       (SELECT COUNT(*) FROM ach_records_type_5)  
                          AS record_count_type5, #5
       (SELECT COUNT(*) FROM ach_records_type_6)  
                          AS record_count_type6, 
       (SELECT COUNT(*) FROM ach_records_type_7)  
                          AS record_count_type7, 
       (SELECT COUNT(*) FROM ach_records_type_8)  
                          AS record_count_type8, 
       (SELECT COUNT(*) FROM ach_records_type_9)  
AS record_count_type9 
        ) AS counts 
   """
   ).fetchone() #6
   record_counts["exception_count"] = len(exceptions) #7

assert expected_results == record_counts #8

1 更新 get_db 调用以使用 dict_row 作为 row_factory

2 我们 ACH 表中的每个记录计数

3 计算总记录数

4 获取每行计数的查询

5 获取每行计数的查询

6 获取单个结果

7 将异常计数添加到 record_counts 字典中

8 比较两个字典

我们有一个类似的查询来验证解析记录存在于所有解析记录的表中。理想情况下,如果我们有任何解析问题,我们期望表中会有异常,但如果我们还没有为这种情况编写代码,或者可能错过了一个会导致解析错误的条件,那么检查这些表也是好的。

列表 11.12 解析 PPD 记录的单元测试
SELECT 
   record_count_type1, #1
   record_count_type5,  #1
   record_count_type6,  #1
   record_count_type7,  #1
   record_count_type8,  #1
   record_count_type9,  #1
   record_count_type1 + record_count_type5 +   #1
   record_count_type6 + record_count_type7 +  #1
   record_count_type8 + record_count_type9   #1
                                         AS total_record_count  #1
FROM (
   SELECT #2
      (SELECT COUNT(*) FROM ach_file_headers)  #2
                            AS record_count_type1,  #2
      (SELECT COUNT(*) FROM ach_batch_headers)  #2
                            AS record_count_type5,  #2
      (SELECT COUNT(*) FROM ach_entry_ppd_details)  #2
                                      AS record_count_type6,  #2
      (SELECT COUNT(*) FROM ach_addenda_ppd_records)  #2
                                      AS record_count_type7,  #2
      (SELECT COUNT(*) FROM ach_batch_control_records)  #2
                                      AS record_count_type8,  #2
      (SELECT COUNT(*) FROM ach_file_control_records)  #2
                                      AS record_count_type9  #2
   ) AS counts   #2

1 选择逻辑可以保持不变。

2 注意,计数现在发生在解析记录上,并且表是针对 PPD 批次的特定表。

我们的目标是确保我们有足够的测试来验证处理成功的文件。现在我们应该有信心,文件已经在数据库中处理过,它们的单元测试可以验证这些数据库字段,并且文件已经被正确解析。记得在第二章中,我们不仅应该测试成功的路径(成功),还应该测试不那么成功的路径(错误处理)。目前,我们有足够的测试来继续进行 IAT 处理的类似测试。

我们可以为未解析的记录创建一个类似的测试,该测试加载一个包含 IAT 批次的文件。我们创建了一个名为(相当缺乏想象力)的 iat.ach。当我们只考虑未解析的记录时,IAT 文件的处理不会改变,这也是我们需要测试未解析和解析记录的部分原因。当然,我们仍然使用单元测试测试了未解析的记录,但真正的工作是为 IAT 解析记录,如下所示。

列表 11.13 解析 IAT 记录的单元测试
SELECT #1
   record_count_type1, record_count_type5, record_count_type6,  #1
   record_count_type710, record_count_type711,  #1
   record_count_type712, record_count_type713,  #1
   record_count_type714, record_count_type715,   #1
   record_count_type716, record_count_type8,  #1
   record_count_type9, record_count_type1 +  #1
   record_count_type5 + record_count_type6 +  #1
   record_count_type710 + record_count_type711 +  #1
   record_count_type712 + record_count_type713 +  #1
   record_count_type714 + record_count_type715 +  #1
   record_count_type716 + record_count_type8 +   #1
                  record_count_type9 AS total_record_count  #1
FROM ( #2
   SELECT  #2
      (SELECT COUNT(*) FROM ach_file_headers)  #2
                                     AS record_count_type1,  #2
      (SELECT COUNT(*) FROM ach_iat_batch_headers)  #2
                                     AS record_count_type5,  #2
      (SELECT COUNT(*) FROM ach_iat_entry_details)  #2
                                     AS record_count_type6,  #2
      (SELECT COUNT(*) FROM ach_iat_addenda_710_records)  #2
                                     AS record_count_type710,  #2
      (SELECT COUNT(*) FROM ach_iat_addenda_711_records)  #2
                                     AS record_count_type711,  #2
      (SELECT COUNT(*) FROM ach_iat_addenda_712_records)  #2
                                     AS record_count_type712,  #2
      (SELECT COUNT(*) FROM ach_iat_addenda_713_records)  #2
                                     AS record_count_type713,  #2
      (SELECT COUNT(*) FROM ach_iat_addenda_714_records)  #2
                                     AS record_count_type714,  #2
      (SELECT COUNT(*) FROM ach_iat_addenda_715_records)  #2
                                     AS record_count_type715,  #2
      (SELECT COUNT(*) FROM ach_iat_addenda_716_records)  #2
                                     AS record_count_type716,  #2
      (SELECT COUNT(*) FROM ach_batch_control_records)  #2
                                     AS record_count_type8,  #2
      (SELECT COUNT(*) FROM ach_file_control_records)  #2
                                     AS record_count_type9  #2
) AS record_counts  #2

1 我们选择所需的记录并将它们加起来以获取总数,我们也通过在任何编辑器中打开文件(因为它只是一个文本文件)并检查记录数来验证了这一点。

2 我们需要包含 IAT 补充表。记住,批次头、条目详情和补充记录将与 PPD 批次不同。

前面的测试将失败,因为我们还没有更新文件解析以利用这些详细表格。然而,我们现在有信心,我们已经通过单元测试覆盖了 PPD 和 IAT 解析,这意味着我们可以继续使用这些新的 IAT 详细表格。

11.5.2 更新文件处理

在我们的 PPD 文件加载通过和 IAT 文件加载失败之后,我们开始着手支持 IAT 所需的更改。随着我们进行更改,我们将不断重新运行单元测试,以确保我们在解析 IAT 文件方面取得进展,并且我们没有无意中破坏任何东西。由于 ACH 文件是顺序处理的,我们需要首先处理的记录是批次头记录。我们添加了一个名为 batch_type 的新字段,我们将用它来解析批次头。以下列表显示了我们所添加的简单 if/elif/else 处理,根据 SEC 代码调用适当的例程,并在遇到我们不认识的头时记录异常。

列表 11.14 调用适当的解析例程
if line[50:53] == "IAT": #1
 self._parse_iat_batch_header(ach_record_id, line) #2
 self.batch_type = "IAT" 
elif line[50:53] == "PPD": #2
    self._parse_batch_header(ach_record_id, line)  #3
 self.batch_type = "PPD" #3
else: #3
 self._add_exception( #4
 AchExceptionSchema( #4
 ach_files_id=ach_file_id, #4
 record_number=sequence_number, #4
 exception_code=AchExceptions.INVALID_BATCH_TYPE.value, #4
 ), #4
 line, #4
 ) #4

1 无论批次头是 IAT 还是 PPD,SEC 代码都在相同的位置。这一行设置了适当的 batch_type 标志以适应 SEC 类型。

2 之前,我们只是调用了 _parse_batch_header 方法。现在,我们确保我们正在处理一个 PPD 批次。

3 我们希望在处理过程中记录异常,并且我们遇到了一个未识别的 SEC 代码。这可能是因为文件格式无效或我们尚未支持的代码。

在正确解析批次头的情况下,我们应该在 IAT 处理测试中前进一点,当然,PPD 处理不应该出错。我们遇到的下一个记录是类型 6 记录。随着 batch_type 变量的引入,它应该遵循相同的模式。请注意,我们继续像以前一样将未解析的记录写入数据库。然而,解析记录并将其写入适当表的需要取决于 batch_type 标志。以下列表显示了所需的更新。

列表 11.15 解析条目详情
case "6": #1
    ach_record = AchRecordType6Schema(  #1
        ach_records_type_5_id=current_batch_header_id,  #1
        unparsed_record=line,  #1
        sequence_number=sequence_number,  #1
    )  #1
    ach_record_id = AchRecordsSqlType6()  #1
➥.insert_record(ach_record)  #1
    if self.batch_type == "IAT": #2
        self._parse_iat_entry_detail(  #2
            ach_file_id=ach_file_id,  #2
            current_batch_header_id=current_batch_header_id,  #2
            ach_records_type_6_id=ach_record_id,  #2
            sequence_number=sequence_number,  #2
            line=line,  #2
        )  #2
    elif self.batch_type == "PPD": #3
        self._parse_entry_ppd_detail(  #3
            ach_file_id=ach_file_id,  #3
            current_batch_header_id=current_batch_header_id,  #3
            ach_records_type_6_id=ach_record_id,  #3
            sequence_number=sequence_number,  #3
            line=line,  #3
        )  #3
    else: #4
        self._add_exception( #4
            AchExceptionSchema( #4
                ach_files_id=ach_file_id,  #4
                record_number=sequence_number,  #4
                exception_code=AchExceptions.INVALID_SEC.value,  #4
            ),  #4
            line,  #4
        )  #4

1 我们未解析的记录的编写与我们正在处理的批次类型无关。

2 当 batch_type 设置为 IAT 时,我们将调用适当的方法来解析条目。

3 IAT 和 PPD 解析的参数完全相同。只有需要解析的字段不同。

4 当我们遇到意外的 batch_type 时,添加一个异常

解析附加记录比解析批次头和条目详细记录稍微复杂一些。这是因为我们现在还必须处理不同的附加记录类型按照期望的顺序排列。类似于 expected_record_type 变量,我们也引入了一个 expected_addenda_type 变量。让我们直接跳到解析特定附加类型记录的方法,如下面的列表所示。在这里,这是标准的处理,增加了新的 expected_addenda_type。我们期望的下一条记录是另一个附加记录(类型 7),它应该是附加类型 11。

列表 11.16 _parse_iat_addenda_710方法
def _parse_iat_addenda_710(self, ach_records_type_7_id: UUID, line: str):
     self.expected_record_types = ["7"] #1
     self.expected_addenda_type = "11" #2

     ach_iat_addenda_record = AchRecordProcessor().parse_iat_addenda_710(
         ach_records_type_7_id, line
     )
     AchIat710AddendaSql().insert_record(ach_iat_addenda_record)

1 expected_record 应该是另一个附加记录。

2 expected_addenda_type 应该是下一个序列,因为记录需要按照特定的顺序排列。注意,我们只期望一种类型的记录,因此不需要像 expected_record_type 那样使用数组。

所有的附加记录将以相同的方式解析,expected_addenda_type 被设置为下一条记录。但是当我们到达最后的附加类型(类型 16)时会发生什么?下面的列表展示了我们如何重置我们的期望类型,一旦我们到达最后一个记录。

列表 11.17 重置期望类型
self.expected_record_types = ["6", "7", "8"] #1
self.expected_addenda_type = "" #2

1 在 IAT 处理中存在一些可选的附加记录,因此可能会遇到这些记录,以及新的条目或批次的结束。

2 没有更多的强制附加记录,因此 expected_addenda_type 可以被设置为空字符串。

现在我们知道了如何解析附加记录,应该很容易将这些单独的方法组合起来——因为它们遵循与我们解析记录类型相似的方法。这个过程在下面的列表中展示。

列表 11.18 解析附加记录
   def _parse_iat_addenda( #1
        self, ach_records_type_7_id: UUID,  #1
        line: str, sequence_number  #1
    ): 
        addenda_type = line[1:3] #2

        if addenda_type != self.expected_addenda_type: #3
            self._add_exception( 
                AchExceptionSchema( 
                    ach_files_id=ach_records_type_7_id, 
                    record_number=sequence_number, 
                    exception_code= 
                       AchExceptions.UNEXPECTED_ADDENDA_TYPE.value, 
                ) 
            ) 
            Return 

        if addenda_type == "10": #4
            self._parse_iat_addenda_710(ach_records_type_7_id, line)  #5
…  #5
        elif addenda_type == "16":  #5
            self._parse_iat_addenda_716(ach_records_type_7_id, line)  #5
        else:  #5
            self._add_exception(  #5
                AchExceptionSchema(  #5
                    ach_files_id=ach_records_type_7_id,  #5
                    record_number=sequence_number,  #5
                    exception_code=  #5
                        AchExceptions.INVALID_IAT_ADDENDA_TYPE.value,  #5
                )  #5
            )  #5

1 定义我们的解析方法。这是一个总称,将调用其他方法来解析特定记录。

2 提取附加类型,因为我们将会反复使用它

3 如果附加类型不是我们期望的,我们需要记录一个异常。

4 根据我们正在处理的附加类型,调用适当的方法。如果遇到意外的附加类型,它将记录不同的异常。

这样,我们应该能够解析包含 IAT 和 PPD 批次的文件。这些额外的 IAT 批次表将对我们的某些 API 产生影响。在下一节中,我们将简要看看具体哪些方面受到了影响。

11.6 对仪表板的影响

IAT 处理的增加如何影响我们仪表板的功能?这个问题可能需要与业务进行更多讨论。从 ACH 标准的角度来看,在同一文件中拥有 IAT 批次和 PPD 批次当然是可能的。然而,IAT 批次的字段和有趣信息可能不同,而且新表的出现确实会影响组件的功能。我们的组件是否应该包含所有批次或仅选择批次可能是一个需要来自最终用户输入的业务决策。目前,让我们看看我们如何将 IAT 批次纳入当前的仪表板组件中。

11.6.1 get_batches 方法

/batches 端点调用 get_batches 方法,该方法需要更新以包含 IAT 批次标题。我们知道 ach_records_type_5_id 只会存在于批次标题表中的一个。我们利用这一点和 COALESCE 命令来使用 ach_batch_headers 字段,如果它是 NULL,则使用 ach_iat_batch_headers 的值。这基于假设字段不会意外地成为 NULL。有关字段在数据库中都被标记为 NOT NULL。它们是否永远保持这种状态是另一个问题。目前,以下列表显示了我们可以如何更新我们的查询,而不会对当前的 API 过于侵入性。

列表 11.19 更新的 get_batches
SELECT COALESCE(abh.company_name, '') AS company_name, #1
       COALESCE(abh.company_identification,       #2
       aibh.originating_dfi_identification) AS company_id,
       art5.ach_records_type_5_id AS id,
       COALESCE(abh.batch_number, aibh.batch_number) #3
                                           AS batch_number, 
       abcd.total_debit_entry_dollar_amount AS debit_total,
       abcd.total_credit_entry_dollar_amount AS credit_total, 
       abcd.entry_addenda_count 
  FROM ach_files AS af
…
LEFT JOIN ach_batch_headers AS abh #4
                USING (ach_records_type_5_id)  #4
LEFT JOIN ach_iat_batch_headers AS aibh  #4
                USING (ach_records_type_5_id)  #4
    WHERE af.ach_files_id = %s
 ORDER BY COALESCE(abh.originating_dfi_identification,  #5
                   aibh.originating_dfi_identification); 

1 我们无法从标题记录中访问 IAT 的公司名称。

2 如果公司识别信息不可用,则使用 originating_dfi_identification

3 从批次中选择适当的批次号

4 使用 LEFT JOIN 因为批次标题是针对一个表或另一个表的

5 按适当的识别号排序

如此所示,我们有时可以更新现有的查询;然而,并非所有情况下都如此。实际上,这可能是例外。下一节将讨论当查询太大或太复杂,无法同时包含 PPD 和 IAT 时应该做什么。

11.6.2 批次条目

从端点 /{file_id}/batches/{batch_id}/entries 收集批次条目需要一些工作。构建 AchBatchEntriesResponse 的查询相当大,因此可能更有意义的是保留当前的查询,并创建一个针对 IAT 交易条目的新查询。因此,我们将采取 get_entries 方法,将现有的查询移动到 _get_ppd_entries 方法中,并创建一个新的 _get_iat_entries 方法。拆分的成果如下所示。

列表 11.20 更新的 get_entries
@staticmethod
def get_entries(
    ach_file_id: UUID, ach_batch_id: UUID
    ) -> list[AchBatchEntriesResponse]:
    if AchFileSql._is_iat_batch(ach_file_id, ach_batch_id): #1
        return AchFileSql._get_iat_entries #2
➥(ach_file_id, ach_batch_id) 
    else:
        return AchFileSql._get_ppd_entries(ach_file_id, #3
                                           ach_batch_id) 

1 确定文件中指定的批次是否为 IAT 批次的 方法

2 返回给定文件和批次的 IAT 条目的 方法

3 之前的 SQL 查询已移动到其自己的方法中。

在创建用于 IAT 记录的AchBatchEntriesResponse时,有一些细微的变化。对于获取附加记录数的原始 PPD 记录查询,我们必须自己收集这些信息。对于 IAT 批量,这些信息是记录的一部分;然而,我们仍然以相同的方式收集计数,部分是为了保持查询的相似性,但更重要的是,因为我们可能希望在将来对字段进行验证,这种方法提供了实现这一目标的机制(列表 11.21)。

列表 11.21 _get_iat_entries查询
WITH addenda_records_for_entry AS (
   SELECT art6.ach_records_type_6_id,
   COUNT(art7.*) AS addenda_count #1
   FROM ach_files AS af
…
   LEFT JOIN ach_records_type_7 AS art7 USING (ach_records_type_6_id)
   WHERE af.ach_files_id = %s
      AND art5.ach_records_type_5_id = %s
      GROUP BY (art6.ach_records_type_6_id)
)
SELECT art6.ach_records_type_6_id AS id,
       aied.transaction_code,
…
       aia10d.receiving_name AS individual_name, #2
       aied.amount, #3
       CONCAT(
          '*************',
          RIGHT(LPAD#D
➥(aied.foreign_receivers_account_number, 4, '0'), 4) #4
       ) AS account_number_last_4,
       arfe.addenda_count
…
      INNER JOIN ach_iat_entry_details AS aied  #5
                  USING (ach_records_type_6_id)  #5
      INNER JOIN addenda_records_for_entry AS arfe  #5
                  USING (ach_records_type_6_id)  #5
      INNER JOIN ach_iat_addenda_10_details AS aia10d   #5
                  USING (ach_records_type_7_id)  #5
…

1 我们获取附加记录数,该数也适用于 IAT 交易的记录类型 6。最终,我们可以使用这种方法来验证传递的附加记录数。

2 我们将接收方名称作为个人名称返回,以符合我们的响应,该响应需要从附加类型 10 记录中检索。

3 从 IAT 记录条目中提取金额

4 记录条目还包括账户号码。

5 连接必要的 IAT 表

我们是将 IAT 纳入当前仪表板还是创建全新的组件,这将是业务和用户偏好的问题。一个不容协商的方面是必须遵守政府法规。下一节将讨论如何解决一些监管问题。

11.7 OFAC 扫描

金融机构在日常运营中必须遵守各种法规和合规要求。例如,爱国者法案(PATRIOT Act)和了解你的客户(KYC)规定,金融机构对未能报告潜在的洗钱和恐怖主义融资活动负有责任。外国资产控制办公室(Office of Foreign Asset Control, OFAC)提供了一份“特别指定国民”名单,或称 SDN 名单,其中包括个人、公司以及禁止与之进行商业往来的船只/飞机等资产,机构可能面临处罚。

注意,还有其他名单,以及一般意义上的国家,机构被禁止与之进行商业往来。与任何这些受制裁实体进行商业往来可能会给银行造成重大损失。违反美国法规的实体及其相关罚款的名单可在mng.bz/pKN8找到。

尽管存在专门从事此类扫描的第三方软件包,但在接下来的几节中,我们将实现仪表板的基本扫描,以了解该过程。如果你对机器学习/人工智能和数据分析感兴趣,这是一个深入了解并构建一些综合检测的绝佳机会!

11.7.1 受制裁的个人和国家

您可以从 OFAC 网站下载 XML 文件,网址为sanctionslist.ofac.treas.gov/Home/SdnList,同时还有一个在线搜索工具,网址为sanctionssearch.ofac.treas.gov/。这两种方式都是熟悉与受制裁个人合作时可用信息的绝佳方法。

为了我们的目的,我们创建了一个包含虚构个人姓名和化名的表格,以避免使用列表中实际数据可能带来的任何潜在问题。我们还创建了一个应扫描的虚构国家列表。正如我们从民事处罚中看到的那样,许多时候,公司仅仅与在其他国家的公司/个人做生意就会陷入麻烦。列表 11.22 显示了用于存储个人姓名以及国家的表的create语句。

列表 11.22 为 OFAC 扫描创建表
CREATE TABLE sdn_list
(
    sdn_id          UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    first_name      VARCHAR(255) NOT NULL,
    middle_name     VARCHAR(255) DEFAULT NULL,
    last_name       VARCHAR(255) NOT NULL,
    alias           VARCHAR(255) DEFAULT NULL,
    created_at      TIMESTAMP    NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMP    NOT NULL DEFAULT NOW()
);

CREATE TABLE sanctioned_countries
(
    sanctioned_country_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    country_name          VARCHAR(255) NOT NULL,
    country_code          VARCHAR(2)   NOT NULL,
    created_at            TIMESTAMP    NOT NULL DEFAULT NOW(),
    updated_at            TIMESTAMP    NOT NULL DEFAULT NOW()
);

接下来,我们在创建数据库时填充这些表,如下所示。不要忘记将sdn_listsanctioned_countries表添加到truncate_all实用方法中,因为在单元测试期间,您不希望默认数据被清除。

列表 11.23 填充新表
INSERT INTO 
sdn_list(first_name, middle_name, last_name, alias) 
VALUES ('Cash', DEFAULT, 'Steeler', 'Heister');
INSERT INTO 
sdn_list(first_name, middle_name, last_name, alias) 
VALUES ('Penny', DEFAULT, 'Pincher', 'Embezzler');
INSERT INTO 
sdn_list(first_name, middle_name, last_name, alias) 
VALUES ('Ben', 'E', 'Factor', '');
INSERT INTO 
sdn_list(first_name, middle_name, last_name, alias) 
VALUES ('Lou', DEFAULT, 'Pole', 'Evader');
INSERT INTO 
sdn_list(first_name, middle_name, last_name, alias) 
VALUES ('Mallory', DEFAULT, 'Practice', 'Biller');

INSERT INTO 
sanctioned_countries(country_name, country_code) 
VALUES ('Bribeland', 'BL');
INSERT INTO 
sanctioned_countries(country_name, country_code) 
VALUES ('Scamistan', 'SC');
INSERT INTO 
sanctioned_countries(country_name, country_code) 
VALUES ('Embezzlvania', 'EV');
INSERT INTO 
sanctioned_countries(country_name, country_code) 
VALUES ('Swindleland', 'SW');
INSERT INTO 
sanctioned_countries(country_name, country_code) 
VALUES ('Greedonia', 'GD');

在我们的数据被填充后,我们现在可以继续构建用于扫描的 API。

11.7.2 对个人进行扫描

在我们构建 API 之前,让我们定义一个查询,该查询将返回 OFAC 扫描的结果。根据我们的需求,每当有人点击我们 UI 中的 OFAC 扫描链接时,我们将扫描所有加载的文件。然而,当我们拥有更大的嫌疑人名单、更复杂的扫描算法和更多加载的文件时,这个功能将不再实用。我们将讨论一些处理这个问题的策略,但现在,让我们分解我们用来收集结果的查询。

如您所知,目标是搜索传入的 ACH 交易中的个人姓名,并将它们与 OFAC 提供的名单上已知的个人进行匹配。到目前为止,在搜索时,我们使用了 SQL 关键字,如ILIKE%通配符。现在,我们通过在我们的查询中引入模糊匹配来变得稍微复杂一些。通过将fuzzystrmatch安装到我们的 Postgres 数据库中,如下所示,我们获得了额外的搜索选项。

列表 11.24 安装fuzzystrmatch
CREATE EXTENSION IF NOT EXISTS "fuzzystrmatch";

此扩展提供了音位匹配算法,如 Soundex 和 Meta­phone,以及 Levenshtein 距离算法,用于测量两个字符串之间的相似度。这些算法可以帮助我们创建一个更健壮的搜索算法,因为我们现在可以扩展搜索范围,以包括可能被恶意个体拼写错误或轻微更改的名称,以便他们仍然可以执行交易。尽管我们正在引入 IAT 交易的特性,但它可以融入任何在金融机构进行的交易中。通常,当客户开设账户时,会进行此类(以及各种其他检查),数据库通常定期扫描以查找任何个人。因此,我们首先创建一个公用表表达式(CTE)来收集 PPD 交易的名称。主要问题是确保我们从适当的记录中收集名称,对于 ACH PPD 交易,这些将位于类型 6 记录上。

在以下列表中,我们只想选择不同的名称以最小化跨批次的搜索,如果我们有多个名称。如果我们对某个客户而不是另一个客户有假阳性,我们可能需要考虑额外的信息。我们还使用了 REPLACE 来删除名称中的任何空格,但如果需要处理任何标点符号,也可以使用 REGEXP_REPLACE(aepd.individual_name, '^[^a-zA-Z0-9]', '', 'g')

列表 11.25 从 PPD 交易中收集单个名称
WITH ach_ppd_collected_names AS (
    SELECT DISTINCT aped.individual_name, #1
           REPLACE(aped.individual_name, ' ', '')  #2
                            AS cleaned_individual_name, 
                    art1.ach_files_id, #3
                    art5.ach_records_type_5_id 
    FROM ach_files #4
    INNER JOIN ach_records_type_1 AS art1 
                     USING (ach_files_id) 
    INNER JOIN ach_records_type_5 AS art5  
                                 USING (ach_records_type_1_id) 
    INNER JOIN ach_records_type_6 AS art6  
                                 USING (ach_records_type_5_id) 
    INNER JOIN ach_ppd_entry_details AS aped  
                                 USING (ach_records_type_6_id) 
    GROUP BY art1.ach_files_id, art5.ach_records_type_5_id, #5
                      individual_name, cleaned_individual_name 
),

1 使用 SELECT DISTINCT,因为我们不希望有重复的搜索;一个名称就足够了。

2 简单替换以删除空格,但仅限于此。使用 REGEXP_REPLACE 进行更复杂的删除。

3 查找特定交易的记录

4 必要的 JOIN 操作以获取单个名称

5 由于 SELECT DISTINCT 语句,必要的 GROUP BY 语句

对于 IAT 名称也需要一个类似的公用表表达式(CTE),如下所示列表所示。至于我们的一些其他 IAT 处理,我们需要从附加记录中检索名称,而不是从条目记录中检索。我们将 receiving_name 作为单个名称返回,以保持 CTE 之间的对齐。

列表 11.26 从 IAT 交易中收集单个名称
ach_iat_collected_names AS (
    SELECT DISTINCT aia10d.receiving_name #1
                                        AS individual_name, 
                    REPLACE(aia10d.receiving_name, ' ', '') #2
                           AS cleaned_individual_name, 
                    art1.ach_files_id, #3
                    art5.ach_records_type_5_id 
    FROM ach_files #4
    INNER JOIN ach_records_type_1 AS art1  #4
               USING (ach_files_id)  #4
    INNER JOIN ach_records_type_5 AS art5  #4
               USING (ach_records_type_1_id)  #4
    INNER JOIN ach_records_type_6 AS art6  #4
               USING (ach_records_type_5_id)  #4
    INNER JOIN ach_records_type_7 AS art7  #4
               USING (ach_records_type_6_id)  #4
    INNER JOIN ach_iat_addenda_10_details AS aia10d   #4
                                          USING (ach_records_type_7_id)  #4
    GROUP BY art1.ach_files_id, art5.ach_records_type_5_id, #5
             individual_name, cleaned_individual_name 
)

1 在此时刻,我们不希望有重复的搜索,因为一个名称就足够了。我们需要将字段重命名为 individual_name 或找到一个字段的通用名称。

2 简单替换以删除空格,但仅限于此。使用 REGEXP_REPLACE 进行更复杂的删除。

3 查找特定交易的记录

4 必要的 JOIN 操作以获取单个名称

5 由于 SELECT DISTINCT 语句,必要的 GROUP BY 语句

下一个 CTE 用于收集我们的嫌疑人的名称(列表 11.27)。为了显示目的,我们处理将名称连接在一起,考虑到 middle_name 可能是 NULL 的可能性,使用 CONCAT_WS。否则,使用标准的 CONCAT 函数将在第一个和最后一个名称之间产生两个空白空间。当然,如果我们使用的 RDMS 没有 CONCAT_WS 这样的函数,还有处理这种情况的方法。

列表 11.27 收集 SDN 名称
sdn_names AS (
    SELECT
        CONCAT_WS(' ', first_name, middle_name, last_name) #1
                                              AS sdn_name  
        REPLACE(
           CONCAT(first_name, middle_name, last_name), ' ', '')  #2
                                               AS cleaned_sdn_name, 
        alias, #3
        REPLACE(alias, ' ', '') as cleaned_sdn_alias 
    FROM sdn_list
)

1 CONCAT_WS 将跳过 NULL 字段。它非常适合将第一个、中间和最后一个名称组合成我们可以用于显示的内容。

2 连接名称并删除任何空格

3 对于别名采用类似的方法

收集了我们的客户和嫌疑人的名称后,我们现在可以开始比较它们。我们使用的第一种方法是函数 LEVENSHTEIN 来计算距离,它考虑了将一个字符串转换为另一个字符串所需的添加、删除和更新次数。我们将字符串转换为介于 0 到 100 之间的数字,其中 0 表示没有匹配,100 表示完全匹配。

另一种方法是列表 11.28 中的 DAITCH_MOKOTOFF 函数,该函数允许对名称进行发音匹配,试图确定名称是否听起来相似。fuzzystrmatch 模块中还有一些其他算法可用。此外,还有其他商业搜索算法。在生产环境中,我们需要额外的信息,如别名、地址等,以减少我们通过简单地找到发音匹配可能产生的误报数量。然而,使用这些方法是一个好的起点。

列表 11.28 计算 IAT 个人的相似度得分
computed_similarity_iat AS (
    SELECT
        ach_files_id, 
        ach_records_type_5_id AS ach_batch_id, 
        sdn.sdn_name,
        aicn.individual_name,
        alias,
        (1 - (LEVENSHTEIN(cleaned_individual_name, #1
sdn.cleaned_sdn_name)::FLOAT  #1
/ GREATEST(LENGTH(cleaned_individual_name),  #1
LENGTH(sdn.cleaned_sdn_name)))) * 100  #1
AS similarity_score,  #1
        CASE #2
           WHEN DAITCH_MOKOTOFF(cleaned_individual_name) &&  
               DAITCH_MOKOTOFF(sdn.cleaned_sdn_name) THEN TRUE 
           ELSE FALSE 
        END AS daitch_mokotoff_match_name,
        CASE
           WHEN daitch_mokotoff(cleaned_individual_name) &&
               DAITCH_MOKOTOFF (sdn.cleaned_sdn_alias) THEN TRUE
           ELSE FALSE
        END AS daitch_mokotoff_match_alias
    FROM ach_iat_collected_names aicn
    CROSS JOIN sdn_names sdn #3

1 将 LEVENSHTEIN 函数返回的值转换为对最终用户更容易理解的百分比

2 使用 DAITCH_MOKOTOFF 算法返回名称是否匹配

3 我们使用 CROSS JOIN 以便每个名称都进行比较。

我们选择从两个查询返回的所有结果,如下所示列表。

列表 11.29 合并结果
computed_similarity AS (
    SELECT * FROM computed_similarity_ppd #1
    UNION ALL              #1
    SELECT * FROM computed_similarity_iat  #1
)

1 汇总我们计算的所有评分和匹配项

最后,我们按照以下列表所示过滤结果,为每个返回的行添加一个唯一的行号作为标识符。有许多方法可以优化这一点并提高查询性能。然而,收集名称、比较、组合然后过滤的清晰性使得在这个例子中过程更容易理解。当我们讨论展示 OFAC 结果时,我们将讨论实际生成此报告的一些策略。

列表 11.30 过滤结果
SELECT ROW_NUMBER() #1
   OVER (ORDER BY ach_files_id, ach_batch_id) AS id, *  #1
FROM computed_similarity  #1
WHERE similarity_score >= 80 #2
   OR daitch_mokotoff_match_name = TRUE  #3
    OR daitch_mokotoff_match_alias = TRUE  #3
ORDER BY similarity_score DESC

1 使用 ROW_NUMBER 函数为行生成一个唯一的 ID。

2 过滤结果,仅返回符合特定阈值或与名称或别名发音匹配的匹配项

现在我们已经根据与这个查询一起工作的结果,对我们将要返回的内容有了很好的了解。我们将在下一节中创建一种方式,让用户可以访问这些结果。

11.8 应用程序编程接口

我们现在有两个关键部分:数据库中存在的数据和产生期望结果的方法。我们继续遵循创建 API 的既定模式,从单元测试开始,并着手必要的部分以确保其通过。

11.8.1 单元测试

下面的列表中的单元测试加载了我们之前创建的一个文件。在这里,我们专注于从名为 ofac_elemental_resources.ach 的文件中加载数据并验证我们的 API。因为我们已经使用仪表板(或另一个单元测试)创建并加载了包含相同文件的数据库,所以我们已经知道我们的查询应该返回三个匹配项。因此,单元测试只需要确保数据库是干净的,加载文件,并获取结果。

列表 11.31 OFAC API 的单元测试
class TestOfacApi:
    client: TestClient = TestClient(app)
    ach_files_id: Optional[str] = None

    # Get the directory of our file
    current_file_dir = Path(__file__).resolve().parent

    @pytest.fixture(autouse=True) #1
    def mock_client_host(self):  #1
        with patch(  #1
            "fastapi.Request.client",  #1
            new_callable=lambda: type("Client", (),  #1
                             {"host": "127.0.0.1"}),  #1
        ):  #1
            Yield  #1

    def get_absolute_path(self, relative_path): #2
        return self.current_file_dir / relative_path 

    def setup_method(self, _method: Callable) -> None: #3
        ach_file = "ofac_elemental_resources.ach"  #3
        absolute_path = self.get_absolute_path(  #3
            Path("../data/ofac_elemental_resources.ach")  #3
        )  #3
        SqlUtils.truncate_all()  #3
        self.ach_files_id = SqlUtils.create_ach_file_record(  #3
            ach_file, str(randint(1, 99999999))  #3
        )  #3
        AchFileProcessor().parse  #3
➥(self.ach_files_id, absolute_path)  #3

    def test_get_ofac_api_for_ppd_batches(self): #4
        response = self.client.get("/api/v1/files/ofac")  #5
 assert response.status_code == 200, response.text #5
  assert len(response.json()) == 3,  #5
➥ "Should have 3 matches"  #5

1 确保客户端传递了主机和 IP 地址,因为我们的日志记录需要这些信息

2 确保我们选择了正确的文件

3 在进行我们的 API 调用之前,我们需要清除数据库并加载文件。

4 一旦我们设置了数据库并准备好客户端进行请求,我们可以从 API 中提取 OFAC 结果并确保它们是正确的。我们只进行了一次快速检查返回的项目数量,但想更深入地检查结果以确保返回了预期的数据。

定义了单元测试后,我们可以持续检查我们的代码是否返回了期望的结果。让我们确保这一点发生!

11.8.2 创建端点

我们首先想要为测试创建端点(列表 11.32)。我们想要定义用户将要访问的路径。这个端点在 fiels.py 路由定义中定义,其中所有内容都以前缀 /api/v1/files 开头,所以路径参数只显示 /ofac。否则,我们只是返回一个空列表,这应该足以确保我们的单元测试通过第一个断言并返回 200-响应代码。

列表 11.32 基础端点
@router.get( #1
    path="/ofac",  #1
)  #1
@log_message("Performed OFAC Scan on loaded ACH files") 
async def read_files(request: Request): #2
    return [] 

1 定义了路径和日志消息

2 简单地返回一个空列表以确保我们收到 200-响应代码,并能够通过单元测试中的第一个断言语句

在我们有了基本的端点工作后,我们可以填充空白处,指定返回类型并调用一个实际的方法来发送结果,如下面的列表所示。

列表 11.33 更新后的端点
@router.get( #1
    path="/ofac",  #1
    response_model=list[OfacScanResults],  #1
    summary="Scan for OFAC issues in loaded ACH Files",  #1
    description=  #1
➥"Perform an OFAC scan and return the results",  #1
    response_description="Results of OFAC scan.",  #1
    tags=["OFAC"],  #1
)  #1
@log_message("Performed OFAC Scan on loaded ACH files")  #1
async def read_files(request: Request) #2
➥ -> list[OfacScanResults]:  #2
    return OfacSql().get_scan_results() 

1 我们的 API 已文档化,并准备好从我们的文档端点供用户使用。

2 我们已经定义了我们打算返回的数据类型以及创建它的方法。

由于新添加的 OfacScan­ResultsOfacSql` 对象,端点将不再工作,这些对象我们将在下一节中定义。

11.8.3 完成 API

在 API 和为其提供动力的 SQL 已经定义好的情况下,我们只需要完成两个维护步骤来完善 API 并使其通过单元测试。首先,我们希望定义OfacScanResults,如下所示。这是从我们的SQL查询返回的数据到其 Pydantic 等价的自然发展。

列表 11.34 OfacScanResults
class OfacScanResults(BaseModel):
    id: int = Field(title="ID", description="The ID of the result")
    ach_files_id: UUID4 = Field(title="ACH Files ID",   #1
                           description="The ACH Files ID")  #2
    ach_batch_id: UUID4 = Field(title="ACH Batch ID",   #2
                           description="The ACH Batch ID") 
    sdn_name: str = Field(title="SDN Name",  #2
➥ description="The SDN Name")  #2
    individual_name: str = Field(  #2
        title="Individual Name",   #2
➥description="The Individual Name"  #2
    )  #2
    alias: str = Field(title="Alias",   #2
                      description="The Alias from the SDN List")  #2
    similarity_score: Decimal = Field( #3
        title="Similarity Score",   #4
                      description="The Similarity Score"  #4
    ) #C
    daitch_mokotoff_match_name: bool = Field(  #4
        title="Daitch Mokotoff Match Name",   #4
        description="Daitch Mokotoff Match Name"  #4
    )  #4
    daitch_mokotoff_match_alias: bool = Field(  #4
        title="Daitch Mokotoff Match Alias",   #4
        description="Daitch Mokotoff Match Alias"  #4
    )  #4

1 使用这些字段,用户可以直接跳转到包含匹配项的文件或批次,进行进一步研究。

2 疑似者的名称/别名以及它从 ACH 文件中匹配的名称

3 计算出的数值分数。目前,它是 Levenshtein 分数,但可能最终会反映一个更复杂算法计算出的分数。

4 这些布尔值旨在显示一个勾选标记或其他指示符,表明该记录也被此方法匹配。

在存储我们查询结果的地方,所需做的只是创建OfacSql类及其相关的get_scan_results方法(列表 11.35)。这将执行我们之前创建的 SQL 语句,因此这应该是直截了当的。

列表 11.35 OfacSql
class OfacSql:
    def get_scan_results(self) -> list[OfacScanResults]:
        with get_db_connection(row_factory=class_row(OfacScanResults)) 
                                                               as conn:
            result = conn.execute(
                """
…   #1
                """,
                [],
            ).fetchall()#2

        return result #3

1 我们之前设计的 SQL 查询

2 获取查询的所有结果

3 返回结果

到目前为止,API 应该是功能性的,我们应该有一个通过单元测试。我们还应该回顾并更新我们的单元测试,以确保我们正在深入结果并验证一些字段,就像我们最初检查数组大小一样。理论上,任何返回三个项目的端点都会通过那个测试。接下来,我们看看拼图的最后一块——展示我们的结果。

11.9 用户界面

在数据和检索它的能力到位之后,最后一步是将信息展示给用户。继续我们的通用方法,我们创建一个负责发起 API 调用并传递其结果的页面,该结果被传递到一个将显示格式化结果的组件。图 11.6 显示了拉取样本报告的结果。报告提供了嫌疑人的姓名、他们的别名以及客户的姓名。我们还包括了分数(匹配的接近程度)以及系统是否匹配了名称或别名。还有选项查找包含疑似客户的文件或批次。

计算机屏幕截图  自动生成的描述

图 11.6 样本 OFAC 报告屏幕

11.9.1 OFAC 页面

如下所示,我们首先创建主页面并从 API 检索数据。我们本可以像前几节那样创建一个占位符页面,并确保在尝试填充数据之前可以导航到它。在这种情况下,因为我们已经使用了类似的方法来处理异常(第九章)和公司信息(第十章),所以我们直接进入构建页面。IDE 应该会抱怨我们没有OfacResponseOfacRecords组件,但我们将接下来解决这些问题。

列表 11.36 示例页面和 API 调用
export default function OfacPage() {

    const [entries, setEntries] = useState<OfacResponse[]>([]);

    useEffect(() => {
        const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? ''; #1
        axios.get<OfacResponse[]>(`${apiUrl}/files/ofac`, {  #1
            headers: {  #1
                'Content-Type': 'application/json'  #1
            }  #1
        }) #1
            .then(response => {  #1
                setEntries(response.data);  #1
            })  #1
…
    return ( #2
…  #2
                    <OfacRecords records={entries}/>  #2
…  #2
    );  #2

1 使用 axios 进行 OFAC 扫描的 API 请求

2 将结果传递给我们的组件,以便以良好的方式展示给用户

如前所述,这已成为一种相当标准的流程。根据手头的任务和需要创建的页面数量,我们有时会创建那些占位符,只是为了感受导航和流程。或者我们可能选择在开始工作时添加页面。我们应该在方法上保持灵活。

11.9.2 OFAC 组件

我们必须创建OfacResponse来保存 API 结果。正如我们与生成式 AI 合作时发现的那样,它确实可以通过生成一些标准代码来提高我们的生产力。我们可以用它从 SQL CREATE TABLE语句生成 Pydantic 定义。然后,我们从 Pydantic 类生成 TypeScript 接口,这通常能很好地捕捉到所需字段。以下列表显示了OfacResponse接口。

列表 11.37 OfacResponse 接口
export interface OfacResponse {
 id: number;
 ach_files_id: string;
 ach_batch_id: string;
 sdn_name: string;
 individual_name: string;
 alias: string | null;
 similarity_score: number;
 daitch_mokotoff_match_name: boolean;
 daitch_mokotoff_match_alias: boolean;
}

现在我们来看如何创建组件本身,就像我们在仪表板中创建之前的组件一样,使用 MUI DataGrid。

列表 11.38 OfacRecords 组件
interface OfacRecordsProps {
    records: OfacResponse[];
}

export default function OfacRecords({records}: 
➥Readonly<OfacRecordsProps>) {

    const columns: GridColDef[] = [
        {
            field: 'ach_files_id', #1
            headerName: '',  #1
            width: 100,  #1
            renderCell: (params) => (  #1
                <Link href={`/fileDetails/${params.value}`}   #1
                      color="inherit">  #1
                    View File  #1
                </Link>  #1
            ),
        },
        {
            field: 'ach_batch_id', #2
            headerName: '',  #2
            width: 100,  #2
            renderCell: (params) => (  #2
                <Link   #2
href={`/fileDetails  #2
       /${params.row.ach_files_id}/batchDetails  #2
       /${params.value}`}   #2
                      color="inherit">  #2
                    View batch  #2
                </Link>  #2
            ), 
        },
…
        {field: 'similarity_score',  #3
                   headerName: 'Score', width: 75,   #4
                   renderCell: (params) => (  #4
                <Typography>{Math.floor(params.value)}</Typography>  #4
            )},  #4
        {
            field: 'daitch_mokotoff_match_name', #4
            headerName: 'Name Match', 
            width: 100, 
            renderCell: (params) => ( #5
… 
                   {params.value ?  
                       <CheckCircle sx={{color: "green"}}/> : null} 
… 
        },
        {
            field: 'daitch_mokotoff_match_alias', #6
            headerName: 'Alias Match',  #6
 width: 100, #6
 renderCell: (params) => ( #6
…  #6
 {params.value ? #6
 <CheckCircle sx={{color: "green"}}/> : null} #6
…  #6
 },
 ];
…
 <DataGrid columns={columns} rows={records}/> #7
…

1 使用 ach_files_id 创建用户可以点击以导航到匹配项所在文件的链接。

2 要导航到具有匹配项的批次,我们需要从行对象中提取 ach_files_id 以及 params.value,尽管我们也可以使用 params.row.ach_batch_id 字段。

3 显示相似度得分;我们使用 Math.floor 来去除任何小数,仅作为显示偏好。

4 根据传递的布尔标志,显示带有勾选标记的绿色圆圈或无任何内容

5 根据传递的布尔标志,显示带有勾选标记的绿色圆圈或无任何内容

6 当别名匹配时,显示带有勾选标记的绿色圆圈

7 我们定义的列和行被传递给 DataGrid 组件。

组件创建后,我们应该能够提取 OFAC 扫描的结果,并在我们的组件中显示它们。这应该足以满足故事的需求。在下一节中,我们将概述一些潜在的问题,以总结 OFAC 搜索。

11.9.3 OFAC 结果

现在我们有一个可以展示 OFAC 扫描结果的组件,并且已经满足了我们的故事需求。这很可能会在短期内足够使用,但在生产环境中,我们必须解决一些设计选择问题。

首先,扫描是在页面加载时动态进行的。虽然这对于有限的数据效果很好,但在生产环境中,页面可能会慢到几乎无法移动。SDN 列表包含超过 15,000 条记录,并且每条记录都会在我们进行的每笔 ACH 交易中被扫描。记住,有大量支付通过 ACH 系统流动(目前每年超过 3100 亿次支付),虽然我们不需要扫描所有这些支付,但显然这可能会导致大量的扫描。当我们有多个用户试图查看报告时,这种情况会进一步恶化,因为每一页的请求都会导致另一次扫描。

第二,我们无法保存或导出我们的结果。因为扫描是在请求时进行的(当用户访问页面时)使用数据库中当前存储的数据,我们没有方法来拉取之前的扫描结果。OFAC 提供的 SDN 和其他列表会不断修订,个人会被添加、删除和更新。我们今天进行的扫描可能明天会产生不同的结果,这在当前设置中可能成为合规的噩梦。如果 SDN 列表发生变化,我们无法再证明某个特定个人在某个日期或交易中被匹配,我们可能会发现自己面临章节开头概述的处罚。

第三,在生产环境中,扫描通常在 ACH 环境中文件加载时进行。数据库也可以定期扫描以识别可能违规的潜在客户和交易。所有这些扫描都会生成一份结果副本,可以供审查并存档在冷存储中,以便在需要时检索。在加载时进行扫描可以帮助减少对系统的持续请求,同时提供对可疑活动的实时反馈。然而,对于大文件,此操作仍然可能需要一些时间,并且根据我们当前的架构,我们可能希望为文件引入一个状态,以便这些任务中的一些可以异步完成。

这些只是我们可能面临的 OFAC 扫描挑战中的一部分,更不用说创建一个更复杂的算法来检测可疑活动了。尽管它提出了几个挑战,但我们可以将它们视为探索 ACH、金融、合规和安全等领域的机会,这些领域是我们需要与这个主题相关的。

在本章中,我们探讨了国际 ACH 交易的新 ACH 布局。这种新类型交易在我们的仪表板中引入的同时,也可能使我们的组织面临风险,因为可能违反了美国关于处理受制裁个人的法律和法规。为了保持合规并降低风险,我们探讨了扫描我们的 ACH 文件,寻找可能出现在 OFAC 提供的受制裁个人名单上的客户。为了完成所有这些,我们增强了我们的 ACH 文件解析,对客户姓名进行了模糊匹配,提供了一个 API 来支持这一切,并在 UI 组件中展示了结果。

摘要

  • 国际 ACH 交易(IAT)引入了独特的监管要求,需要精确的格式,这强调了在金融系统中进行仔细规划和合规验证的必要性。

  • 扩展数据库结构以适应 IAT 需要整合针对特定 ACH 组件定制的新的表格,这强调了系统可扩展性规划的重要性。

  • 生成 IAT 批处理需要调整现有解决方案以处理额外的头信息和条目信息,突出了国际交易标准的复杂性。

  • 综合单元测试确保新的 IAT 功能与现有的 PPD 流程保持一致,同时防止中断,展示了回归测试在软件开发中的重要性。

  • 通过重构简化代码提高了可维护性并减少了冗余,这表明随着项目的演变,持续进行代码库优化是必要的。

  • 在仪表板中集成 IAT 处理需要理解技术限制和用户需求,这强调了在功能开发中的跨职能一致性。

  • 设计用于处理 IAT 数据的 API 确保了数据检索和处理的连续性,展示了灵活且健壮的系统接口的必要性。

  • 处理 IAT 时,OFAC 合规性至关重要,以减轻与受制裁个人相关的风险,这突出了在交易处理中遵守监管规定的作用。

  • 实施模糊匹配算法有助于检测嫌疑人姓名的细微变化,从而提高识别受制裁交易的准确性。

  • OFAC 结果交付的有效 UI 组件强调了用户在复杂的合规环境中导航时清晰、可操作的见解的价值。

  • 持续扫描结果通过提供可追溯的审计轨迹来满足合规要求,这强调了在金融应用中可靠数据存储和检索的需求。

  • 添加 IAT 处理提高了组织对风险的认识,这强调了制定严格的合规框架以有效应对国际法规的迫切需要。

第十二章:接下来去哪里

本章涵盖

  • 扩展仪表板

  • 提升我们的自动清算所处理

  • 深入探索金融科技

在这本书中,我们完成了一个整个程序增量——从研究激增开始,我们推导出一个最小可行产品(MVP),并根据利益相关者的反馈增强了产品。在这个过程中,我们使用 FastAPI 进行 Python 编程,以记录和驱动我们的 API。此外,我们使用 Next.js 和 Material UI 来展示存储在我们 Postgres 数据库中的数据。在这个项目中工作扩展了我们对金融科技和自动清算所处理的了解。

然而,我们只是触及了可能性的表面。通过处理自动清算所交易,我们能够驱动许多你可能在日常生活中经常遇到的有意思的功能。换句话说,项目现在处于一个可以扩展到软件开发和金融不同领域的点。本章分享了一些你可以用来改进项目的想法,图 12.1 展示了各种可能性的概述。

公司图示  自动生成描述

图 12.1 扩展我们的项目

12.1 后端处理

我们使用 Python、FastAPI 框架和运行 Postgres 的数据库构建了后端处理。虽然我们有机会触及所有这些领域,但我们并没有真正深入地检查它们,但现在是你调查我们跳过或未能涵盖的一些可能性完美的时机。本节涉及后端的一些你可能觉得既有趣又有价值的领域。

12.1.1 异步处理

到目前为止,你应该对自动清算所处理有了更好的理解,因此让我们探讨一些增强上传文件解析的可能方法。在上传文件时,我们目前必须等待文件加载和处理完毕后才能返回响应。对于大文件来说,这根本不会是一个愉快的体验,因为处理过程可能会超时,用户可能不会意识到文件仍在后台加载。这个缺点可能会导致用户尝试第二次、第三次或第四次处理文件。根据我们实施的保障措施,如果没有方法可以识别文件是相同的(例如计算哈希),这很可能会导致重复的交易被记入账户。

我们不仅需要担心大文件,还需要担心与文件加载相关的辅助处理。我们在 OFAC 扫描和我们将如何可能需要将其集成到自动清算所文件处理本身中看到了这一点。你还需要将交易记入账户,根据适当的情况借记或贷记账户。当 OFAC 扫描和账户是独立的服务或需要额外的 API 调用时,当涉及大文件或远程服务时,你会看到用户体验的下降。

解决这个问题的方法之一是在文件加载中引入异步处理,然后分步骤处理文件。通过引入这种处理方式,你还将有更多机会改进用户体验。想象一下,用户正在加载一个文件。一旦你可以确认文件已成功上传,你就可以在那个点响应用户。然后文件可以被处理,用户可以通过仪表板上的消息、电子邮件或短信得到通知。要实现这个功能,你需要引入文件状态并跟踪文件通过各种阶段。

12.1.2 ORM 框架

对象关系映射器 (ORM) 允许将对象映射到数据库,并可以简化我们的类与数据库之间的交互。SQLAlchemy 和 Peewee 等 ORM 是 Python 中流行的选项,可以与包括我们的 Postgres 数据库在内的各种数据库一起工作。那么,我们为什么没有使用 ORM 呢?

我们认为 ORM 非常方便,但它们带来的抽象级别也带来了一定的学习曲线。当然,学习纯 SQL 也有其学习曲线!现在你已经学习了一些 SQL 概念并创建了一些查询,现在是时候看看你是否可以加入 ORM 并替换一些手写的 SQL。

12.1.3 应用程序编程接口

在这个阶段,RESTful API 在行业中无处不在,FastAPI 为我们在应用程序中构建 API 提供了一种很好的方法。它还允许我们轻松生成 OpenAPI 文档,因此有很多机会学习 API 开发。我们还简要地了解了一些 API 管理器,例如 WSO2。

我们构建了足够的 API 来演示 ACH 处理和一些仪表板功能,但要有一个完全功能的仪表板,我们需要生成额外的 API 和功能。例如,仪表板应该提供添加/更新/删除公司、删除上传的文件以及撤销与删除文件相关的一切(例如,OFAC 相关的扫描)的能力。你可以在项目的许多地方扩展或增强当前的 API。

另一个选择可能是开始引入 GraphQL。虽然 RESTful API 仍然是主要的 API 标准,但 GraphQL 仍然有其位置。无论我们是否想要从 REST 迁移,还是将其与我们的 RESTful API 结合使用,GraphQL 都是一个值得探索的有趣选项。至少,你可以获得一些实践经验,这样当另一个团队想要实施它时,你会有一个有根据的意见。

12.2 基础设施

大多数公司和项目都有强大的基础设施——它支持项目的长期可持续性。例如,项目需要以某种形式构建、打包和部署。这个过程可能需要代码合并、审查、扫描漏洞或只是确保代码符合现有标准。在本节中,我们探讨如何改进 ACH 仪表板的架构。

12.2.1 Docker

使用 ACH 仪表板,我们高度依赖 Docker,不仅因为理解并熟悉容器技术很重要,而且因为探索其他技术和产品时它也非常有用。

例如,Atlassian 等公司提供了允许我们运行其产品的 Docker 镜像,这有助于在沙盒环境中评估它们。我们看到了如何使用 Docker 镜像轻松地将 Postgres 启动并运行。此外,WSO2 API Manager 也可通过 Docker 下载。您应该记住,无论何时您想要探索新事物,都可能有一个容器让您快速开始工作。无论是 Docker 上的官方 Golang、MongoDB 或 Debian Linux 发行版的镜像,您都可以探索丰富的资源,而无需在您的机器上安装大量产品——只需使用容器,完成后即可丢弃。

12.2.2 多租户环境

虽然存在几种多租户环境版本,但我们认为多租户环境涉及两个不同的实体(金融机构)共享相同的数据库表。我们只考虑了该应用程序被 Futuristic FinTech 使用。但如果您想要或需要支持额外的金融机构呢?这可以通过几种方式来解决。首先,由于应用程序作为独立服务运行,您可以简单地启动额外的容器来处理新的金融机构。您应该能够做到这一点而无需任何额外设置。每个金融机构都会在自己的实例上运行,并且您会有不同的 DNS 名称指向这些位置,例如 http://bankA.dashboard.comhttp://bankB.dashboard.com

另一个选择是更新数据库,使表也利用租户 ID,这可能是银行的 ABA 号码或其他标识符。这个选项允许银行存在于同一个数据库中。标识符不一定需要添加到所有表中(例如,个人 ACH 记录表不需要标识符,因为它们依赖于 ACH 文件表),这意味着 ACH 文件本身可以与特定的银行关联,然后该文件中的所有记录将自动与该银行关联。然而,您需要审查查询以确保无法使用另一家银行的 UUID 拉取银行数据。

使用单个数据库与多个数据库相比,各有优缺点。根据行业、公司和监管法规的不同,客户可能需要将数据隔离在不同的数据库中,甚至限制使用自己的硬件。然而,添加其他可以使用自己的路由号、公司和其他类似功能的仪表板,将允许您探索不同类型的架构,并有机会解决这类问题。

12.2.3 持续集成和交付

我们的项目使用了在 Docker 下运行的几个容器。这种方法对我们的需求来说已经足够好了,尤其是在运行数据库在容器中时。我们能够在需要时将容器关闭并从全新的数据库开始。我们遇到的一个挑战是将我们的项目从 IDE 或 GitHub 这样的源代码控制系统转移到那些容器中,因为目前这是一个手动过程。

一个值得探索的方向(特别是如果你没有太多机会这样做)是看看自动化这些任务的各种方法。这让你朝着持续集成(CI)和持续部署(CD)的方向前进。例如,你可以从 IDE 自动化移动和/或构建代码到适当的容器中。你也可以创建一个 CI/CD 服务器(如 Jenkins)的实例,并学习从那里构建和部署代码。此外,你也可以使用 GitHub Actions 来部署代码,尽管由于当前的基础设施设置需要在本地 Docker 容器中运行,使用本地 Jenkins 服务器可能更容易。

像 Jenkins 这样的构建服务器允许你在构建过程中自动化其他任务,例如运行单元测试、扫描安全漏洞以及执行一系列其他任务。你很可能会在任何规模和任何行业的公司中找到这些类型的构建服务器。

12.2.4 测试

我们尝试引入了几种测试应用程序的方法,主要借鉴了 TDD 和 BDD。在引入后,我们没有机会重新审视使用 Locust 对 API 进行负载测试。我们也没有进行太多的 UI 测试,除了在 Jest 和 Playwright 中进行的一些测试。

扩展测试和审查测试覆盖率是熟悉测试过程的一个很好的方法。你也可以确保测试足够健壮,能够在 CI/CD 服务器上运行。从编写测试和确保它们通过发布管道运行中可以学到很多东西。

12.2.5 数据库

我们选择了 Postgres 作为我们的数据库首选,但你当然不限于使用 Postgres。更改数据库可以与探索 ORM 框架相关联,因为它们提供了一种抽象级别,当底层数据库需要更换时可能很有帮助。如果企业有其他他们偏好的供应商或数据库产品,你通常必须使用那个数据库。如果你对销售产品并允许客户在其环境中安装它感兴趣,数据库选择的灵活性可以成为一个很大的卖点。如果一家公司使用 Oracle 数据库,而你的产品需要 Postgres 数据库,客户可能不会对管理另一个数据库产品的开销感兴趣。

一种替代方案不仅仅是将 Postgres 的 RDMS(关系数据库管理系统)换成其他供应商的产品,还要考虑探索使用 NoSQL 数据库,例如 MongoDB。虽然 Postgres 是存储 ACH 仪表板数据的绝佳选择,但将一些表迁移到 MongoDB 可能也很有趣。你可以从一些小的地方开始,比如bank_routing_numbers表,然后寻找可以迁移的其他表。你需要 MongoDB 来用于 ACH 仪表板吗?不需要,但你已经理解了可能使探索和过渡到新数据库变得有趣的表结构和数据。

12.3 用户界面

如果你想探索开发的另一端,从 UI(用户界面)的角度来说,有很多事情可以做。在整个项目中,我们使用了 React 和 Next.js 框架来构建界面。你可以使用其他的 React 框架,或者转换方向使用 Angular,甚至使用传统的 HTML、CSS 和 JavaScript!这完全取决于你的兴趣和需求所在。

12.3.1 图表和表单

我们使用了 Recharts 来创建一些散点图、条形图和饼图。然而,我们没有深入研究,你当然可以致力于添加新的图表并可视化 ACH 文件的其他方面。图 12.2 和 12.3 展示了两个样本图表,可视化了一段时间内的 ACH 文件。由于当前的图表专注于文件中的现有内容,它们将是我们用户的一个很好的补充。

你还可以让用户选择他们想要查看数据显示的图表类型。给图表添加一些交互性也会很好,比如根据用户操作动态调整数据或导航到其他页面。通过添加额外的图表和交互性,你可能可以改善用户体验,并使仪表板更加实用。

仪表板需要更新的另一个方面是通过使用表单来管理信息的基本方式。例如,公司信息被显示出来,但无法更新它。你可以启用更新、执行验证或尝试不同的方法向用户展示数据。我们还使用了大量的 DataGrid 元素来展示我们的数据。你可能继续使用这些元素,或者设计其他组件来展示数据。这可能会引导你探索像 Yehonathan Sharvit 在《面向数据的编程》(2022,Manning)中提到的那些概念。

多列的图表,描述自动生成

图 12.2 一年内按月支付的工资

多个公司的图表,描述自动生成,中等置信度

图 12.3 按公司划分的 ACH 返回

12.3.2 可配置的仪表板

你可以看到,在可配置仪表板方面,至少有三个级别的发展(可能还有更多)。你可以创建一些更多的预包装组件,用户可以将它们拖放到仪表板屏幕上以配置他们在查看主仪表板和公司级别时想要看到的内容。虽然这会给用户带来更多灵活性,并且可能极大地造福他们,但我们还可以提供更多的定制。

而不是从预包装组件中选择,如果允许他们自定义和配置自己的组件会怎样?通过允许用户选择要显示的字段并根据该信息生成图表,你可以给他们提供更精细的 UI 控制。

下一个发展步骤是允许用户使用仪表板或后端提供的 API 编写自己的组件。例如,Jenkins 或 Atlassian 套件允许第三方编写扩展产品功能的外部工具。想象一下,ACH 仪表板 UI 有一个市场,其中自定义用户组件通过我们之前描述的定制方式集成到仪表板中。也许用户想要在 ACH 文件加载、处理文件时发生异常或为 VIP 客户有交易时向 Microsoft Teams 或 Slack/Discord 频道发布消息以发出警报。

12.3.3 移动版本

我们使用 API 来确保后端和 UI 分离。不仅使用 API 是分离组件的好方法,而且它还允许你创建其他方式(或渠道)供用户与应用程序通信。例如,你可以使用 React Native、Dart、Swift、Kotlin 或任何你想要的来设计仪表板的移动版本。

对于移动应用程序的另一种方法可能是允许客户(公司)直接从他们的移动应用程序中创建批次并输入 ACH 系统的交易!因此,即使在处理 ACH 和 FinTech 时,我们也可以在这个领域继续工作。

12.4 财务

如果我们在技术方面已经拥有大量知识,并希望继续扩展应用程序的功能,那么也有许多可能性。

12.4.1 账户和客户

我们建议首先进行的一项任务是为应用程序添加账户。这些账户是存在于同一数据库中还是作为独立服务,这是可以在整体设计中做出的决定。添加账户会带来一些可能性,其中最不重要的是,如果你有账户号码,这些账户必须属于客户。客户与账户之间的关系并非小事,你可以考虑处理账户上的关系,例如主要/次要所有者或授权签字人等。

对于数据库中的账户(或通过 API 发布到核心银行产品),ACH 交易可以入账到账户。这会产生级联效应,导致您需要处理资金不足条件(也称为 NSF)、错误的或错误的账户号码错误、利息累积和账单。

当一个账户的资金不足以支付交易时,这可能会为账户创造一个全新的转账世界。您可能在该账户上有透支保护,或者可能从另一个账户转账资金以支付费用。此外,您可能还想查看计划转账或账单支付。

当一笔交易由于资金不足、无效的账户号码或其他多种条件而未能入账时,它可能会被退回或未入账。您需要处理退回或未入账项的方式可能因客户而异,以及根据具体情况的不同。

所有这些账户活动——例如交易入账、透支转账以及相关费用——必须在客户的月度账单上反映。这些账单和通知可以通过平信、电子邮件或在线查看。

最后,之前所做的许多事情也需要被撤销。例如,如果由于某些错误,一笔交易多次入账到账户,您需要能够安全地撤销该借记或贷记。这也可能导致需要撤销透支转账以及账户可能因错误交易入账而发生的任何相关费用。

12.4.2 ACH 原始交易和退回

ACH 原始交易是指创建将由 ACH 网络处理的 ACH 交易,如贷记和借记。在我们的项目中,我们专注于在仪表板内接收和处理 ACH 文件。金融机构可以创建 ACH 文件以帮助促进银行与其客户之间的资金转移。我们在创建用于测试的 ACH 文件时,也稍微触及了创建文件的过程。然而,随着我们仪表板中公司的增加,在创建文件方面出现了更多的发展机会。那些定义了数据库的公司可能希望为支付员工(工资)或其他企业/公司创建文件。您需要提供创建带有交易条目/附加信息的批次的必要能力。然后,金融机构将代表公司发送这些批次。

ACH 处理的另一个方面是需要处理退回。当金融机构拒绝交易时,就会发生 ACH 退回。可能导致这种情况发生的原因包括账户信息不正确、资金不足或交易中的止付。在 ACH 退回的情况下,您将把资金退回到原始账户,并可能收取费用。

通过添加 ACH 发起和处理退回的功能,您开始构建一个更完整的 ACH 处理系统。

12.4.3 正面支付

您可以考虑的另一个功能是构建正面支付系统。正面支付是一种最初为支票处理而创建的欺诈预防系统,但它已经扩展到其他交易处理领域。基本上,使用正面支付,客户将在仪表板上输入交易或在仪表板上上传预期交易的列表。然后系统将检查日期、金额、账户号码、姓名等字段,与公司提供的数据进行核对,以确保已添加交易。

当进入系统的交易与项目不匹配时,它们会被标记并拒绝处理。基本上,这是为了防止银行处理未经授权的交易,降低公司和银行的风险和欺诈。正面支付通常提供给商业客户,但在理论上,任何客户都可以使用。

12.4.4 服务费用

这是一个广泛的领域,客户可能会因交易类型或账户问题而被收取服务费用或费用。例如,大多数人痛苦地熟悉透支或资金不足的费用,这发生在交易金额超过可用余额时,银行会收取费用。显然,如果您正在处理 ACH 交易,您可能会遇到这种情况。其他费用可能与不同的操作相关,例如发起 ACH 交易、处理 IAT 交易、创建止付或每月维护费,仅举几个例子。

由于您拥有公司、客户和账户(根据您实施了多少附加功能而定),有充足的机会向客户应用不同类型的服务费用。继续遵循 ACH 主题,您可以选择根据公司处理的交易数量或发起的交易数量收费,或者对退回或撤销的项目收费。虽然服务费用开始涉及更广泛的 FinTech 领域,但在 ACH 特定领域应用费用仍有大量机会。

12.5 最后的想法

我们已经到达了旅程的终点,并希望感谢您一直陪伴我们!本书的主要目标是激发您对软件开发和金融的兴趣,希望您继续探索其他领域或深入研究我们已经触及的主题。根据我们的经验,将优秀的软件开发者与伟大的软件开发者区分开来的是好奇心和愿意扩展知识以继续成长。承担这本书并完成它应该表明您具备成为一名优秀程序员所需的素质。

在这本书中,你有机会通过敏捷方法完成一个 FinTech 项目。我们希望提供一个基于现实世界的项目,并遵循你可能在任何你将工作的公司中看到的过程。

从处理 ACH 文件,到创建 API,以及在 UI 中展示一切,你工作了很多领域,覆盖了很多额外的主题。我们希望你喜欢这段旅程!

摘要

  • 不论是扩展现有代码、替换 FastAPI 框架,还是完全转向 Python 之外,我们都可以做很多事情来继续扩展项目的后端处理。将功能从一种语言重写到另一种语言通常能提供很好的学习体验,而且能够使用在原始语言中不可用的语言或框架特性,这可以带来启发。

  • 有时,由于资源限制,需要重写代码片段或整个应用程序。我们见过应用程序被重写为新的语言,因为它们是使用非标准语言编写的遗留软件,而团队没有足够的带宽或资源去学习它,只是为了维护那块软件。

  • 建立良好的产品通常周围有大量的基础设施。这种基础设施通常是自制的,尤其是如果我们处理的是可能在商业工具可用之前就存在的遗留软件。

  • 了解现有的工具可以很有帮助,因为自制的工具最终需要被替换。通常,随着团队成员的成熟,需要用标准产品替换现有的自制工具,以便新团队成员的学习曲线降低。

  • ACH 在美国被广泛使用,正如我们所见,它还具有国际能力。处理 ACH 交易为每年数十亿次发生但没有多少宣传的实际事件提供了真实世界的曝光。创建一个简单的系统来开始处理 ACH 交易,并查看处理过程中的情况,为我们提供了一个很好的扩展基础。

索引

posted @ 2025-11-23 09:27  绝不原创的飞龙  阅读(14)  评论(0)    收藏  举报