围绕 JSON 数据包装一个 python 类,哪个更好?
Posted
技术标签:
【中文标题】围绕 JSON 数据包装一个 python 类,哪个更好?【英文标题】:Wrapping a python class around JSON data, which is better? 【发布时间】:2016-11-22 16:32:28 【问题描述】:序言:我正在针对提供 JSON 的服务编写一个 python API。 这些文件以 JSON 格式存储在磁盘上以缓存值。 API 应该对 JSON 数据进行分类访问,因此 IDE 和用户可以在运行前了解对象中存在哪些(只读)属性,同时还提供一些便利功能。
问题:我有两种可能的实现方式,我想知道哪个更好或“pythonic”。虽然我两者都喜欢,但如果您提出更好的解决方案,我愿意提供建议。
第一个解决方案:定义和继承 JSONWrapper 虽然不错,但它非常冗长且重复。
class JsonDataWrapper:
def __init__(self, json_data):
self._data = json_data
def get(self, name):
return self._data[name]
class Course(JsonDataWrapper):
def __init__(self, data):
super().__init__(data)
self._users = # class omitted
self._groups = # class omitted
self._assignments =
@property
def id(self): return self.get('id')
@property
def name(self): return self.get('full_name')
@property
def short_name(self): return self.get('short_name')
@property
def users(self): return self._users
@users.setter
def users(self, data):
users = [User(u) for u in data]
for user in users:
self.users[user.id] = user
# self.groups = user # this does not make much sense without the rest of the code (It works, but that decision will be revised :D)
第二种解决方案:使用 lambda 获得更短的语法。虽然工作且简短,但它看起来不太正确(请参阅下面的编辑 1。)
def json(name): return property(lambda self: self.get(name))
class Group(JsonDataWrapper):
def __init__(self, data):
super().__init__(data)
self.group_members = [] # elements are of type(User). edit1, was self.members = []
id = json('id')
description = json('description')
name = json('name')
description_format = json('description_format')
(命名这个函数'json'没有问题,因为我不在那里导入json。)
我想到了第三种可能的解决方案,但我无法完全理解:覆盖内置属性,因此我可以定义一个装饰器来包装返回的字段名称以进行查找:
@json # just like a property fget
def short_name(self): return 'short_name'
这可能会更短一些,不知道这是否会使代码更好。
不合格的解决方案(恕我直言):
JSONDe,Encoder:扼杀所有灵活性,不提供只读属性方法__get,setattr__
:使得在运行前无法确定属性。虽然它会将 self.get('id')
缩短为 self['id']
,但如果属性不在基础 json 数据中,它也会使事情变得更加复杂。
感谢您的阅读!
编辑 1:2016-07-20T08:26Z
进一步澄清 (@SuperSaiyan) 为什么我不太喜欢第二种解决方案:
我觉得 lambda 函数与其他类语义完全脱节(这也是它更短的原因:D)。我想我可以通过在代码中正确记录决定来帮助自己更喜欢它。对于理解@property
含义的每个人来说,第一个解决方案都很容易理解,无需任何额外解释。
@SuperSaiyan 的第二条评论:你的问题是,为什么我把Group.members
作为属性放在那里?列表存储了 type(User) 实体,可能不是你想的那样,我改了例子。
@jwodder:下次我会使用 Code Review,不知道那是一回事。
(另外:我真的认为Group.members
让你们有些失望,我编辑了代码以使其更加明显:组成员是将添加到列表中的用户。
The complete code is on github,虽然没有记录,但对某些人来说可能很有趣。请记住:这都是 WIP :D)
【问题讨论】:
好问题!您能否详细说明(通过编辑问题)为什么您觉得第二种解决方案看起来不正确?我个人喜欢它(并且有一些库/框架实现了第二种解决方案。 .. 另外,您希望在类级别定义cls.members
。 self
在 property
的上下文中没有意义
这个问题似乎更适合Code Review - 请参阅它的comparative-review
标签。
使用 class JsonDataWrapper(object) 获取新的样式类,至少在 Python 2.x 上
@SuperSaiyan 我发布了一个编辑,不确定是否已经通知了你们所有人,但我认为这个评论会?不确定。
【参考方案1】:
(注意:这得到了更新,我现在正在使用具有运行时类型强制的数据类。见底部:3)
所以,已经一年了,我要回答我自己的问题。我不太喜欢自己回答,但是:这会将线程标记为已解决,这本身可能会对其他人有所帮助。
另一方面,我想记录并说明为什么我选择我的解决方案而不是建议的答案。不是为了证明我是对的,而是为了突出不同的权衡。
我刚刚意识到,这很长,所以:
tl;博士
collections.abc
包含强大的抽象,如果你可以访问它,你应该使用它们(cpython >= 3.3)。
@property
很好用,可以轻松添加文档并提供只读访问权限。
嵌套类看起来很奇怪,但可以很好地复制深度嵌套 JSON 的结构。
建议的解决方案
python 元类
首先:我喜欢这个概念。 我已经考虑了许多应用程序,它们被证明是有用的,尤其是在以下情况下:
-
编写一个可插入的 API,其中元类强制正确使用派生类及其实现细节
拥有从元类派生的类的全自动注册表。
另一方面,python 的元类逻辑让我难以理解(我至少花了三天时间才弄明白)。虽然原则上很简单,但魔鬼在细节中。 所以,我决定不这样做,只是因为我可能会在不久的将来放弃这个项目,而其他人应该能够轻松地从我离开的地方继续。
命名元组
collections.namedtuple
非常高效且简洁,足以将我的解决方案简化为几行,而不是当前的 800 多行。我的 IDE 也将能够内省生成的类的可能成员。
缺点:namedtuple 的简洁性为 API 返回值的非常必要的文档留下了更少的空间。因此,使用不那么疯狂的 API,您可能会侥幸逃脱。 将类对象嵌套到命名元组中也感觉很奇怪,但这只是个人喜好。
我做了什么
所以最后我还是选择了我的第一个原创解决方案,添加了一些小细节,如果你觉得细节有趣,你可以看看source on github。
collections.abc
当我开始这个项目时,我的 Python 知识几乎一无所有,所以我使用了我对 Python 的了解(“一切都是字典”)并编写了类似的代码。例如:像字典一样工作的类,但在下面有一个文件结构(在pathlib
之前)。
在查看 python 的代码时,我注意到它们是如何通过 abstract base classes 实现和强制执行容器“特征”的,这听起来比在 python 中实际要复杂得多。
基础知识
以下确实非常基本,但我们将从那里开始。
from collections import Mapping, Sequence, Sized
class JsonWrapper(Sized):
def __len__(self):
return len(self._data)
def __init__(self, json):
self._data = json
@property
def raw(self): return self._data
我能想到的最基本的类,它只会让你在容器上调用len
。如果您真的想打扰底层字典,也可以通过raw
获得只读访问权限。
那么为什么我要从 Sized
继承而不是从头开始和 def __len__
那样呢?
-
不覆盖
__len__
不会被python 解释器接受。我忘记了确切的时间,但是当您导入包含该类的模块时,您就不会在运行时被搞砸了。
虽然Sized
没有提供任何mixin 方法,但接下来的两个抽象确实提供了它们。我会在那里解释。
有了这个,我们在 JSON 列表和字典中只得到了两个基本案例。
列表
因此,对于我不得不担心的 API,我们并不总是确定我们得到了什么;所以我想要一种在初始化包装类时检查是否有列表的方法,主要是在更复杂的过程中提前中止而不是“对象没有成员”。
从序列派生将强制覆盖__getitem__
和__len__
(已在JsonWrapper
中实现)。
class JsonListWrapper(JsonWrapper, Sequence):
def __init__(self, json_list):
if type(json_list) is not list:
raise TypeError('received type , expected list'.format(type(json_list)))
super().__init__(json_list)
def __getitem__(self, index):
return self._data[index]
def __iter__(self):
raise NotImplementedError('__iter__')
def get(self, index):
try:
return self._data[index]
except Exception as e:
print(index)
raise e
所以您可能已经注意到,我选择不实现__iter__
。
我想要一个产生类型化对象的迭代器,所以我的 IDE 能够自动完成。举例说明:
class CourseListResponse(JsonListWrapper):
def __iter__(self):
for course in self._data:
yield self.Course(course)
class Course(JsonDictWrapper):
pass # for now
实现Sequence
的抽象方法,mixin方法__contains__
、__reversed__
、index
和count
是给你的,所以你不必担心可能的副作用。
字典
为了完成整理JSON的基本类型,这里是从Mapping
派生的类:
class JsonDictWrapper(JsonWrapper, Mapping):
def __init__(self, json_dict):
super().__init__(json_dict)
if type(self._data) is not dict:
raise TypeError('received type , expected dict'.format(type(json_dict)))
def __iter__(self):
return iter(self._data)
def __getitem__(self, key):
return self._data[key]
__marker = object()
def get(self, key, default=__marker):
try:
return self._data[key]
except KeyError:
if default is self.__marker:
raise
else:
return default
映射仅强制执行 __iter__
、__getitem__
和 __len__
。
为避免混淆:还有MutableMapping
将强制写入方法。但这既不需要也不需要。
排除了抽象方法后,python 提供了基于它们的 mixins __contains__
、keys
、items
、values
、get
、__eq__
和 __ne__
。
我不知道为什么我选择覆盖 get
mixin,我可能会在它回复给我时更新帖子。
__marker
用作检测是否未设置 default
关键字的后备。如果有人决定致电get(*args, default=None)
,否则您将无法检测到。
所以继续前面的例子:
class CourseListResponse(JsonListWrapper):
# [...]
class Course(JsonDictWrapper):
# Jn is just a class that contains the keys for JSON, so I only mistype once.
@property
def id(self): return self[Jn.id]
@property
def short_name(self): return self[Jn.short_name]
@property
def full_name(self): return self[Jn.full_name]
@property
def enrolled_user_count(self): return self[Jn.enrolled_user_count]
# [...] you get the idea
属性提供对成员的只读访问权限,并且可以像函数定义一样记录在案。 虽然很冗长,但对于基本的访问器,您可以轻松地在编辑器中定义模板,因此编写起来不那么乏味。
属性还允许从幻数和可选的 JSON 返回值中抽象出来,以提供默认值,而不是在任何地方保护 KeyError
:
@property
def isdir(self): return 1 == self[Jn.is_dir]
@property
def time_created(self): return self.get(Jn.time_created, 0)
@property
def file_size(self): return self.get(Jn.file_size, -1)
@property
def author(self): return self.get(Jn.author, "")
@property
def license(self): return self.get(Jn.license, "")
类嵌套
在其他人中嵌套类似乎有点奇怪。 我选择这样做,因为 API 对具有不同属性的各种对象使用相同的名称,具体取决于您调用的远程函数。
另一个好处:新人可以很容易地理解返回的 JSON 的结构。
end of the file 包含嵌套类的各种别名,以便于从模块外部访问。
添加逻辑
现在我们已经封装了大部分返回值,我希望有更多与数据相关的逻辑,以增加一些便利。 似乎还需要将一些数据合并到一个更全面的树中,其中包含通过多个 API 调用收集的所有数据:
-
获取所有“任务”。每个作业都包含许多提交,因此:
for(assignment in assignments) 获取所有“提交”
将提交的内容合并到各自的作业中。
现在获取提交的成绩,等等...
我选择单独实现它们,所以我只是从“哑”访问器(full source)继承:
所以在this class
class Assignment(MoodleAssignment):
def __init__(self, data, course=None):
super().__init__(data)
self.course = course
self._submissions = # accessed via submission.id
self._grades = # are accessed via user_id
这些属性进行合并
@property
def submissions(self): return self._submissions
@submissions.setter
def submissions(self, data):
if data is None:
self.submissions =
return
for submission in data:
sub = Submission(submission, assignment=self)
if sub.has_content:
self.submissions[sub.id] = sub
@property
def grades(self):
return self._grades
@grades.setter
def grades(self, data):
if data is None:
self.grades =
return
grades = [Grade(g) for g in data]
for g in grades:
self.grades[g.user_id] = g
这些实现了一些可以从数据中抽象出来的逻辑。
@property
def is_due(self):
now = datetime.now()
return now > self.due_date
@property
def due_date(self): return datetime.fromtimestamp(super().due_date)
虽然 setter 掩盖了争论,但它们的编写和使用都很好:所以这只是一个权衡。
警告:逻辑实现并不是我想要的,它不应该存在很多相互依赖。它源于我对 python 的了解不够,无法正确抽象并完成工作,因此我可以在乏味的情况下完成实际工作。 现在我知道了,可以做些什么:我看了一些意大利面,嗯……你知道那种感觉。
结论
将 JSON 封装到类中证明对我和项目结构非常有用,我对此非常满意。 该项目的其余部分很好并且可以工作,尽管有些部分很糟糕:D 谢谢大家的反馈,我会在附近回答问题和评论。
更新:2019-05-02
正如@RickTeachey 在 cmets 中指出的那样,pythons dataclasses (DCs) 也可以在这里使用。
而且我忘了在这里更新,因为我前段时间已经did that 并用pythons typing
功能扩展了它:D
原因:我越来越厌倦手动检查我从中抽象出来的 API 的文档是否正确,或者我的实现是否错误。
使用dataclasses.fields
,我可以检查响应是否符合我的架构;现在我能够更快地找到外部 API 中的更改,因为在实例化的运行时会检查假设。
一旦__init__
成功完成,DC 会提供一个__post_init__(self)
挂钩来进行一些后期处理。 Python 的类型提示仅用于为静态检查器提供提示,我构建了一个小系统,在初始化后阶段对数据类强制执行类型。
这里是 BaseDC,所有其他 DC 都从它继承(缩写)
import dataclasses as dc
@dataclass
class BaseDC:
def _typecheck(self):
for field in dc.fields(self):
expected = field.type
f = getattr(self, field.name)
actual = type(f)
if expected is list or expected is dict:
log.warning(f'untyped list or dict in self.__class__.__qualname__: field.name')
if expected is actual:
continue
if is_generic(expected):
return self._typecheck_generic(expected, actual)
# Subscripted generics cannot be used with class and instance checks
if issubclass(actual, expected):
continue
print(f'mismatch field.name: should be: expected, but is actual')
print(f'offending value: f')
def __post_init__(self):
for field in dc.fields(self):
castfunc = field.metadata.get('castfunc', False)
if castfunc:
attr = getattr(self, field.name)
new = castfunc(attr)
setattr(self, field.name, new)
if DEBUG:
self._typecheck()
Fields 有一个附加属性,允许存储任意信息,我用它来存储转换响应值的函数;但稍后会详细介绍。
一个基本的响应包装器如下所示:
@dataclass
class DCcore_enrol_get_users_courses(BaseDC):
id: int # id of course
shortname: str # short name of course
fullname: str # long name of course
enrolledusercount: int # Number of enrolled users in this course
idnumber: str # id number of course
visible: int # 1 means visible, 0 means hidden course
summary: Optional[str] = None # summary
summaryformat: Optional[int] = None # summary format (1 = html, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN)
format: Optional[str] = None # course format: weeks, topics, social, site
showgrades: Optional[int] = None # true if grades are shown, otherwise false
lang: Optional[str] = None # forced course language
enablecompletion: Optional[int] = None # true if completion is enabled, otherwise false
category: Optional[int] = None # course category id
progress: Optional[float] = None # Progress percentage
startdate: Optional[int] = None # Timestamp when the course start
enddate: Optional[int] = None # Timestamp when the course end
def __str__(self): return f'self.fullname[0:39]:40 id:self.id:5d short: self.shortname'
core_enrol_get_users_courses = destructuring_list_cast(DCcore_enrol_get_users_courses)
只是列表的响应一开始就给我带来了麻烦,因为我无法使用普通的List[DCcore_enrol_get_users_courses]
对它们进行类型检查。
这就是destructuring_list_cast
为我解决这个问题的地方,这涉及到更多。我们正在进入高阶函数领域:
T = typing.TypeVar('T')
def destructuring_list_cast(cls: typing.Callable[[dict], T]) -> typing.Callable[[list], T]:
def cast(data: list) -> List[T]:
if data is None:
return []
if not isinstance(data, list):
raise SystemExit(f'listcast expects a list, you sent: type(data)')
try:
return [cls(**entry) for entry in data]
except TypeError as err:
# here is more code that explains errors
raise SystemExit(f'listcast for class cls failed:\nerr')
return cast
这需要一个接受 dict 并返回类型为 T
的类实例的 Callable,这是您对构造函数或工厂的期望。
它返回一个接受列表的 Callable,这里是cast
。
当您调用core_enrol_get_users_courses(response.json())
时,return [cls(**entry) for entry in data]
通过构建数据类列表来完成所有工作。
(抛出 SystemExit
不好,但这是在上层处理的,所以它对我有用;我希望它快速失败。)
另一个用例是定义嵌套字段,然后响应是深度嵌套的:还记得BaseDC
中的field.metadata.get('castfunc', False)
吗?这就是这两个快捷方式的用武之地:
# destructured_cast_field
def dcf(cls):
return dc.field(metadata='castfunc': destructuring_list_cast(cls))
def optional_dcf(cls):
return dc.field(metadata='castfunc': destructuring_list_cast(cls), default_factory=list)
这些用于像这样的嵌套情况(见底部):
@dataclass
class core_files_get_files(BaseDC):
@dataclass
class parent(BaseDC):
contextid: int
# abbrev ...
@dataclass
class file(BaseDC):
contextid: int
component: str
timecreated: Optional[int] = None # Time created
# abbrev ...
parents: List[parent] = dcf(parent)
files: Optional[List[file]] = optional_dcf(file)
【讨论】:
一个赞成票,因为这显然是一种学习劳动,我为你鼓掌。一个更正:在您尝试实例化类之前,不会发生 ABC 错误(因为不提供抽象方法)。另外,我建议查看the newtyping.NamedTuple
,其中provides a much nicer API 用于命名元组(包括编写允许文档的代码的能力)。
对于这个或未来的项目,我也会仔细查看dataclasses
。
另一个 nit 选择——这对于您的列表类型检查来说更加 Pythonic:if not isinstance(json_list, list):
。既然您已经在使用 ABC,我非常建议您这样做:if not isinstance(json_list, Sequence):
,这样更灵活。
@RickTeachey 提醒我为我的解决方案添加更新,我前段时间用数据类重写了它:D
好极了。 dclasses 规则。【参考方案2】:
您是否考虑过使用元类?
class JsonDataWrapper(object):
def __init__(self, json_data):
self._data = json_data
def get(self, name):
return self._data[name]
class JsonDataWrapperMeta(type):
def __init__(self, name, base, dict):
for mbr in self.members:
prop = property(lambda self: self.get(mbr))
setattr(self, mbr, prop)
# You can use the metaclass inside a class block
class Group(JsonDataWrapper):
__metaclass__ = JsonDataWrapperMeta
members = ['id', 'description', 'name', 'description_format']
# Or more programmatically
def jsonDataFactory(name, members):
d = "members":members
return JsonDataWrapperMeta(name, (JsonDataWrapper,), d)
Course = jsonDataFactory("Course", ["id", "name", "short_name"])
【讨论】:
我认为 IDE 对发现属性的支持将受到这种方法的限制。 我认为这个实现中可能存在与闭包相关的错误 @BiRico 是的,我做到了:虽然这对于快速脚本来说会很好,但我认为这不会使代码保持可读性。我也试过了,helm 和 pycharm 都猜不到类的属性(除了members
),这违背了目的。 (你可能也得到了 Group.members 的错误想法,我试图在帖子中澄清这一点。)【参考方案3】:
在开发这样的 API 时,其中所有成员都是只读的(这意味着您不希望它们被覆盖,但可能仍然具有可变数据结构作为成员),我经常考虑使用 collections.namedtuple
一个硬-击败方法,除非我有很好的理由不这样做。它速度很快,并且只需要最少的代码。
from collections import namedtuple as nt
Group = nt('Group', 'id name shortname users')
g = Group(**json)
简单。
如果json
中的数据多于对象中将使用的数据,只需将其过滤掉:
g = Group(**k:v for k,v in json.items() if k in Group._fields)
如果您想要缺失数据的默认值,您也可以这样做:
Group.__new__.__defaults__ = (0, 'DefaultName', 'DefN', None)
# now this works:
g = Group()
# and now this will still work even if some keys are missing;
g = Group(**k:v for k,v in json.items() if k in Group._fields)
使用上述设置默认值技术的一个陷阱:不要将成员之一的默认值设置为任何可变对象,例如list
,因为它将是所有实例中相同的可变共享对象:
# don't do this:
Group.__new__.__defaults__(0, 'DefaultName', 'DefN', [])
g1 = Group()
g2 = Group()
g1.users.append(user1)
g2.users # output: [user1] <-- whoops!
相反,将其全部封装在一个不错的工厂中,为需要它们的成员实例化一个新的 list
(或 dict
或任何用户定义的数据结构):
# jsonfactory.py
new_list = Object()
def JsonClassFactory(name, *args, defaults=None):
'''Produces a new namedtuple class. Any members
intended to default to a blank list should be set to
the new_list object.
'''
cls = nt(name, *args)
if defaults is not None:
cls.__new__.__defaults__ = tuple(([] if d is new_list else d) for d in defaults)
现在给定一些 json 对象来定义你想要呈现的字段:
from jsonfactory import JsonClassFactory, new_list
MyJsonClass = JsonClassFactory(MyJsonClass, *json_definition,
defaults=(0, 'DefaultName', 'DefN', new_list))
然后像以前一样:
obj = MyJsonClass(**json)
或者,如果有额外的数据:
obj = MyJsonClass(**k:v for k,v in json.items() if k in MyJsonClass._fields)
如果您希望默认容器不是列表,这很简单 - 只需将 new_list
哨兵替换为您想要的任何哨兵。如果需要,您可以同时拥有多个哨兵。
如果您仍然需要额外的功能,您可以随时扩展您的MyJsonClass
:
class ExtJsonClass(MyJsonClass):
__slots__ = () # optional- needed if you want the low memory benefits of namedtuple
def __new__(cls, *args, **kwargs):
self = super().__new__(cls, *args, **k:v for k,v in kwargs.items()
if k in cls._fields)
return self
def add_user(self, user):
self.users.append(user)
上面的__new__
方法可以很好地解决丢失数据的问题。所以现在你总是可以这样做:
obj = ExtJsonClass(**json)
简单。
【讨论】:
【参考方案4】:我自己是 python 的新手,如果我听起来很天真,请原谅。一种解决方案可能是使用__dict__
,如下文所述:
https://www.safaribooksonline.com/library/view/python-cookbook-3rd/9781449357337/ch06s02.html
当然,如果类中的对象低于其他类并且需要序列化或反序列化,则此解决方案会产生问题。我很想听听这里的专家对此解决方案和不同限制的意见。
关于 jsonpickle 的任何反馈。
更新:
我刚刚看到您对序列化的反对意见以及您不喜欢它,因为一切都是运行时的。明白了。非常感谢。
下面是我为解决这个问题而编写的代码。有点牵强,但效果很好,我不必每次都添加 get/set !!!
import json
class JSONObject:
exp_props = "id": "", "title": "Default"
def __init__(self, d):
self.__dict__ = d
for key in [x for x in JSONObject.exp_props if x not in self.__dict__]:
setattr(self, key, JSONObject.exp_props[key])
@staticmethod
def fromJSON(s):
return json.loads(s, object_hook=JSONObject)
def toJSON(self):
return json.dumps(self.__dict__, indent=4)
s = '"name": "ACME", "shares": 50, "price": 490.1'
anObj = JSONObject.fromJSON(s)
print("Name - ".format(anObj.name))
print("Shares - ".format(anObj.shares))
print("Price - ".format(anObj.price))
print("Title - ".format(anObj.title))
sAfter = anObj.toJSON()
print("Type of dumps is ".format(type(sAfter)))
print(sAfter)
结果如下
Name - ACME
Shares - 50
Price - 490.1
Title - Default
Type of dumps is <type 'str'>
"price": 490.1,
"title": "Default",
"name": "ACME",
"shares": 50,
"id": ""
【讨论】:
以上是关于围绕 JSON 数据包装一个 python 类,哪个更好?的主要内容,如果未能解决你的问题,请参考以下文章
mongodb 3.0.3 export json - 围绕数字创建 Number() 包装器
使用 SWIG 围绕 C++ 的 Python 包装器。参数类型无法识别