工厂设计模式:继承 vs 委托 vs 模块级函数

Factory design pattern: inheritance vs delegation vs module-level functions

我正在开发一个模块,允许用户创建 SQLAlchemy 的 URL 对象的实例,专门用于通过 pyodbc 连接到 MS SQL 服务器。该模块需要公开一个方便的 API,其中 URL 可以通过指定主机名、端口和数据库或 DSN,或通过传递原始 ODBC 连接字符串来创建。因此,那些 URL 的字符串表示形式如下所示,其中已经指定了数据库和驱动程序,其余部分由用户决定:

"mssql+pyodbc://<username>:<password>@<host>:<port>/<database>?driver=<odbc-driver>"
"mssql+pyodbc://<username>:<password>@<dsn>"
"mssql+pyodbc://<username>:<password>@?odbc_connect=<connection-string>"

现在这似乎是工厂模式的一个很好的用例,因此我为每个创建了一个单独的 method/function(例如 from_hostnamefrom_dsnfrom_connection_string)创建 URL 的不同方法。但我可以想到该模式的四种不同实现方式,我想知道更喜欢哪一种。

(旁注:您会在下面注意到,我通过 class 工厂方法 URL.create. This is because the SQLAlchemy developers would like to keep users from instantiating URLs via direct calls to the default constructor 实例化了 URL。此外,为了简单起见,我忽略了所有类型methods/functions 应该接受的其他有用参数,例如身份验证。)

1个继承

I subclass URL 添加 URL.createdrivername 参数的值作为 class attribute/constant。然后我添加我的 class 方法。

from sqlalchemy.engine import URL

class MyURL(URL):
    _DRIVERNAME = "mssql+pyodbc"

    @classmethod
    def from_hostname(cls, host, port, database):
        parts = {
            "drivername": MyURL._DRIVERNAME,
            "host": host,
            "port": port,
            "database": database,
            "query": {"driver": "ODBC Driver 17 or SQL Server"}
        }
        return super().create(**parts)

    @classmethod
    def from_dsn(cls, dsn):
        parts = {
            "drivername": MyURL._DRIVERNAME,
            "host": dsn
        }
        return super().create(**parts)

    @classmethod
    def from_connection_string(cls, connection_string):
        parts = {
            "drivername": MyURL._DRIVERNAME,
            "query": {"odbc_connect": connection_string}
        }
        return super().create(**parts)

用法:

MyURL.from_hostname('host', 1234, 'db')
MyURL.from_dsn('my-dsn')
MyURL.from_connection_string('Server=MyServer;Database=MyDatabase')

MyURL 当然会继承其父级的所有方法,包括 MyURL.create,它允许实例化各种 URL(包括非 SQL -Server ones),或 MyURL.set 允许修改 URL,包括 drivername 部分。这违背了 MyURL class 的意图,它的存在专门是为了提供一些方便的方法来仅通过 pyodbc 为 SQL 服务器创建 URLs。此外,由于现在所有这些父方法都由我的模块公开,我觉得有义务为用户记录它们,这会导致大量冗余文档(我想我可以参考 SQLAlchemy 的所有其他方法的文档和属性,或其他东西)。但最重要的是,所有这些都有些令人不快。

难道URLMyURL之间的父子关系在这里实际上不是正确的选择,即因为事实证明我们甚至都不感兴趣首先从 URL 继承,MyURL 在语义上不是 URL 的子代吗?

2 代表团

委托的实现几乎与继承相同,除了我们显然从 MyURL 中删除父 class 并将对 super 的调用替换为 class 名称.

from sqlalchemy.engine import URL

class MyURL:
    _DRIVERNAME = "mssql+pyodbc"

    @classmethod
    def from_hostname(cls, host, port, database):
        parts = {
            "drivername": MyURL._DRIVERNAME,
            "host": host,
            "port": port,
            "database": database,
            "query": {"driver": "ODBC Driver 17 or SQL Server"}
        }
        return URL.create(**parts)

    @classmethod
    def from_dsn(cls, dsn):
        parts = {
            "drivername": MyURL._DRIVERNAME,
            "host": dsn
        }
        return URL.create(**parts)

    @classmethod
    def from_connection_string(cls, connection_string):
        parts = {
            "drivername": MyURL._DRIVERNAME,
            "query": {"odbc_connect": connection_string}
        }
        return URL.create(**parts)

用法:

MyURL.from_hostname('host', 1234, 'db')
MyURL.from_dsn('my-dsn')
MyURL.from_connection_string('Server=MyServer;Database=MyDatabase')

这种方法让 MyURL 没有了 URL 的所有包袱,而且它并不意味着父子关系。但也不一定感觉对。

创建一个除了封装一些工厂方法之外什么都不做的 class 是不是太过分了?或者这可能是一种反模式,因为我们创建了一个 class MyURL 即使 MyURL 类型的实例没有太多用处(毕竟,我们只是想创建URL)?

的实例

3 个模块级工厂函数

这是一个与 SQLAlchemy 自己的 make_url 工厂函数(本质上是 URL.create 的包装器)相似的模式。我可以想到两种实现方式。

3.A 多个工厂函数

这个的实现非常简单。它又几乎与继承和委托相同,当然函数和属性没有包含在 class.

中。
from sqlalchemy import URL

_DRIVERNAME = "mssql+pyodbc"

def url_from_hostname(host, port, database):
    parts = {
        "drivername": _DRIVERNAME,
        "host": host,
        "port": port,
        "database": database,
        "query": {"driver": "ODBC Driver 17 or SQL Server"}
    }
    return URL.create(**parts)

def url_from_dsn(dsn):
    parts = {
        "drivername": _DRIVERNAME,
        "host": dsn
    }
    return URL.create(**parts)

def url_from_connection_string(connection_string):
    parts = {
        "drivername": _DRIVERNAME,
        "query": {"odbc_connect": connection_string}
    }
    return URL.create(**parts)

用法:

url_from_hostname('host', 1234, 'db')
url_from_dsn('my-dsn')
url_from_connection_string('Server=MyServer;Database=MyDatabase')

这是否会创建一个有点“混乱”的模块 API?然而,创建一个具有独立功能的模块 API 又是一种反模式吗?难道不应该有一些东西“连接”或“封装”那些明显相关的功能(例如class ...)吗?

3.B 单一工厂函数

试图通过单个函数封装所有创建 URL 的不同方法意味着某些参数是相互排斥的(hostportdatabasedsn 对比 connection_string)。这使得实施更加复杂。尽管进行了所有文档工作,用户几乎肯定会犯错误,因此人们可能希望验证提供的函数参数并在参数组合没有任何意义时引发异常。正如 here and here 所建议的那样,装饰器似乎是一种优雅的方式。当然,url 函数中的 if-elif 逻辑也可以扩展来完成所有这些,所以这实际上只是一个(可能不是最好的)可能的实现。

from functools import wraps
from sqlalchemy import URL

_DRIVERNAME = "mssql+pyodbc"

class MutuallyExclusiveError(Exception):
    pass

def mutually_exclusive(*args, **kwargs):
    excl_args = args
    def inner(f):
        @wraps(f)
        def wrapper(*args, **kwargs):
            counter = 0
            for ea in excl_args:
                if any(key in kwargs for key in ea):
                    counter += 1
            if counter > 1:
                raise MutuallyExclusiveError
            return f(*args, **kwargs)
        return wrapper
    return inner

@mutually_exclusive(
    ["host", "port", "database"],
    ["dsn"],
    ["connection_string"]
)
def url(host=None, port=None, database=None, dsn=None, connection_string=None):
    parts = {
        "drivername": _DRIVERNAME,
        "host": host or dsn,
        "port": port,
        "database": database
    }
    if host:
        parts["query"] = {"driver": "ODBC Driver 17 or SQL Server"}
    elif connection_string:
        parts["query"] = {"odbc_connect": connection_string}
    return URL.create(**parts)

用法:

url(host='host', port=1234, database='db')
url(dsn='my-dsn')
url(connection_string='Server=MyServer;Database=MyDatabase')

但是,如果用户传递位置参数而不是关键字参数,他们将完全绕过我们的验证,所以这是一个问题。此外,以有效的方式使用位置参数对于 DSN 和连接字符串来说甚至是不可能的,除非有人做了一些奇怪的事情,比如 url(None, None, None, 'my-dsn')。一种解决方案是通过将函数定义更改为 def url(*, host=None, ...): 来完全禁用位置参数,从而基本上丢弃位置参数。以上所有感觉也不太对。

如果函数不接受位置参数,这是不好的做法吗?验证输入的整个概念是不是有点“非 Python 式”,或者这仅仅是指类型检查之类的东西?这通常只是试图将太多的东西强加到一个函数中吗?

如果对上述全部或部分内容有任何想法(特别是在 斜体 中提出的问题),我们将不胜感激。

谢谢!

我会尽力回答我自己的问题。让我先看看通过 classes.

实现工厂模式

评论:1 继承

subclassingsubtyping 这两个术语在继承的上下文中经常被提及。前者通过实现重用(实现继承)暗示了 句法 关系,而后者暗示了 语义 "is-a" 关系(接口继承) .这两个概念在 Python 中经常被混为一谈,但是当我问 MyURL 对象是否是 URL 对象时,我指的是两者之间的语义关系。

当然,当我在上面的代码示例中使用 subclass URL 时,我确实创建了满足 Liskov Substitution Principle (LSP): I added a few methods (i.e. I specialized URL), but I can still pass MyURL instances to SQLAlchemy's create_engine 函数的 URL 的子类型,但什么也没有休息。那是因为 MyURL 实现了它的(通用的)superclass.

的完整接口

不过,我真正想要实现的是 MyURL 不仅要添加那几个方法和属性,而且还只拥有(或公开)其 superclass,试图禁用创建与 SQL 服务器不兼容的 URL 字符串的方法。其他人询问是否删除 subclasses 中的 superclass 方法(例如参见 [​​=36=]),但这样做会违反 LSP 以及两个 classes.

所以我想通过 subclassing 继承实际上不是我应该在这里做的。

评论:2 代表团

委托是实现重用的另一个示例,其中共享的不是 class“蓝图”,而是 class 的实例。因此,它更像是一个 "has-a" relationship. Specifically, in my code example I'm doing an implicit delegation, since I'm not passing URL or an instance thereof as a parameter to MyURL's methods. URL.create is a class method, so I can access it directly. In fact, since SQLALchemy's URLs are themselves a subclass of (immutable) tuples 在实例化它们之后我什至无法创建我的专用版本。

我对 MyURL 的实例有什么意义的一些困惑源于这样一个事实,即我仍然非常关注“是”关系。意识到情况并非如此,可以更清楚 MyURL 实际上是一个工厂 class 来创建 URL。我可以将其重命名为 MyURLFactory 以使区别更清楚。

我什至可以删除 @classmethod 装饰器。要使用 MyURL 我必须在使用前实例化它(尽管我不确定这样做有什么好处):

my_url_factory = MyURLFactory()
my_url_factory.from_hostname('host', 1234, 'db')

从这个角度来看,我认为这可能是解决我的问题的好方法。但是,让我们也重新审视模块级工厂功能。

评论:3.A 多个工厂函数

我对这个解决方案的一个问题是工厂功能都非常密切相关并且做几乎相同的事情。有可能出现代码重复。当然,我可以通过将共享代码移动到私有函数中来避免这种情况:

_DRIVERNAME = "mssql+pyodbc"

def _make_parts_dict(*args, **kwargs):
    return dict(kwargs, drivername=_DRIVERNAME)

def url_from_hostname(host, port, database):
    parts = _make_parts_dict(
        host=host,
        port=port,
        database=database,
        query={"driver": "ODBC Driver 17 or SQL Server"}
    )
    return URL.create(**parts)

def url_from_dsn(dsn):
    parts = _make_parts_dict(host=dsn)
    return URL.create(**parts)

def url_from_connection_string(connection_string):
    parts = _make_parts_dict(query={"odbc_connect": connection_string})
    return URL.create(**parts)

结果 URLs 将是相同的。尽管如此,这仍然给我留下了“混乱”的模块API——但是话又说回来,实现2会让我留下同样的“混乱”classAPI ...

我还可以通过添加 _make_parts_dict class 方法来组合 23.AMyURLFactory class.

评论:3.B单一工厂函数

我对这个实现没有太多评论。这可能是可行的,但我认为 2 或(不太受欢迎)3.A 将是 much 更易于实施和维护。考虑到我只是想创建几个 URL,处理不同的互斥关键字参数的复杂性似乎并不合理。另外,位置参数缺乏适当的支持也让我很困扰。


1 一个潜在的 hack 是在所有继承方法中保留对 drivername 参数的所有引用,但更改所有实现以简单地忽略它或从 class 属性中提取值。