Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

用户管理开发规范[初稿] #1001

Closed
wklken opened this issue Jun 3, 2023 · 23 comments
Closed

用户管理开发规范[初稿] #1001

wklken opened this issue Jun 3, 2023 · 23 comments

Comments

@wklken
Copy link
Collaborator

wklken commented Jun 3, 2023

用户管理开发规范[初稿]

基于 Python 的 Django + DRF 框架开发, 开发规范汇总

[toc]

分层与单一职责

当前分层:

  • view 层: slz + viewset, 处理参数校验和响应, 不允许放复杂业务逻辑
  • biz 层: 业务逻辑层, 组合/拼装, 依赖底层model, 外部components, 存储等
  • model 层: 模型层, 关注模型本身的逻辑, 简单的property/函数等, 不允许出现复杂逻辑

其他:

  • 切分appsapis, 避免混用/引入不必要的复杂度
    • apps 放供产品使用的接口层代码
    • apis开发 API, 接入网关/ESB 供外部调用的接口代码
  • common 模块: 放业务相关的公共代码
  • util 模块: 放业务无关的公共代码
  • component 模块: 放依赖外部系统的代码

1. Python 基础规范

请先阅读:

下面补充一些额外规范

1.1 基础规范

1.1.1 强制要求 typehint

1.1.2 优先使用f-string

除了logger, 其他字符串拼装尽量使用f-string

1.1.3 函数的存在多个return, 返回值必须保持一致

  • 参数个数必须一致, 不能出现某个return返回 1 个参数, 另一个return返回 2 个
  • 返回的形式尽量保持一致, 例如校验函数要么都raise要么都返回bool, 不要混用

1.1.4 单一职责, 函数不能有副作用

一个函数只做一件事情, 函数不能有副作用

1.2 日志与异常

1.2.4 logger打印要包含上下文, 有异常的地方要用logger.exception

# bad
except:
    logger.error("error")

# good
except:
    logger.exception("some log error [key=%s, value=%s]", key, value)

1.2.2 使用统一的logging.getLogger

禁止通过其他方式获取logger

1.2.3 禁止吞掉异常

except:
    pass

1.2.4 禁止在代码中使用print打印日志

1.2.5 日志打印中禁止打印敏感信息, 例如密码/解密的明文等信息

2. view层

2.1 serializer

2.1.1 命名规则

驼峰命名, 以大写SLZ结尾(或者写全Serializer, 但是必须所有地方保持一致, 不要混用), 区分入参及响应使用的 SLZ

  • 入参: FooBarInputSLZ
  • 响应: FooBarOutputSLZ

其中FooBarviewset的前缀

2.1.2 DRF的输出都必须使用Serializer, 禁止直接构造dict放入response

好处:

  1. 便于生成文档
  2. 可维护性更好

2.1.2 同一个模型的serializer公共字段尽量复用

class FooBarBaseSLZ(serializers.Serializer):
    ...
    
class FooBarListInputSLZ(FooBarBaseSLZ):
    ...
    
class FooBarSearchInputSLZ(FooBarBaseSLZ):
    ...

2.1.3 尽量避免使用serializers.ModelSerializer

简单模型/字段少/无特殊处理逻辑等场景, 可以使用, 例如示例中只包含Meta.model/Meta.fields, 不存在覆写一些父类方法改变/定制某些行为;

class AccountSerializer(serializers.ModelSerializer):
    class Meta:
        model = Account
        fields = ['id', 'account_name', 'users', 'created']

否则, 尽量避免使用

如果使用, 使用Meta.fields声明白名单, 避免使用fields = "__all__"; 防止未来增删敏感字段被意外暴露或被修改

2.1.4 复杂的字段校验使用 validate_{field_name}(); 复杂的整体校验使用validate()

from rest_framework import serializers

class BlogPostSerializer(serializers.Serializer):
    title = serializers.CharField(max_length=100)
    content = serializers.CharField()

    def validate_title(self, value):
        """
        Check that the blog post is about Django.
        """
        if 'django' not in value.lower():
            raise serializers.ValidationError("Blog post is not about Django")
        return value
from rest_framework import serializers

class EventSerializer(serializers.Serializer):
    description = serializers.CharField(max_length=100)
    start = serializers.DateTimeField()
    finish = serializers.DateTimeField()

    def validate(self, data):
        """
        Check that the start is before the stop.
        """
        if data['start'] > data['finish']:
            raise serializers.ValidationError("finish must occur after start")
        return data

2.1.5 字段处理/转换使用SerializerMethodField

class LoginLogOutputSLZ(serializers.Serializer):
    datetime = serializers.SerializerMethodField(help_text=_("登录时间"), required=False)

    def get_datetime(self, obj):
        return obj.create_time

注意: 尽量避免在SerializerMethodField中做数据库查询/IO 等代价较高的操作, 可能带来性能问题; e.g. 在一个method中做了一次db查询, 如果翻页返回 100 个, 那么单次接口请求会有 100 个db查询(查询放大)

2.1.6 使用serializer_context来避免查询放大

全局查询一次, 而不是每个对象查询一次

class LoginLogListApi(generics.ListAPIView):
    serializer_class = LoginLogOutputSLZ

    def get_serializer_context(self):
        # request/format/view
        ctx = super().get_serializer_context(self)
        ctx["category_name_map"] =  get_category_display_name_map()
        return ctx

class LoginLogOutputSLZ(serializers.Serializer):
    category_display_name = serializers.SerializerMethodField(help_text=_("所属目录"), required=False)

    def get_category_display_name(self, obj) -> str:
        """get category display name from log extra value"""
        category_id = obj.profile.id
        category_name_map = self.context.get("category_name_map")
        category_display_name = category_name_map.get(category_id, PLACE_HOLDER)
        return category_display_name

2.1.7 除非你理解to_representationto_internal_value, 否则不要使用

如果输入的表单和业务模型数据结构差异很大, 需要做全局转换, 可以使用 to_internal_value 统一处理; 但是需要注意validate是否生效的问题;

如果需要做较大的转换, 例如得到的模型数据相对返回的数据结构差异很大, 需要做大量的转换/映射/内嵌/判断等, 此时可以直接定义to_representation(self, obj)简化

2.2 viewset

2.2.1 命名规则

实体 + 操作 + [Api|View]

注意, Api小写;

建议用 Api

  • {model}ListCreateApi
  • {model}UpdateApi

也可以用View, 但是必须所有地方保持一致, 不能混用

2.2.2 View细粒度拆分

不要把同一个实体的所有处理逻辑放在一个view class中

切忌大而全, 这样会导致 serializer_class or get_serializer_class/pagination_class/filter_backends/get_serializer_context等等代码出现大量的判断, 导致逻辑揉在一起, 读一段代码不是线性的, 到了每个方法都有一些逻辑判断;

2.2.3 只能继承于generics.xxxAPIView

DRF继承关系图

单一

  • generics.CreateAPIView
  • generics.ListAPIView
  • generics.DestroyAPIView
  • generics.RetrieveAPIView
  • generics.UpdateAPIView

复合

  • generics.ListCreateAPIView
  • generics.RetrieveUpdateAPIView
  • generics.RetrieveDestroyAPIView
  • generics.RetrieveUpdateDestroyAPIView

原则:

  • 除非极特殊场景, 只能继承于上面这批父类;
  • 优先继承单一的父类, 有需要再继承复合的父类

2.2.4 禁止对 DRF Viewset/View 做封装增加中间层

使用 DRF 的大多数情况下, 代码越多越好维护

某些项目基于某些原因增加一层中间层, 封装的同时加入了一批约定属性及约定方法, 来实现业务公共逻辑

避免引入意大利面式的调用链, 阅读困难, 带来认知负担

复杂度极具上升, 且因为存在两层约定(DRF 一层, 封装类一层), 在加上继承子类里面的逻辑, 整体的调用链路/数据流将变得不可理解, 认知负担非常大, 容易出bug

2.2.5 尽量避免使用 viewsets.ModelViewSet

除非模型非常简单, 没有附加的一些处理逻辑, 并且未来也不会加入, 否则尽量避免使用

2.2.6 使用 DRF 约定 扩展实现公共逻辑

组合优于继承

根据需求, 合理定义相关的组件, 拼装组合出最终的结果

  • permission_classes 权限控制
  • pagination_class 翻页
  • filter_backends 过滤
class LoginLogListApi(generics.ListAPIView):
    permission_classes = [ViewAuditPermission]
    pagination_class = CustomPagination
    serializer_class = LoginLogOutputSLZ

    filter_backends = [StartTimeEndTimeFilterBackend]

这些扩展, 尽量做到望名知意, 细粒度可自由组合, 切忌实现大而全的扩展

2.3 url

2.3.1 符合 Restful 风格

遵循 Restful 风格, 避免在路径中使用动词create/serach

3. biz 层

4. model 层

4.1 model

4.1.1 命名规则

驼峰命名, 并且需要跟业务概念一致

4.1.2 只允许包含简单的方法和property

避免在model的方法做一些复杂操作

尽量避免在方法/property中做db查询; (不可预测的查询放大, 例如某个分页查询page_size = 100返回的slz中引用了property, 会带来100次查询)

4.1.3 避免在model方法中查询另一张表/几张表

不合理的依赖, 可能带来循环引用

4.2 manager

复杂的model查询/操作等, 写到managers.py

4.2.1 方法命名规则

动词+名词, 不要带实体本身

# Good
FooBar.objects.getRelatedTypes()

# Bad
FooBar.objects.getFooBarRelatedTypes()

4.2.2 尽量只包含对当前模型的操作

5 DRF

5.1 DRF 特性使用

5.1.1 使用 permission_classes 做权限控制

但是注意, 一个类只做一件事情

@wklken
Copy link
Collaborator Author

wklken commented Jul 18, 2023

  • 模型层 apps/{entity}/[models.py, managers.py, constants.py] 通用的, 需要高内聚
  • View 层
    • apis/web/{entity}/[serializers.py, constants.py, views.py, urls.py] 注意这里只能引用外部模块, 不能被外部模块引用
    • apis/web/[authentication.py, permissions.py, utils.py, urls.py] 这里仅web内部模块使用
  • Biz层:
    • biz/{entity}.py
    • biz/{entity}/{sub_entity}.py
  • 其他:
    • 逐步去attrs
    • 统一使用dataclass, 或者pydantic

其他: 使用新版 HTTP 协议实现 (标准 HTTP 状态码 + 统一的响应体)

@wklken
Copy link
Collaborator Author

wklken commented Jul 26, 2023

  • filter, 只应该有通用的filter, 例如统一处理start_time/end_time; 如果一个filter只被一个view使用, 并且是定制逻辑, 那么应该直接放在get_queryset里面(即, 在view里面能读到所有逻辑, 不需要跳转到filter)
  • 建议: outputSLZ 不使用 modelSerializer; 如果使用modelSerializer, 需要定义meta中read_only_fields以及readonly相关属性

@wklken
Copy link
Collaborator Author

wklken commented Jul 26, 2023

drf 继承关系图: 当前规范建议只继承图中右下角的类
https://wklken.me/posts/2022/10/07/django-drf-inherit.html

@wklken
Copy link
Collaborator Author

wklken commented Jul 26, 2023

urls.py中使用聚合的方式组织相同前缀的 path

urlpatterns = [
    path(
        "<page_slug>-<page_id>/",
        include(
            [
                path("history/", views.history),
                path("edit/", views.edit),
                path("discuss/", views.discuss),
                path("permissions/", views.permissions),
            ]
        ),
    ),
]

@wklken
Copy link
Collaborator Author

wklken commented Jul 26, 2023

serializer命名风格 {entity}{Verb}InputSLZ or {entity}{Verb}OutputSLZ

@nannan00
Copy link
Collaborator

nannan00 commented Aug 3, 2023

目前都是基于Python3.6开发,尽可能添加类型注解

@wklken
Copy link
Collaborator Author

wklken commented Aug 3, 2023

目前都是基于Python3.6开发,尽可能添加类型注解

1.1.1 里面有说明, 强制要求 typehint

@wklken
Copy link
Collaborator Author

wklken commented Aug 4, 2023

使用lint检查项目的分层依赖

https://github.com/seddonym/import-linter

@nannan00
Copy link
Collaborator

nannan00 commented Aug 7, 2023

引入Pydantic 2.x,作为数据类

  1. view层调用biz层,对于数据对象,尽可能的使用Pydantic定义数据,不直接使用Dict
  2. 对于biz层返回,使用Pydantic定义数据,不直接返回Dict 对象模型的数据

@nannan00
Copy link
Collaborator

view层调用biz层,参数和返回值不直接使用Dict,而是需要使用Pydantic定义的数据类,pydantic 实践参考如下:

  • pydantic版本:2.x

  • 赋值方式初始化数据类

      1. Entity(id=1, name="2", desc="3", ...)
      1. Entity(**{"id": 1, "name": "2", "desc": "3", ...})
  • 数据类对象的一些使用方式

    • 1.【对于需要使用的字段属性多于3个】Entity.model_dump(include={"id", "name", "desc", ...}) 将数据对象按include指定字段导出为Dict
      • Note: include方式,字段都是动态的,如果模型删除字段了,编辑器也检查不出来,只能靠单元测试覆盖
      1. 【对于小于或等于3个字段】可以使用方式(1),也可以直接以对象方式使用,即 {"id": Entity.id, "name": Entity.name, ...}

@wklken
Copy link
Collaborator Author

wklken commented Aug 11, 2023

正确地使用 201/204, 而不要所有的地方都用 200(前端需要同时支持判定 20x 为成功)

@wklken
Copy link
Collaborator Author

wklken commented Aug 15, 2023

  • 删除必须使用delete方法, 并且一般情况下不能有body, 通过query params 传递条件信息; 删除返回状态码 204

@nannan00
Copy link
Collaborator

允许使用Mixin,但必须保证职责单一,不能将多种职责复合在一个Mixin里

@narasux
Copy link
Collaborator

narasux commented Sep 4, 2023

Input/Output SLZ 中,如果有不确定的 Dict 类型需求,更推荐使用 serializers.JSONField

讨论参考:#1207 (comment)

@wklken
Copy link
Collaborator Author

wklken commented Sep 4, 2023

相对引用与绝对引用

  • 模块内的引用, 使用相对引用 (即, 允许from .demo.a import b
  • 模块外的引用, 使用绝对引用 (即, 禁止from ..demo.a import b)

@nannan00
Copy link
Collaborator

nannan00 commented Sep 4, 2023

相对引用与绝对引用

  • 模块内的引用, 使用相对引用 (即, 允许from .demo.a import b
  • 模块外的引用, 使用绝对引用 (即, 禁止from ..demo.a import b)

是否限制相对引用,可以只能引用 一层,比如 可以from .demo imprt a ,但不能 from .demo.a.b.c.d import e ?

@wklken
Copy link
Collaborator Author

wklken commented Sep 6, 2023

@wklken
Copy link
Collaborator Author

wklken commented Sep 8, 2023

应该明确, 什么时候用dataclass, 什么时候用原生类, 什么时候用pydantic?

  • dataclass: 纯数据对象, 带有少量方法, 偏数据, 基本没有业务逻辑
  • class: 对象, 带有很多方法, 是业务实体包含业务逻辑
  • pydantic?

@wklken
Copy link
Collaborator Author

wklken commented Sep 11, 2023

class XSLZ(serializers.Serializer):
    class Meta:
        ref_name = "the_import_path_of_this_serializer"

@wklken
Copy link
Collaborator Author

wklken commented Sep 14, 2023

NIT: serializer_class规则 (同DRF保持一致)

nice to have

  1. CreateAPIView InputSLZ
  2. ListModelMixin OutputSLZ
  3. RetrieveAPIView OutputSLZ
  4. DestroyAPIView -
  5. UpdateAPIView InputSLZ
  6. ListCreateAPIView InputSLZ/OutputSLZ => 原则上用同一个, 如果有差异, 默认给 第一个用, 例如 ListCreateAPIView => List 可以用 serializer_class
  7. RetrieveUpdateAPIView InputSLZ/OutputSLZ, 同上
  8. RetrieveDestroyAPIView InputSLZ/OutputSLZ, 同上
  9. RetrieveUpdateDestroyAPIView InputSLZ/OutputSLZ, 同上

@wklken
Copy link
Collaborator Author

wklken commented Sep 15, 2023

  • slz的data是一个dict, 不允许作为参数直接传到下层, 必须构造对应的类实例后下传(pydantic)
  • biz 层往下, 尽量使用dataclass而不是pydantic; 因为没有校验需求, 并且dataclass大概率满足需求

@wklken
Copy link
Collaborator Author

wklken commented Sep 18, 2023

  • 没有特殊情况, list 接口需要使用分页;
  • 不建议提供类似no_page=true拉取全量数据;

@wklken
Copy link
Collaborator Author

wklken commented Oct 11, 2023

转到 内部规范, 进入讨论修订阶段
close

@wklken wklken closed this as completed Oct 11, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants