深圳幻海软件技术有限公司 欢迎您!

有了Bug,先看看“Type”

2023-02-28

译者|崔皓策划 | 云昭1、开篇不懂原始类型的程序员,往往由于急于求成,上手很快,最后却发现被各种Bug耽误进度。本文以经典的邮箱类型、货币类型、密码类型为例,利用好类型系统能够很好地改进编码方式,同时为技术人找回“打稳地基”的快乐。2、字符串类型变身成为邮箱类型笔者已经厌倦了使

译者 | 崔皓

策划 | 云昭

1、开篇

不懂原始类型的程序员,往往由于急于求成,上手很快,最后却发现被各种Bug耽误进度。本文以经典的邮箱类型、货币类型、密码类型为例,利用好类型系统能够很好地改进编码方式,同时为技术人找回“打稳地基”的快乐。

2、字符串类型变身成为邮箱类型

笔者已经厌倦了使用原始类型,并试图通过使用原始类型,来为一个领域进行建模。

字符串值(String)类型不仅仅用来保存用户的电子邮件地址或国籍信息,还可以有更丰富的用途。我需要一个EmailAddress的类型,并定义它不能为空,同时希望有单一的入口来创建该类型的对象。在返回一个值之前,需要被验证和规范化。

同时,也希望该数据类型有一些方法,如.Domain()或.NonAliasValue(),在输入foo+bar@gmail.com时,会分别调用这两个方法并返回gmail.com和foo@gmail.com。

在类型设计中应该考虑这种有用的功能,该功能的引入有助于防止错误的发生,并提高了类型的可维护性。

3、设计良好、功能实用的类型

例如,一个EmailAddress可以提供两个方法来检查是否相等。

lEquals方法用来判断两个(规范化)的电子邮件地址是否相同,如果相同将返回true。

lEqualsInPrinciple
  • 1.

该方法对于foo@gmail.com和foo+bar@gmail.com的输入会判断相同,因此也会返回true。(这里假设两个邮箱都是同一个人注册的,因此相同需要判断两个邮箱“相等”)

特定类型的方法在不同的使用场景下都会发挥不同的作用。如果用户jane@gmail.com注册,但又用Jane@gmail.com登录,那么用户的登录不应该失败(仅仅存在首字母大小写的区别)。同样的,如果户用使用电子邮件地址(foo@gmail.com)和另一个注册账户(foo+svc@gmail.com)联系客户支持,相同就需要对这两个邮箱进行有效匹配。这些都是典型应用场景,如果没有散落在代码库中的业务逻辑,仅凭一个简单的字符串是无法满足的。

注意:根据Office RFC描述,电子邮件地址中@符号之前的部分可以区分大小写,但所有主要的电子邮件主机都将其视为不区分大小写,因此,域名类型也考虑这方面的问题。

4、好的类型可以防止Bug

QQaqJ2j">顺着上面邮箱类型的例子,如果我们想走得更远,假如希望一个电子邮件地址可以被验证或未被验证。通常的做法是,通过向个人的收件箱发送一个独特的代码来验证电子邮件地址。这些 "商业 "上的互动也可以通过类型系统来表达。例如,创建一个叫做VerifiedEmailAddress的第二个类型。该类型可以继承自EmailAddress。并且确保代码中只有一个地方可以产生VerifiedEmailAddress的实例,即负责验证用户地址的服务。如此这般,应用程序的其他部分可以依靠这个新类型来防止Bug。

任何发送电子邮件的功能都可以依靠该类来验证的电子邮件地址的安全性。想象一下,如果电子邮件地址是通过简单的字符串来表达的,会是怎样的情况。

因此,要找到相关的用户账户,检查一些模糊的标志,如HasVerifiedEmail或IsActive,确保这些标志设置是正确的,而不会在默认构造函数中被错误地初始化为真。有太多的错误空间由于使用了原始字符串,导致有些检查不到位的情况,这种使用原始类型的表达方式被认为是懒惰和缺乏想象力的编程。

5、富类型免受错误的侵扰

另一个很好的例子是货币!我已经数不清有多少应用程序使用十进制来表达货币值。也已经数不清有多少应用程序使用十进制类型表达货币值。为什么呢?

这种类型有很多问题,甚至很难理解。每个与钱打交道的领域都应该有专门的货币类型。货币类型应该包括货币和运算符重载(或其他安全功能),以防止出现100美元与20英镑相乘这样的愚蠢错误。此外,并非每种货币在小数点后都只有两位数。有些货币,如巴林或科威特第纳尔有三位。如果你在致力处理投资或银行贷款,那么你最好确保你呈现的Unidad de Fomento有4个小数点。这些问题已经很重要了,足以保证有一个专门的Moneytype,但这还远远不够。

除非在系统内部完成所有功能,否则就不得不与第三方系统打交道。例如,大多数支付网关都是以整数值来请求和响应资金。由于整数值不能涵盖类似浮点数(双数类型)的四舍五入运算,因此比浮点数更受欢迎。唯一需要注意的是,数值必须以小单位(如美分、便士、迪拉姆、格罗兹、科佩克等)传输,这意味着如果你的程序处理小数点数值,在与外部API对话时,你将不得不不断地来回转换它们。如前所述,并不是每种货币都使用两个小数点,所以不是每次都是简单的乘/除以100。事情很快就会变得很困难,如果这些业务规则被封装成一个简洁的单一类型,事情就会被大大简化。

var x = Money.FromMinorUnit(100, "GBP"):£1
var y = Money.FromUnit(100.50, "GBP"):£1.50
Console.WriteLine(x.AsUnit()):1.5
Console.WriteLine(x.AsMinorUnit()):150
  • 1.
  • 2.
  • 3.
  • 4.

如果这还不够复杂的话,各国也有不同的货币格式来表示货币。在英国,"一万英镑和五十便士 "将被表示为10,000.50,但在德国,"一万欧元和五十美分 "将被显示为10.000,50。试想一下,如果这些规则没有放到统一的货币类型中,那么在整个代码库中会有多少与货币相关的代码被分割开来。

此外,一个专门的货币类型可以包括许多功能,这将使货币价值的工作变得轻而易举。

var gbp = Currency.Parse("GBP");
var loc = Locale.Parse("Europe/London");
var money = Money.FromMinorUnit(1000050, gbp);
money.Format(loc) // ==> £10,000.50
money.FormatVerbose(loc) // ==> GBP 10,000.50
money.FormatShort(loc) // ==> £10k
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

当然,建立这样一个Money类型在开始的时候会有点费劲,但是一旦它被实现并经过测试,那么代码库的其他部分就可以带来更大的安全性,并防止大多数的Bug的产生,否则这些Bug会随着时间的推移而慢慢出现。即使像Money.FromUnit(decimal v, Currency c)或Money.FromMinorUnit(int v, Currency c)这样的小功能看起来并不多,但它使参与连续开发的程序员能够意识到,用户输入或外部API收到的值是否包含在其中,这样可以在一开始就防止Bug的产生。

6、聪明的类型设计减少副作用

富类型的伟大之处在于,可以以任何的方式来塑造它们。这里展示另外一个例子,富类型如何将团队从巨大的操作开销中拯救出来,甚至防止安全漏洞。

相信很多系统中的代码库都有一个类似于字符串secretKey或字符串password的东西,它作为函数的参数。那么在什么情况下有可能出错呢?

如下(伪)代码:

try
{
var userLogin = new UserLogin
{
Username = username
Password=password
}
var success = _loginService.TryAuthenticate(userLogin);
if(success)
RedirectToHomeScreen(userLogin)。
ReturnUnauthorized()}
catch (Exception ex)
{
Logger.LogError(ex, "User login failed for {login}", userLogin);
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.

这里出现的问题是,如果在认证过程中抛出一个异常,那么这个应用程序将用户的明文密码写入日志。当然,这段代码一开始就不应该存在,这种情况会随着时间的推移而发生。大多数这样的错误都是随着时间的推移而逐步发生的。

最初,UserLogin类可以有一组不同的属性,在最初的代码审查中,这段代码可能没有问题。几年后,有人可能修改了UserLogin类以包括明文密码。这个功能甚至不会出现在代码提交的差异中,因此会逃过代码审查。于是就引入了安全漏洞。然而,如果引入一个富类型(专有类型),就可以避免类似错误的发生。

在C#中(以这个语言为例),当一个对象被写入日志时,ToString()方法会被自动调用。有了这些知识,我们就可以设计一个这样的密码类型。

public readonly record struct Password()
{
public override string ToString()
{
return "****"}
public string Cleartext()
{
return _cleartext。
}
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.

虽然是一个微小的变化,但在系统的任何地方都不可能意外地输出一个明文密码。这不是很好吗?

当然,在实际的认证过程中,你可能仍然需要明文值,那么就需要通过非常明确的命名方法Cleartext()来实现的,所以对这个操作的敏感性没有任何含糊,它自动引导开发者有意和谨慎地使用这个方法。

处理用户的PII(如国家保险号、税号等)也是同样的原则。使用专门的类型对这些信息进行建模。覆盖默认函数,如.ToString()。ToString()的默认函数,并通过相应的命名函数暴露敏感数据。你永远不会把PII泄露到日志和其他地方,以后可能需要一个巨大的操作来再次刷掉它。

小伎俩发挥了大作用!

7、形成习惯

每当开发者处理那些有特殊规则、行为或敏感数据的时候,不妨考虑如何能通过创建一个显式类型来帮助自己。

让我们再举一个密码类型的例子,可以走得更远!

密码在被存储到数据库之前会进行散列计算,但这个哈希值不是一个简单的字符串。在某些时候,我们将不得不在登录过程中把以前存储的哈希值与新计算的哈希值进行比较。但并不是每个开发人员都是安全专家,比较两个哈希字符串可能会使代码受到攻击。

检查两个密码哈希值是否相等的推荐方法是以非优化的方式进行。

[MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization) ]

private static bool ByteArraysEqual(byte[] a, byte[] b)
{
if(a == null &&b == null)
{
return true;
}
if(a == null || b == null || a.Length != b.Length)
{
return false;
}
var areSame = true;
for (var i = 0; i < a.Length; i++)
{
areSame &= (a[i] == b[i])。
}
return areSame。
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.

注:代码示例取自原始ASP.NET Core资源库

因此,将这一特殊功能编码为一个专门的类型才是合理的。

public readonly record struct PasswordHash
{
public override bool Equals(PasswordHash other)
{
return ByteArraysEqual(this.Bytes(), other.Bytes())}
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.

如果一个PasswordHasher只返回PasswordHash类型的值,即使是对业务不太了解的开发者也会使用一种安全的形式来检查相等。

在建立领域模型方面要考虑周全! 当然,编程中的一切都没有明确的对错之分,人们的个人使用情况总是有更多的细微差别,这些不是在一篇文章中所能表达的,但笔者建议是,考虑如何使类型系统对开发者的帮助很大。现在许多现代编程语言都有非常丰富的类型系统,我们可能忽视了它们没有利用好这些类型改进编码方式。

原文链接:https://dusted.codes/the-type-system-is-a-programmers-best-friend

译者介绍

崔皓,51CTO社区编辑,资深架构师,拥有18年的软件开发和架构经验,10年分布式架构经验。