用 python class 环绕 JSON 数据,哪个更好?

Wrapping a python class around JSON data, which is better?

序言:我正在针对提供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 来获得更短的语法。虽然工作和简短,但看起来不太正确(见下面的 edit1。)

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'

这可能会更短一些,不知道这是否会使代码更好。

不合格的解决方案(恕我直言):

感谢阅读!

编辑 1:2016-07-20T08:26Z

进一步澄清(@SuperSaiyan)为什么我不太喜欢第二种解决方案: 我觉得 lambda 函数与 类 语义的其余部分完全脱节(这也是它更短的原因:D)。我想我可以通过在代码中正确记录决定来帮助自己更喜欢它。第一种解决方案对于理解@property含义的每个人来说都很容易理解,无需任何额外解释。

关于@SuperSaiyan的第二条评论:你的问题是,为什么我把Group.members作为属性放在那里?该列表存储类型(用户)实体,可能不是您认为的那样,我更改了示例。

@jwodder:下次我会使用代码审查,不知道那是一回事。

(另外:我真的认为 Group.members 让你们中的一些人失望了,我编辑了代码以使其更明显:组成员是将被添加到列表中的用户。

The complete code is on github,虽然没有记录,但对某些人来说可能很有趣。请记住:这都是 WIP :D)

您是否考虑过使用元class?

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"])

我自己是 python 的新手,如果我听起来很天真,请原谅。解决方案之一是使用 __dict__,如下文所述:

https://www.safaribooksonline.com/library/view/python-cookbook-3rd/9781449357337/ch06s02.html

当然,如果 class 中的对象低于其他 class 并且需要序列化或反序列化,则此解决方案会产生问题。我很想听听这里的专家对此解决方案和不同限制的意见。

关于 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": ""
}

在开发像这样的 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)

简单。

(注意:这有更新,我现在使用数据classes 和 运行-时间类型强制执行。见底部:3)

所以,已经一年了,我要回答我自己的问题。我不太喜欢自己回答,但是:这会将线程标记为已解决,这本身可能会对其他人有所帮助。

另一方面,我想记录并说明为什么我选择我的解决方案而不是建议的答案。不是为了证明我是对的,而是为了强调不同的权衡。

我刚刚意识到,这太长了,所以:

tl;博士

collections.abc 包含强大的抽象,如果您有权访问它,您应该使用它们 (cpython >= 3.3)。 @property 很好用,可以轻松添加文档并提供只读访问权限。 嵌套 classes 看起来很奇怪,但复制深度嵌套 JSON 的结构就好了。

建议的解决方案

python 元classes

所以首先:我喜欢这个概念。 我考虑过许多​​应用程序,证明它们有用,尤其是在以下情况下:

  1. 编写可插拔 API,其中元classes 强制正确使用派生的 classes 及其实现细节
  2. 拥有 classes 的全自动注册表,从元 class.
  3. 派生 a

另一方面,python 的元class 逻辑让我难以理解(我至少花了三天时间才弄明白)。虽然原则上很简单,但细节决定成败。 所以,我决定反对,只是因为我可能会在不久的将来放弃这个项目,而其他人应该能够轻松地从我离开的地方接手。

命名元组

collections.namedtuple 非常高效和简洁,足以将我的解决方案简化为几行而不是当前的 800 多行。我的 IDE 也将能够内省生成的 class.

的可能成员

缺点:namedtuple 的简洁为 APIs returned 值的非常必要的文档留下了更少的空间。因此,如果 APIs 不那么疯狂,你可能会侥幸逃脱。 将 class 对象嵌套到 namedtuple 中也感觉很奇怪,但这只是个人喜好。

我的选择

所以最后,我选择坚持我的第一个解决方案,添加了一些小细节,如果你觉得细节有趣,你可以看看source on github

collections.abc

当我开始这个项目时,我的 python 知识仅次于 none,所以我用我所知道的 python ("everything is a dict") 写了代码像那样。例如:classes 像字典一样工作,但下面有一个文件结构(在 pathlib 之前)。

在查看 python 的代码时,我注意到他们如何通过 abstract base classes 实施和执行容器 "traits",这听起来比 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

我能想到的最基本的 class,这只会使您能够在容器上调用 len。如果你真的想打扰底层字典,你也可以通过 raw 获得只读访问权限。

那么,为什么我要从 Sized 继承,而不是像那样从头开始 def __len__

  1. 不覆盖 __len__ 将不会被 python 解释器接受。我忘记了确切的时间,但 AFAIR 是在您导入包含 class 的模块时,所以您不会在 运行 时间搞砸。
  2. 虽然 Sized 没有提供任何 mixin 方法,但接下来的两个抽象确实提供了它们。我会在那里解释。

有了它,我们在 JSON 列表和字典中只得到了两个基本案例。

列表

所以,API 我不得不担心,我们并不总是确定我们得到了什么;所以我想要一种方法来检查我是否在初始化包装器时得到了一个列表class,主要是为了在更复杂的过程中提前中止而不是"object has no member"。

从序列派生将强制覆盖 __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__indexcount是天赐给你的,所以你不用不必担心可能的副作用。

词典

为了完成基本类型以讨论 JSON,这里是从 Mapping 派生的 class:

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 提供了 mixin __contains__keysitemsvaluesget__eq____ne__ 基于它们。

我不确定为什么我选择覆盖 get mixin,我可能会在 post 返回给我时更新它。 __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 return 值中抽象出来,以提供默认值而不是到处保护 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, "")

class嵌套

把classes嵌套在别人身上似乎有点奇怪。 我选择这样做,因为 API 对具有不同属性的各种对象使用相同的名称,具体取决于您调用的远程函数。

另一个好处:新人可以很容易地理解returned JSON.

的结构

end of the file 包含嵌套 class 的各种别名,以便于从模块外部访问。

添加逻辑

现在我们已经封装了大部分 returned 值,我希望有更多与数据关联的逻辑,以增加一些便利。 似乎也有必要将一些数据合并到一个更全面的树中,该树包含通过几次 API 调用收集的所有数据:

  1. 获得全部"assignments"。每个作业都包含许多提交,因此:
  2. for(assignment in assignments) get all "submissions"
  3. 将提交合并到各自的作业中。
  4. 现在获取提交的评分,等等...

我选择单独实现它们,所以我只是继承自 "dumb" 访问器 (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 封装到 classes 证明对我和项目的结构非常有用,我对此非常满意。 项目的其余部分很好并且可以工作,尽管有些部分很糟糕:D 谢谢大家的反馈,我会随时提出问题和评论。

更新:2019-05-02

正如@RickTeachey 在评论中指出的那样,pythons dataclasses (DC) 也可以在这里使用。 而且我忘了在这里更新,因为我已经 did that 前段时间用 pythons typing 功能扩展了它 :D

原因:我越来越厌倦手动检查我从中提取的 API 的文档是否正确,或者我的实现是否有误。 使用 dataclasses.fields 我可以检查响应是否符合我的模式;现在我可以更快地找到外部 API 中的变化,因为假设是在 运行 实例化期间检查的。

DC 提供了一个 __post_init__(self) 钩子来做一些 post 处理,一旦 __init__ 成功完成。 Python 的类型提示仅用于为静态检查器提供提示,我构建了一个小系统,在 post 初始化阶段对数据 classes 强制执行类型。

这里是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:\n{err}')

    return cast

这需要一个接受字典的 Callable 和 return 类型 T 的 class 实例,这是您对构造函数或工厂的期望。 它 return 是一个将接受列表的 Callable,这里是 cast。 当您调用 core_enrol_get_users_courses(response.json()) 时,return [cls(**entry) for entry in data] 通过构造一个数据列表 class 来完成这里的所有工作。 (抛出 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)