构建器

Builder 是一种创建型设计模式,它通过提供流畅的接口来简化复杂对象的创建,使我们能够逐步构建对象。 这极大地提高了创建此类复杂对象时的可读性和一般开发人员体验。

我们可以从构建器模式中受益的最明显的情况是具有构造函数的类,该构造函数具有很长的参数列表,或者采用许多复杂的参数作为输入。 通常,这类类预先需要如此多的参数,因为所有这些参数都是构建一个完整且处于一致状态的实例所必需的,因此在考虑潜在的解决方案时有必要考虑到这一点。

那么,让我们看看该模式的一般结构。 想象一下,有一个带有构造函数的 Boat 类,如下所示:

class Boat {
    constructor (hasMotor, motorCount, motorBrand, motorModel,
                 hasSails, sailsCount, sailsMaterial, sailsColor,
                 hullColor, hasCabin) {
        // ...
    }
}

调用这样的构造函数会创建一些难以阅读的代码,很容易出错(哪个参数是什么?)。 以下面的代码为例:

const myBoat = new Boat(true, 2, 'Best Motor Co. ', 'OM123', true, 1, 'fabric', 'white', 'blue', false)

改进此构造函数设计的第一步是将所有参数聚合在单个对象文字中,如下所示:

class Boat {
    constructor (allParameters) {
        // ...
    }
}

const myBoat = new Boat({
    hasMotor: true,
    motorCount: 2,
    motorBrand: 'Best Motor Co. ',
    motorModel: 'OM123',
    hasSails: true,
    sailsCount: 1,
    sailsMaterial: 'fabric',
    sailsColor: 'white',
    hullColor: 'blue',
    hasCabin: false
})

从前面的代码中我们可以注意到,这个新的构造函数确实比原来的构造函数要好得多,因为它允许我们清楚地看到接收每个值的参数是什么。 然而,我们可以做得更好。 使用单个对象文字一次传递所有输入的一个缺点是,了解实际输入是什么的唯一方法是查看类文档,或者更糟糕的是查看类的代码。 除此之外,没有强制协议来指导开发人员创建连贯的类。 例如,如果我们指定 hasMotor: true,那么我们还需要指定 motorCount、motorBrand 和 motorModel,但此接口中没有任何内容向我们传达此信息。

Builder 模式甚至修复了最后几个缺陷,并提供了一个流畅的界面,该界面易于阅读、自记录,并为创建连贯的对象提供了指导。 我们来看一下 BoatBuilder 类,它实现了Boat 类的 Builder 模式:

class BoatBuilder {
    withMotors (count, brand, model) {
        this.hasMotor = true
        this.motorCount = count
        this.motorBrand = brand
        this.motorModel = model
        return this
    }

    withSails (count, material, color) {
        this.hasSails = true
        this.sailsCount = count
        this.sailsMaterial = material
        this.sailsColor = color
        return this
    }

    hullColor (color) {
        this.hullColor = color
        return this
    }

    withCabin () {
        this.hasCabin = true
        return this
    }

    build() {
        return new Boat({
            hasMotor: this.hasMotor,
            motorCount: this.motorCount,
            motorBrand: this.motorBrand,
            motorModel: this.motorModel,
            hasSails: this.hasSails,
            sailsCount: this.sailsCount,
            sailsMaterial: this.sailsMaterial,
            sailsColor: this.sailsColor,
            hullColor: this.hullColor,
            hasCabin: this.hasCabin
        })
    }
}

为了充分理解 Builder 模式对我们创建 Boat 对象的方式的积极影响,让我们看一个例子:

const myBoat = new BoatBuilder()
    .withMotors(2, 'Best Motor Co. ', 'OM123')
    .withSails(1, 'fabric', 'white')
    .withCabin()
    .hullColor('blue')
    .build()

正如我们所看到的,BoatBuilder 类的作用是收集使用一些辅助方法创建船所需的所有参数。 我们通常对每个参数或一组相关参数都有一个方法,但没有确切的规则。 由 Builder 类的设计者决定负责收集输入参数的每个方法的名称和行为。

相反,我们可以总结一些实现构建器模式的一般规则,如下所示:

  • 主要目标是将复杂的构造函数分解为多个更易读且更易于管理的步骤。

  • 尝试创建可以同时设置多个相关参数的构建器方法。

  • 根据 setter 方法作为输入接收到的值推导并隐式设置参数,并且一般来说,尝试将尽可能多的参数设置相关逻辑封装到 setter 方法中,以便构建器接口的使用者无需这样做。

  • 如有必要,可以在将参数传递给正在构建的类的构造函数之前进一步操作参数(例如,类型转换、规范化或额外验证),以进一步简化构建器类使用者要做的工作。

在 JavaScript 中,构建器模式还可以应用于调用函数,而不仅仅是使用构造函数构建对象。 事实上,从技术角度来看,这两种场景几乎是相同的。 处理函数时的主要区别在于,我们不再使用 build() 方法,而是使用 invoke() 方法,该方法使用构建器对象收集的参数调用复杂函数,并将任何最终结果返回给调用者。

接下来,我们将研究一个更具体的示例,该示例利用我们刚刚学到的构建器模式。

实现一个 URL 对象构建器

我们想要实现一个 Url 类,它可以保存标准 URL 的所有组件,验证它们,并将它们格式化回字符串。 该类有意变得简单和最小化,因此对于标准生产用途,我们建议使用内置 URL 类 (nodejsdp.link/docs-url)。

现在,让我们在名为 url.js 的文件中实现自定义 Url 类:

export class Url {
    constructor (protocol, username, password, hostname,
                 port, pathname, search, hash) {
        this.protocol = protocol
        this.username = username
        this.password = password
        this.hostname = hostname
        this.port = port
        this.pathname = pathname
        this.search = search
        this.hash = hash

        this.validate()
    }

    validate () {
        if (!this.protocol || !this.hostname) {
            throw new Error('Must specify at least a ' +
                'protocol and a hostname')
        }
    }

    toString () {
        let url = ''
        url += `${this.protocol}://`
        if (this.username && this.password) {
            url += `${this.username}:${this.password}@`
        }
        url += this.hostname
        if (this.port) {
            url += this.port
        }
        if (this.pathname) {
            url += this.pathname
        }
        if (this.search) {
            url += `?${this.search}`
        }
        if (this.hash) {
            url += `#${this.hash}`
        }
        return url
    }
}

一个标准的 URL 由多个组件组成,因此要将它们全部包含在内,Url 类的构造函数不可避免地很大。 调用这样的构造函数可能是一个挑战,因为我们必须跟踪参数位置才能知道我们正在传递的 URL 的哪个组成部分。 请看以下示例以了解这一点:

return new Url('https', null, null, 'example.com', null, null, null, null)

这是应用我们刚刚学到的 Builder 模式的完美情况。 我们现在就这样做吧。 计划是创建一个 UrlBuilder 类,该类为实例化 Url 类所需的每个参数(或一组相关参数)提供一个 setter 方法。 最后,构建器将有一个 build() 方法来检索使用构建器中设置的所有参数创建的新 Url 实例。 因此,让我们在名为 urlBuilder.js 的文件中实现构建器:

export class UrlBuilder {
    setProtocol (protocol) {
        this.protocol = protocol
        return this
    }
    setAuthentication (username, password) {
        this.username = username
        this.password = password
        return this
    }
    setHostname (hostname) {
        this.hostname = hostname
        return this
    }
    setPort (port) {
        this.port = port
        return this
    }
    setPathname (pathname) {
        this.pathname = pathname
        return this
    }
    setSearch (search) {
        this.search = search
        return this
    }
    setHash (hash) {
        this.hash = hash
        return this
    }

    build () {
        return new Url(this.protocol, this.username, this.password,
            this.hostname, this.port, this.pathname, this.search,
            this.hash)
    }
}

这应该非常简单。 只需注意我们将用户名和密码参数耦合到单个 setAuthentication() 方法中的方式即可。 这清楚地传达了这样一个事实:如果我们想在 Url 中指定任何身份验证信息,则必须提供用户名和密码。

现在,我们准备尝试 UrlBuilder 并见证它相对于直接使用 Url 类的优势。 我们可以在一个名为index.js的文件中做到这一点:

import { UrlBuilder } from './urlBuilder.js'

const url = new UrlBuilder()
    .setProtocol('https')
    .setAuthentication('user', 'pass')
    .setHostname('example.com')
    .build()

console.log(url.toString())

正如我们所看到的,代码的可读性得到了显着的提高。 每个 setter 方法都清楚地提示我们正在设置什么参数,最重要的是,它们提供了一些有关如何设置这些参数的指导(例如,用户名和密码必须一起设置)。

Builder 模式也可以直接实现到目标类中。 例如,我们可以通过添加一个空构造函数(因此在对象创建时不进行验证)和各个组件的 setter 方法来重构 Url 类,而不是创建单独的 UrlBuilder 类。 然而,这种方法有一个重大缺陷。 使用与目标类分离的构建器具有始终生成保证处于一致状态的实例的优点。 例如,UrlBuilder.build()返回的每个Url对象都保证是有效的并且处于一致的状态; 对此类对象调用 toString() 将始终返回有效的 URL。 如果我们直接在 Url 类上实现 Builder 模式,则不能说同样的情况。 事实上,在这种情况下,如果我们在设置各个 URL 组件时调用 toString(),它的返回值可能无效。 这可以通过添加额外的验证来缓解,但代价是增加更多的复杂性。

in the wild

Builder 模式是 Node.js 和 JavaScript 中非常常见的模式,因为它为创建复杂对象或调用复杂函数的问题提供了非常优雅的解决方案。 一个完美的例子是使用 http 和 https 内置模块中的 request() API 创建新的 HTTP(S) 客户端请求。 如果我们查看它的文档(可在nodejsdp.link/docs-http-request获取),我们可以立即看到它接受大量选项,这是构建器模式可以提供更好的界面的常见迹象。 事实上,最流行的 HTTP(S) 请求包装器之一 superagent (nodejsdp.link/superagent) 旨在通过实现 Builder 模式来简化新请求的创建,从而提供一个流畅的接口来逐步创建新请求 。 请参阅以下代码片段作为示例:

superagent
    .post('https://example.com/api/person')
    .send({ name: 'John Doe', role: 'user' })
    .set('accept', 'json')
    .then((response) => {
        // deal with the response
    })

从前面的代码中,我们可以注意到这是一个不寻常的构建器; 事实上,我们没有 build() 或 invoke() 方法(或任何其他具有类似目的的方法),也没有使用 new 运算符。 触发请求的是对 then() 方法的调用。 有趣的是,超级代理请求对象不是一个 Promise,而是一个自定义的 thenable,其中 then() 方法触发我们使用构建器对象构建的请求的执行。

我们已经在第 5 章 “带有 Promises 和 Async/Await 的异步控制流模式” 中讨论了 thenable。

如果您查看该库的代码,您将看到 Request 类中正在运行的 Builder 模式 (nodejsdp.link/superagent-src-builder)。

我们对 Builder 模式的探索到此结束。 接下来,我们将看看揭示构造函数模式。