我正在开发一个模块,该模块允许用户创建SQLAlchemy的URL
对象的实例,专门用于通过pyodbc连接到MS SQL Server。模块需要公开一个方便的API,通过指定主机名、端口和数据库或DSN,或通过传递原始ODBC连接字符串,可以在其中创建URL
。因此,这些URL
的字符串表示将如下所示,其中数据库和驱动程序已经指定,其余由用户决定:
"mssql+pyodbc://<username>:<password>@<host>:<port>/<database>?driver=<odbc-driver>"
"mssql+pyodbc://<username>:<password>@<dsn>"
"mssql+pyodbc://<username>:<password>@?odbc_connect=<connection-string>"
现在,这似乎是工厂模式的一个很好的用例,通过它,我为创建URL
的每种不同方式创建了一个单独的方法/函数(例如from_hostname
、from_dsn
、from_connection_string
)。但我能想到该模式的四种不同实现,我想知道该选择哪一种。
(附带说明:您将在下面注意到,我通过类工厂方法URL.create
实例化URL
s。这是因为SQLAlchemy开发人员希望阻止用户通过直接调用默认构造函数来实例化URL
s。此外,为了简单起见,我忽略了方法/函数应该接受的所有其他有用参数,例如身份验证。)
1继承
I子类URL
添加URL.create
的drivername
参数的值作为类属性/常量。然后我添加我的类方法。
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
当然会继承其父级的所有方法,包括允许实例化各种URL
(包括非SQL Server的)的MyURL.create
,或者允许修改URL
(包括drivername
部分)的MyURL.set
。这违背了MyURL
类的意图,该类专门用于提供一些方便的方法,仅通过pyodbc为SQL Server创建URL
。此外,由于现在所有这些父方法都由我的模块公开,我觉得有义务为用户记录它们,这导致了大量冗余的文档(我想我可以参考SQLAlchemy的文档来了解所有其他方法和属性,或者其他什么)。但最重要的是,所有这些都有些不可取。
难道URL
和MyURL
之间的父子关系实际上不是正确的选择吗?也就是说,既然事实证明我们一开始甚至对继承URL
都不感兴趣,那么MyURL
在语义上不是URL
的子代吗
2代表团
委托的实现几乎与继承相同,只是我们显然从MyURL
中删除了父类,并用类名替换了对super
的调用。
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
的所有包袱,也不意味着父子关系。但这种感觉也不一定正确。
创建一个除了封装几个工厂方法之外什么都不做的类是不是太过分了?或者这可能是一种反模式,因为我们创建了一个类MyURL
,尽管类型为MyURL
的实例没有太多用处(毕竟,我们只想创建URL
的实例)
3模块级工厂功能
这是一个与SQLAlchemy自己的make_url
工厂函数(本质上是URL.create
的包装器)类似的模式。我可以想出两种方法来实现它。
3.A多工厂功能
这个的实现非常简单。它同样与继承和委派几乎相同,当然除了函数和属性没有封装在类中。
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,所有函数都做同样的事情,这又是一种反模式吗?难道不应该有什么";连接";或";封装";那些明显相关的函数(比如类…)
3.B单工厂功能
试图通过单个函数封装所有不同的创建URL
的方法意味着某些参数是互斥的(host
、port
和database
与dsn
与connection_string
)。这使得实现更加复杂。尽管做了所有的文档工作,用户几乎肯定会犯错误,所以如果参数的组合没有任何意义,用户可能会想要验证提供的函数参数并引发异常。正如这里和这里所建议的那样,装饰师似乎是一种优雅的方式。当然,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, ...):
来完全禁用位置自变量,从而实质上丢弃位置自变量。以上所有这些也让人感觉不太对劲。
函数不接受位置参数是不是一种糟糕的做法?验证输入的整个概念在某种程度上不是"有效的"吗;非蟒蛇";,还是这仅仅指类型检查之类的事情?这通常只是试图将过多的内容强加给一个函数吗
对以上所有或部分内容的任何想法(特别是用斜体提出的问题)都将不胜感激。
谢谢!
我会尽力回答自己的问题。让我首先来看看通过类实现工厂模式。
评论:1继承
子类化和分型这两个术语经常在继承的上下文中被提及。前者通过实现重用(实现继承)暗示了语法关系,而后者暗示了语义";is-a";关系(接口继承)。在Python中,这两个概念经常被混为一谈,但当我问MyURL
对象是否是URL
对象时,我指的是两者之间的语义关系。
当然,当我在上面的代码示例中对URL
进行子类化时,我确实在创建一个满足Liskov替换原则(LSP)的URL
的子类型:我添加了一些方法(即我专门化了URL
),但我仍然可以将MyURL
实例传递给SQLAlchemy的create_engine
函数,并且没有任何中断。这是因为MyURL
实现了其(广义)超类的完整接口。
不过,我真正想实现的是,MyURL
不仅添加了这几个方法和属性,而且只拥有(或公开)其超类的方法的一个子集,试图禁用创建与SQL Server不兼容的URL字符串的方法。其他人询问了删除子类中的超类方法(例如,请参见此处和此处),但这样做将违反LSP以及";is-a";两个阶级之间的关系。
所以我认为通过子类化进行继承实际上不是我应该在这里做的。
评论:2代表团
委派是实现重用的另一个例子;蓝图";是共享的,但却是一个类的实例。因此,它更像是一个";has-a";关系具体来说,在我的代码示例中,我正在执行一个隐式委托,因为我没有将URL
或其实例作为参数传递给MyURL
的方法。URL.create
是一个类方法,所以我可以直接访问它。事实上,由于SQLALchemy的URL
本身就是(不可变的)元组的子类,所以在实例化它们之后,我甚至无法创建专门的版本。
我对MyURL
的例子有什么意义的一些困惑源于这样一个事实,即我仍然非常关注";is-a";关系意识到事实并非如此,MyURL
实际上是一个用于创建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)
得到的URL将是相同的。尽管如此,这仍然给我留下了";杂乱的";模块API-但是再一次,实现2将给我留下相同的";杂乱的";类API。。。
我还可以通过在MyURLFactory
类中添加_make_parts_dict
类方法来组合2和3.A。
评论:3.B单工厂功能
我对这个实现没有太多意见。这可能是可行的,但我认为2或(不太喜欢)3。实现和维护起来会简单得多。考虑到我只是想创建几个URL
s,处理不同的互斥关键字论点的复杂性似乎是不合理的。此外,缺乏对位置论点的适当支持也困扰着我。
1一个潜在的破解方法是在所有继承的方法中保留对drivername
参数的所有引用,但更改所有实现以简单地忽略它或从类属性中提取值。