Django-drf 补充

中间件的应用

之前我们已经实现了用户必须登录才能投票的限制,但是一个新的问题来了。如果我们的应用中有很多功能都需要用户先登录才能执行,例如将前面导出Excel报表和查看统计图表的功能都做了必须登录才能访问的限制,那么我们是不是需要在每个视图函数中添加代码来检查session中是否包含userid的代码呢?答案是否定的,如果这样做了,我们的视图函数中必然会充斥着大量的重复代码。编程大师Martin Fowler曾经说过:代码有很多种坏味道,重复是最坏的一种。在Python程序中,我们可以通过装饰器来为函数提供额外的能力;在Django项目中,我们可以把类似于验证用户是否登录这样的重复性代码放到中间件中。

Django中间件概述

中间件是安插在Web应用请求和响应过程之间的组件,它在整个Web应用中扮演了拦截过滤器的角色,通过中间件可以拦截请求和响应,并对请求和响应进行过滤(简单的说就是执行额外的处理)。通常,一个中间件组件只专注于完成一件特定的事,例如:Django框架通过SessionMiddleware中间件实现了对session的支持,又通过AuthenticationMiddleware中间件实现了基于session的请求认证。通过把多个中间件组合在一起,我们可以完成更为复杂的任务,Django框架就是这么做的。

Django项目的配置文件中就包含了对中间件的配置,代码如下所示。

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

我们稍微为大家解释一下这些中间件的作用:

  1. CommonMiddleware - 基础设置中间件,可以处理以下一些配置参数。
    • DISALLOWED_USER_AGENTS - 不被允许的用户代理(浏览器)
    • APPEND_SLASH - 是否追加/
    • USE_ETAG - 浏览器缓存相关
  2. SecurityMiddleware - 安全相关中间件,可以处理和安全相关的配置项。
    • SECURE_HSTS_SECONDS - 强制使用HTTPS的时间
    • SECURE_HSTS_INCLUDE_SUBDOMAINS - HTTPS是否覆盖子域名
    • SECURE_CONTENT_TYPE_NOSNIFF - 是否允许浏览器推断内容类型
    • SECURE_BROWSER_XSS_FILTER - 是否启用跨站脚本攻击过滤器
    • SECURE_SSL_REDIRECT - 是否重定向到HTTPS连接
    • SECURE_REDIRECT_EXEMPT - 免除重定向到HTTPS
  3. SessionMiddleware - 会话中间件。
  4. CsrfViewMiddleware - 通过生成令牌,防范跨请求份伪的造中间件。
  5. XFrameOptionsMiddleware - 通过设置请求头参数,防范点击劫持攻击的中间件。

在请求的过程中,上面的中间件会按照书写的顺序从上到下执行,然后是URL解析,最后请求才会来到视图函数;在响应的过程中,上面的中间件会按照书写的顺序从下到上执行,与请求时中间件执行的顺序正好相反。

自定义中间件

Django中的中间件有两种实现方式:基于类的实现方式和基于函数的实现方式,后者更接近于装饰器的写法。装饰器实际上是代理模式的应用,将横切关注功能(与正常业务逻辑没有必然联系的功能,例如:身份认证、日志记录、编码转换之类的功能)置于代理中,由代理对象来完成被代理对象的行为并添加额外的功能。中间件对用户请求和响应进行拦截过滤并增加额外的处理,在这一点上它跟装饰器是完全一致的,所以基于函数的写法来实现中间件就跟装饰器的写法几乎一模一样。下面我们用自定义的中间件来实现用户登录验证的功能。

"""
middlewares.py
"""
from django.http import JsonResponse
from django.shortcuts import redirect

# 需要登录才能访问的资源路径
LOGIN_REQUIRED_URLS = {'/praise/', '/criticize/', '/excel/', '/teachers_data/'}


def check_login_middleware(get_resp):

    def wrapper(request, *args, **kwargs):
        # 请求的资源路径在上面的集合中
        if request.path in LOGIN_REQUIRED_URLS:
            # 会话中包含userid则视为已经登录
            if 'userid' not in request.session:
                # 判断是不是Ajax请求
                if request.is_ajax():
                    # Ajax请求返回JSON数据提示用户登录
                    return JsonResponse({'code': 10003, 'hint': '请先登录'})
                else:
                    backurl = request.get_full_path()
                    # 非Ajax请求直接重定向到登录页
                    return redirect(f'/login/?backurl={backurl}')
        return get_resp(request, *args, **kwargs)

    return wrapper

当然,我们也可以定义一个类来充当装饰器,如果类中有__call__魔术方法,这个类的对象就像函数一样可调用,所以下面是另一种实现中间件的方式,道理跟上面的代码完全一样。

还有一种基于类实现中间件的方式,这种方式在较新版本的Django中已经不推荐使用了,但是大家接触到的代码中,仍然有可能遇到这种写法,大致的代码如下所示。

from django.utils.deprecation import MiddlewareMixin


class MyMiddleware(MiddlewareMixin):

    def process_request(self, request):
        pass

    def process_view(self, request, view_func, view_args, view_kwargs):
        pass

    def process_template_response(self, request, response):
        pass

    def process_response(self, request, response):
        pass

    def process_exception(self, request, exception):
        pass

上面类中的五个方法都是中间件的钩子函数,分别在收到用户请求、进入视图函数之前、渲染模板、返回响应和出现异常的时候被回调。当然,写不写这些方法是根据中间件的需求来确定的,并不是所有的场景都需要重写五个方法,下面的图相信能够帮助大家理解这种写法。

写好中间件代码后,需要修改配置文件来激活中间件使其生效。

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
    'debug_toolbar.middleware.DebugToolbarMiddleware',
    'vote.middlewares.check_login_middleware',
]

注意上面这个中间件列表中元素的顺序,当收到来自用户的请求时,中间件按照从上到下的顺序依次执行,这行完这些中间件以后,请求才会最终到达视图函数。当然,在这个过程中,用户的请求可以被拦截,就像上面我们自定义的中间件那样,如果用户在没有登录的情况下访问了受保护的资源,中间件会将请求直接重定向到登录页,后面的中间件和视图函数将不再执行。在响应用户请求的过程中,上面的中间件会按照从下到上的顺序依次执行,这样的话我们还可以对响应做进一步的处理。

中间件执行的顺序是非常重要的,对于有依赖关系的中间件必须保证被依赖的中间件要置于依赖它的中间件的前面,就好比我们刚才自定义的中间件要放到SessionMiddleware的后面,因为我们要依赖这个中间件为请求绑定的session对象才能判定用户是否登录。

Django项目接入Redis

在此前的课程中,我们介绍过Redis的安装和使用,此处不再进行赘述。如果需要在Django项目中接入Redis,可以使用三方库django-redis,这个三方库又依赖了一个名为redis 的三方库,它封装了对Redis的各种操作。

安装django-redis

pip install django-redis

修改Django配置文件中关于缓存的配置。

CACHES = {
    'default': {
        # 指定通过django-redis接入Redis服务
        'BACKEND': 'django_redis.cache.RedisCache',
        # Redis服务器的URL
        'LOCATION': ['redis://1.2.3.4:6379/0', ],
        # Redis中键的前缀(解决命名冲突)
        'KEY_PREFIX': 'vote',
        # 其他的配置选项
        'OPTIONS': {
            'CLIENT_CLASS': 'django_redis.client.DefaultClient',
            # 连接池(预置若干备用的Redis连接)参数
            'CONNECTION_POOL_KWARGS': {
                # 最大连接数
                'max_connections': 512,
            },
            # 连接Redis的用户口令
            'PASSWORD': 'foobared',
        }
    },
}

至此,我们的Django项目已经可以接入Redis,接下来我们修改项目代码,用Redis为之写的获取学科数据的接口提供缓存服务。

为视图提供缓存服务

声明式缓存

所谓声明式缓存是指不修改原来的代码,通过Python中的装饰器(代理)为原有的代码增加缓存功能。对于FBV,代码如下所示。

from django.views.decorators.cache import cache_page


@api_view(('GET', ))
@cache_page(timeout=86400, cache='default')
def show_subjects(request):
    """获取学科数据"""
    queryset = Subject.objects.all()
    data = SubjectSerializer(queryset, many=True).data
    return Response({'code': 20000, 'subjects': data})

上面的代码通过Django封装的cache_page装饰器缓存了视图函数的返回值(响应对象),cache_page的本意是缓存视图函数渲染的页面,对于返回JSON数据的视图函数,相当于是缓存了JSON数据。在使用cache_page装饰器时,可以传入timeout参数来指定缓存过期时间,还可以使用cache参数来指定需要使用哪一组缓存服务来缓存数据。Django项目允许在配置文件中配置多组缓存服务,上面的cache='default'指定了使用默认的缓存服务(因为之前的配置文件中我们也只配置了名为default的缓存服务)。视图函数的返回值会被序列化成字节串放到Redis中(Redis中的str类型可以接收字节串),缓存数据的序列化和反序列化也不需要我们自己处理,因为cache_page装饰器会调用django-redis库中的RedisCache来对接Redis,该类使用了DefaultClient来连接Redis并使用了pickle序列化django_redis.serializers.pickle.PickleSerializer是默认的序列化类。

如果缓存中没有学科的数据,那么通过接口访问学科数据时,我们的视图函数会通过执行Subject.objects.all()向数据库发出SQL语句来获得数据,视图函数的返回值会被缓存,因此下次请求该视图函数如果缓存没有过期,可以直接从缓存中获取视图函数的返回值,无需再次查询数据库。如果想了解缓存的使用情况,可以配置数据库日志或者使用Django-Debug-Toolbar来查看,第一次访问学科数据接口时会看到查询学科数据的SQL语句,再次获取学科数据时,不会再向数据库发出SQL语句,因为可以直接从缓存中获取数据。

对于CBV,可以利用Django中名为method_decorator的装饰器将cache_page这个装饰函数的装饰器放到类中的方法上,效果跟上面的代码是一样的。需要提醒大家注意的是,cache_page装饰器不能直接放在类上,因为它是装饰函数的装饰器,所以Django框架才提供了method_decorator来解决这个问题,很显然,method_decorator是一个装饰类的装饰器。

from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page


@method_decorator(decorator=cache_page(timeout=86400, cache='default'), name='get')
class SubjectView(ListAPIView):
    """获取学科数据的视图类"""
    queryset = Subject.objects.all()
    serializer_class = SubjectSerializer

编程式缓存

所谓编程式缓存是指通过自己编写的代码来使用缓存服务,这种方式虽然代码量会稍微大一些,但是相较于声明式缓存,它对缓存的操作和使用更加灵活,在实际开发中使用得更多。下面的代码去掉了之前使用的cache_page装饰器,通过django-redis提供的get_redis_connection函数直接获取Redis连接来操作Redis。

def show_subjects(request):
    """获取学科数据"""
    redis_cli = get_redis_connection()
    # 先尝试从缓存中获取学科数据
    data = redis_cli.get('vote:polls:subjects')
    if data:
        # 如果获取到学科数据就进行反序列化操作
        data = json.loads(data)
    else:
        # 如果缓存中没有获取到学科数据就查询数据库
        queryset = Subject.objects.all()
        data = SubjectSerializer(queryset, many=True).data
        # 将查到的学科数据序列化后放到缓存中
        redis_cli.set('vote:polls:subjects', json.dumps(data), ex=86400)
    return Response({'code': 20000, 'subjects': data})

需要说明的是,Django框架提供了cachecaches两个现成的变量来支持缓存操作,前者访问的是默认的缓存(名为default的缓存),后者可以通过索引运算获取指定的缓存服务(例如:caches['default'])。向cache对象发送getset消息就可以实现对缓存的读和写操作,但是这种方式能做的操作有限,不如上面代码中使用的方式灵活。还有一个值得注意的地方,由于可以通过get_redis_connection函数获得的Redis连接对象向Redis发起各种操作,包括FLUSHDBSHUTDOWN等危险的操作,所以在实际商业项目开发中,一般都会对django-redis再做一次封装,例如封装一个工具类,其中只提供了项目需要用到的缓存操作的方法,从而避免了直接使用get_redis_connection的潜在风险。当然,自己封装对缓存的操作还可以使用“Read Through”和“Write Through”的方式实现对缓存的更新,这个在下面会介绍到。

缓存相关问题

缓存数据的更新

在使用缓存时,一个必须搞清楚的问题就是,当数据改变时,如何更新缓存中的数据。通常更新缓存有如下几种套路,分别是:

  1. Cache Aside Pattern
  2. Read/Write Through Pattern
  3. Write Behind Caching Pattern

第1种方式的具体做法就是,当数据更新时,先更新数据库,再删除缓存。注意,不能够使用先更新数据库再更新缓存的方式,也不能够使用先删除缓存再更新数据库的方式,大家可以自己想一想为什么(考虑一下有并发的读操作和写操作的场景)。当然,先更新数据库再删除缓存的做法在理论上也存在风险,但是发生问题的概率是极低的,所以不少的项目都使用了这种方式。

第1种方式相当于编写业务代码的开发者要自己负责对两套存储系统(缓存和关系型数据库)的操作,代码写起来非常的繁琐。第2种方式的主旨是将后端的存储系统变成一套代码,对缓存的维护封装在这套代码中。其中,Read Through指在查询操作中更新缓存,也就是说,当缓存失效的时候,由缓存服务自己负责对数据的加载,从而对应用方是透明的;而Write Through是指在更新数据时,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后再由缓存服务自己更新数据库(同步更新)。刚才我们说过,如果自己对项目中的Redis操作再做一次封装,就可以实现“Read Through”和“Write Through”模式,这样做虽然会增加工作量,但无疑是一件“一劳永逸”且“功在千秋”的事情。

第3种方式是在更新数据的时候,只更新缓存,不更新数据库,而缓存服务这边会异步的批量更新数据库。这种做法会大幅度提升性能,但代价是牺牲数据的强一致性。第3种方式的实现逻辑比较复杂,因为他需要追踪有哪数据是被更新了的,然后再批量的刷新到持久层上。

缓存穿透

缓存是为了缓解数据库压力而添加的一个中间层,如果恶意的访问者频繁的访问缓存中没有的数据,那么缓存就失去了存在的意义,瞬间所有请求的压力都落在了数据库上,这样会导致数据库承载着巨大的压力甚至连接异常,类似于分布式拒绝服务攻击(DDoS)的做法。解决缓存穿透的一个办法是约定如果查询返回为空值,把这个空值也缓存起来,但是需要为这个空值的缓存设置一个较短的超时时间,毕竟缓存这样的值就是对缓存空间的浪费。另一个解决缓存穿透的办法是使用布隆过滤器,具体的做法大家可以自行了解。

缓存击穿

在实际的项目中,可能存在某个缓存的key某个时间点过期,但恰好在这个时间点对有对该key的大量的并发请求过来,这些请求没有从缓存中找到key对应的数据,就会直接从数据库中获取数据并写回到缓存,这个时候大并发的请求可能会瞬间把数据库压垮,这种现象称为缓存击穿。比较常见的解决缓存击穿的办法是使用互斥锁,简单的说就是在缓存失效的时候,不是立即去数据库加载数据,而是先设置互斥锁(例如:Redis中的setnx),只有设置互斥锁的操作成功的请求,才能执行查询从数据库中加载数据并写入缓存,其他设置互斥锁失败的请求,可以先执行一个短暂的休眠,然后尝试重新从缓存中获取数据,如果缓存还没有数据,则重复刚才的设置互斥锁的操作,大致的参考代码如下所示。

data = redis_cli.get(key)
while not data:
    if redis_cli.setnx('mutex', 'x'):
        redis.expire('mutex', timeout)
        data = db.query(...)
        redis.set(key, data)
        redis.delete('mutex')
    else:
        time.sleep(0.1)
        data = redis_cli.get(key)

缓存雪崩

缓存雪崩是指在将数据放入缓存时采用了相同的过期时间,这样就导致缓存在某一时刻同时失效,请求全部转发到数据库,导致数据库瞬时压力过大而崩溃。解决缓存雪崩问题的方法也比较简单,可以在既定的缓存过期时间上加一个随机时间,这样可以从一定程度上避免不同的key在同一时间集体失效。还有一种办法就是使用多级缓存,每一级缓存的过期时间都不一样,这样的话即便某个级别的缓存集体失效,但是其他级别的缓存还能够提供数据,避免所有的请求都落到数据库上。

posted @ 2025-04-21 23:06  小郑[努力版]  阅读(35)  评论(0)    收藏  举报