Published on

让你的Django应用变DRY的几个最佳实践

Django+Django REST Framework下的DRY实践

Reading Time

10 min read

目前在 Python 的 Web 框架中被应用最广泛的就是 Django 和 Django REST Framework. 这两种框架都提供了非常健壮的功能,能满足 Web 开发的各个方面。DRY 是 Don't-Repeat-Yourself 的缩写,是一种代码编写的原则,即不要重复自己的工作。我个人有些代码洁癖,凡是发现我需要复制粘贴代码的地方,就想着能怎样去除重复的工作。在日常的开发中也总结出了一些个人的实践,分享给大家。

总的来说,要使得你的应用很 DRY,要遵循以下两个原则:

  • 全局都应用的变更,收拢到一个地方配置
  • 有少数与其他不一样的行为,将多数行为定义为全局行为,将少数行为分别配置,并尽可能简化配置方法。

Django 和 Django REST framework(后简称 DRF)提供了海量的全局配置、局部配置,来实现上述思想,但配置项太多了,有时人们往往不知道该如何利用。

一、用户鉴权

1. Django 的配置AUTHENTICATION_BACKENDS

AUTHENTICATION_BACKENDS控制了应用根据传入的参数校验用户是否属于合法用户(用户名是否存在?密码是否正确?)。使用时通过django.contrib.auth.authenticate函数,传入想要的参数,该函数会自动选择对应的后端进行用户校验,常用的校验方式有数据库校验、配置文件校验、LDAP 校验等等。如果你想接入第三方登录,OAuth 登录,都应该自定义一个 Backend,无需继承任何基类,只需实现一个 authenticate 方法,该方法参数与django.contrib.auth.authenticate的传入参数相同,返回一个用户对象,然后将这个 Backend 添加到AUTHENTICATION_BACKENDS就可以了。

**注意:**在使用到用户模型的时候,要使用django.contrib.auth.get_user_model()而不是导入具体的 model 类,这样可以方便用AUTH_USER_MODEL配置去改变用户模型。

class PowerOAuthBackend:
    """请求Power单点登录后跳转的验证"""

    def authenticate(self, request, user=None, password=None):
        if check_user_password(user, password):
            # 返回用户对象
            return get_user_model().get(username=user)
        else:
            # 用户名密码错误 403
            raise PermissionDenied()

	def get_user(self, user_id):
        # 若通过浏览器访问则需要定义次方法,获取已登录的用户对象
        # 若只有RESTful调用则跳过
        return get_user_model().objects.get(staff_id=user_id)

# 登录
def login_view(request):
    username = request.POST.get('user')
    password = request.POST.get('password')
    user = authenticate(user=username, password=password)
    # 将用户存入会话
    login(request, user)
    return redirect('/')

2. DRF 的配置 DEFAULT_AUTHENTICATION_CLASSES

DEFAULT_AUTHENTICATION_CLASSES,以及针对每个APIView配置的authentication_classes,是对 RESTful 请求的身份验证,通过分析请求带的身份信息判断来源方的身份,一般有以下几种方式:

  • 会话鉴权(登录态)
  • BasicAuth 鉴权
  • Token 鉴权

这些类都包含在rest_framework.authentication模块中。如果你要通过智能网关转发后端请求,则需要写一个 Authentication 类,继承自rest_framework.authentication.BaseAuthentication类,其中有两个比较重要的方法,函数签名及说明如下:

class MyAuthentication(BaseAuthentication):
    def authenticate(self, request):
        # 若鉴权成功,则返回一个(user, auth)的元组
        return user, auth
        # 否则,若想交给后面的authentication处理,则返回None
        return None
        # 否则抛出401错误
        raise rest_framework.exceptions.AuthenticationFailed()

    def authenticate_header(self, request):
        # DRF会选择第一顺位的Authentication的此方法返回的结果作为WWW-Authentication头
        # 如果返回为空则会将401错误转换成403错误
        return 'OMS'

3. DRF 的DEFAULT_PERMISSION_CLASSES

如果说 Authentication 是判断「你是谁」,那么 Authorization 就是判断「你能做什么」,就好比你进入公司大楼需要用工卡(Authentication),但你有了工卡也不能随便去总裁办公室(Authorization)。在 DRF 中完成 Authorization 工作的就是DEFAULT_PERMISSION_CLASSES配置项,以及针对每个APIView配置的permission_classes,他是用来精确控制请求放对某一资源有无权限。在 RESTful 规范中,无鉴权信息是 401 错误而无权限是 403 错误。在DRF 的官方文档中有详细例子这里就不再赘述。

二、自定义响应体

很多时候(如前端框架、开发 SDK)对响应体的格式是有要求的,我看到大多数的实现只是用一个格式化的类去填充响应信息,但这种方法有两个缺点:

  1. 每次需要人为构造响应
  2. 无法适用于 DRF 的ModelViewSet,因为它自带的方法的响应是默认的,如果要挨个重载就无法利用到ModelViewSet的懒人特性

所以我们需要将这种格式自定义收拢到一处,做到使用时无感知,响应自动形成期望的格式。要达成这种效果,大致有两种途径:

  1. 写自定义中间件,修改响应格式
  2. 写自定义 renderer

这里第一种途径有几处劣势:

  1. 在中间件处理时rest_framework.response.Response已完成渲染,修改内部数据不起作用
  2. 若重新构造一个rest_framework.response.Response则会报未渲染错误,而渲染过程比较复杂
  3. 若选择用django.http.response.JSONResponse重新构造响应则放弃了 DRF 的自动渲染特性

我对这些缺陷不能忍,于是想到了第二种途径,也就是自定义 renderer,它有以下好处:

  1. 即可全局生效(DEFAULT_RENDERER_CLASSES),又可针对单个APIView生效,非常灵活
  2. 保留了 DRF 的智能渲染特性,即浏览器请求渲染 HTML 页面,后端请求渲染 JSON 响应

DRF 的默认 renderer 有两个:rest_framework.renderers.JSONRendererrest_framework.renderers.BrowsableAPIRenderer。这里可以按需重载,如果浏览器和后端响应都需要,则都重载,如果只需要 JSON 响应,则重载第一个就可以了,这里两个类的重载点不一样:

class JSONRenderer(renderers.JSONRenderer):

    def render(self, data, accepted_media_type=None, renderer_context=None):
        request = renderer_context['request']
        # 在此处修改data
        return super().render(data, accepted_media_type=accepted_media_type, renderer_context=renderer_context)


class BrowsableAPIRenderer(renderers.BrowsableAPIRenderer):

    def get_content(self, renderer, data,
                    accepted_media_type, renderer_context):
        request = renderer_context['request']
        # 在此处修改data
        return super().get_content(renderer, data,
                                   accepted_media_type, renderer_context)

三、异常处理

我们经常会需要抛出异常,有些是主动抛出、有些是未捕获的异常,在这些情况下,我们都希望日志记录异常的堆栈信息,然后返回一个规范的响应(格式与上一节中一致),这样我们就需要更改异常处理。在 Django+DRF 中异常处理有两个重载点:

  1. 中间件中的process_exception函数
  2. DRF 的EXCEPTION_HANDLER配置

而其中EXCEPTION_HANDLER的作用时间早于中间件,这就导致了有些 DRF 内置的异常,在到达中间件之前已经渲染为正常的响应了,这明显不是我们期望的效果,所以我们选择第二个重载点。

def exception_handler(exc, context):
    # copy自DRF默认exception_handler
    if isinstance(exc, Http404):
        exc = exceptions.NotFound()
    elif isinstance(exc, PermissionDenied):
        exc = exceptions.PermissionDenied()

    if isinstance(exc, exceptions.APIException):
        # DRF内置异常
        headers = {}
        if getattr(exc, 'auth_header', None):
            headers['WWW-Authenticate'] = exc.auth_header
        if getattr(exc, 'wait', None):
            headers['Retry-After'] = '%d' % exc.wait

        if isinstance(exc.detail, (list, dict)):
            body = exc.detail
            message = str(exc)
        else:
            body = {}
            message = str(exc.detail)
		# copy结束
        # 组装响应体
        return Response({...})

    else:
        # 其他未捕获异常
        logger.error(traceback.format_exc())
        if not isinstance(exc, ApiError):
            exc = ApiError(str(exc))
        # 组装响应体
        return exc.as_response()

美中不足的是有一大段的代码是从 DRF 默认的异常处理函数 copy 过来的,这是 DRF 为数不多的不合理设计,留了一个配置项供你改变默认行为,但却没有留出一个好的重载点。

总结

DRY 原则能使你的代码结构好、易维护、易扩展。在日常的开发中,要时刻反思自己的代码是否过于重复,可以精简。在 Python 中,可以说只要你想,一定能把多处一样的代码给抽取出来。只是有时候为了抽出这些代码,又产生了很多额外的代码,这是需要取舍的。相信本文中提到的三个大方向,能对你有所启发。

Share: