【python之DRF学习】drf之jwt使用

介绍JWT

Json web token (JWT)是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准((RFC 7519).
该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。
JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,
以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,
该token也可直接被用于认证,也可被加密。

一、构成与原理

1、构成

jwt构成介绍

JWT是一段字符串,由三段信息构成,将这三段信息文本用.连接在一起,就构成了JWT字符串,例如:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
第一段信息:  jwt头部有两部分信息:(header)
 	· 声明加密算法: jwt
  · 声明的加密算法的类型: 通常都为HMAC SHA256
 	
  完整的头部格式(json):
  	{
      'typ': 'JWT',
      'alg': 'HS256'
    }
import base64
import json
res = base64.b64decode('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9')
print(json.loads(res))  # {'alg': 'HS256', 'typ': 'JWT'}      

第二段信息:
	载荷(payload):存放有效信息的地方:过期时间、签发时间、用户id、用户名等。
标准中注册的声明 (建议但不强制使用) :
	载荷的三部分信息:
  	1、标准中注册的声明
    2、公共的声明
    3、私有的声明
    
iss: jwt签发者
sub: jwt所面向的用户
aud: 接收jwt的一方
exp: jwt的过期时间,这个过期时间必须要大于签发时间
nbf: 定义在什么时间之前,该jwt都是不可用的.
iat: jwt的签发时间
jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避时序攻击。
            
第三段信息:
  签名(signature)
  header(base64加密后) + payload(base64加密后) + secret  = jwt
	这个方式需要base64加密后的header和base64加密后的payload用.连接起来组成字符串,然后通过header中声名的加密方式进行加盐secret组合加密,就构成了jwt的第三部分。
  
// javascript  通过前端生成第三段数据
var encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload);
var signature = HMACSHA256(encodedString, 'secret'); // TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ(得到的仅是第三部分。
"""
注意:secret是保存在服务端当中的,并且服务端只有一个secret,也就是所有的用户加密的秘钥都是相同的,jwt的签发也是在服务端发生,secret就是用来进行jwt的签发和jwt的验证,所以这是服务端的私钥,任何时候都不应该泄露出去。泄露出去代表客户端可以自己签发jwt了。
"""  
                                                                                                    
总结:
第一部分我们称它为头部(header);
第二部分我们称其为载荷(payload, 类似于飞机上承载的物品);
第三部分是签证(signature).

2、本质/原理

1、jwt认证算法

签发与校验

#1、jwt分三段式:头.体.签名 (head.payload.sgin)

#2、加密数据
    头和体是可逆加密,让服务器可以反解出user对象, base64
    签名是不可逆加密,保证整个token的安全性的     MD5加密算法
1.1、签发:

根据登录请求提交来的 账号 + 密码 + 设备信息 签发 token

1)用基本信息存储json字典,采用base64算法加密得到 头字符串
2)用关键信息存储json字典,采用base64算法加密得到 体字符串
3)用头、体加密字符串再加安全码信息存储json字典,采用hash md5算法加密得到 签名字符串

账号密码就能根据User表得到user对象,形成的三段字符串用 . 拼接成token返回给前台
1.2、校验

根据客户端带token的请求 反解出 user 对象

#将token按 . 拆分为三段字符串1)第一部分
    1-头加密字符串 一般不需要做任何处理

(2)第二部分 
    2.1-体加密字符串,要反解出用户主键,通过主键从User表中就能得到登录用户,
    2.2-过期时间和设备信息都是安全信息,确保token没过期,且时同一设备来的

(3)第三部分
    再用 第一段 + 第二段 + 服务器安全码 不可逆md5加密,与第三段 签名字符串 进行碰撞校验,通过后才能代表第二段校验得到的user对象就是合法的登录用户

3、补充: base64的介绍与使用

-1、任何语言都有base64的加码和解码,转码方式(加密方式)
-2、 python中base64的加密与解密
import base64
import json
dic_info={
  "name": "quan",
  "age": 18
}
# 转成json格式字符串

dic_str=json.dumps(dic_info)
print(dic_str)

#eyJuYW1lIjogInF1YW4iLCAiYWdlIjogMTh9
#eyJuYW1lIjogInF1YW4iLCAiYWdlIjogMTh9
# 需要用bytes格式
# 加码
base64_str=base64.b64encode(dic_str.encode('utf-8'))
print(base64_str)  # b'eyJuYW1lIjogInF1YW4iLCAiYWdlIjogMTh9'

# 解码
res_bytes=base64.b64decode('eyJuYW1lIjogInF1YW4iLCAiYWdlIjogMTh9')
print(res_bytes)

4、drf+ jwt开发流程

1)用账号密码访问登录接口,登录接口逻辑中调用 签发token 算法,得到token,返回给客户端,客户端自己存到cookies中

2)校验token的算法应该写在认证类中(在认证类中调用),全局配置给认证组件,所有视图类请求,都会进行认证校验,所以请求带了token,就会反解出user对象,在视图类中用request.user就能访问登录的用户

注:登录接口需要做 认证 + 权限 两个局部禁用
"""
#使用jwt的开发流程
    -用户表,写一个登录功能,登录成功,签发一个token,返回给前端
    -以后前端访问,都要携带这个token串
    -进入我的认证类(drf,中间件),取出token,认证,认证通过,反解出当前登录用户
    -以后再视图类中使用,request.user就是当前登录用户

二、使用第三方drf-jwt实现自动签发

(必须是使用或者继承auth表)

继承auth操作
(models,使用User用户继承auth_user表)
from django.contrib.auth.models import AbstractUser
class User(AbstractUser):

settings配置添加下面配置用于继承auth_user表备注
AUTH_USER_MODEL = 'app01.User'

1、drf-jwt安装和简单使用

#1-官网
http://getblimp.github.io/django-rest-framework-jwt/

#2-安装
pip install djangorestframework-simplejwt

#3-简单使用1)先创建管理员用户
    python3 manage.py createsuperuser (也可以通过pycharm使用manage工具直接使用createsupercreate创建超级用户)2)配置路由urls.py
    from django.urls import path
    from rest_framework_jwt.views import obtain_jwt_token
    urlpatterns = [
        path('login/', obtain_jwt_token),
    ]3)postman测试
    http://127.0.0.1:8000/login/
  
  
认证问题:
    class UserTokenView(GenericViewSet, mixins.CreateModelMixin):
        # 必须登陆后才能新增
        authentication_classes = [JWTAuthentication]
        permission_classes = [IsAuthenticated]

2、配置文件介绍

# JWT配置
SIMPLE_JWT = {
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=5),  # Access Token的有效期
    'REFRESH_TOKEN_LIFETIME': timedelta(days=7),  # Refresh Token的有效期
    
    # 对于大部分情况,设置以上两项就可以了,以下为默认配置项目,可根据需要进行调整
    
    # 是否自动刷新Refresh Token
    'ROTATE_REFRESH_TOKENS': False,  
    # 刷新Refresh Token时是否将旧Token加入黑名单,如果设置为False,则旧的刷新令牌仍然可以用于获取新的访问令牌。需要将'rest_framework_simplejwt.token_blacklist'加入到'INSTALLED_APPS'的配置中
    'BLACKLIST_AFTER_ROTATION': False,  
    'ALGORITHM': 'HS256',  # 加密算法
    'SIGNING_KEY': settings.SECRET_KEY,  # 签名密匙,这里使用Django的SECRET_KEY
    # 如为True,则在每次使用访问令牌进行身份验证时,更新用户最后登录时间
    "UPDATE_LAST_LOGIN": False, 
    # 用于验证JWT签名的密钥返回的内容。可以是字符串形式的密钥,也可以是一个字典。
    "VERIFYING_KEY": "",
    "AUDIENCE": None,# JWT中的"Audience"声明,用于指定该JWT的预期接收者。
    "ISSUER": None, # JWT中的"Issuer"声明,用于指定该JWT的发行者。
    "JSON_ENCODER": None, # 用于序列化JWT负载的JSON编码器。默认为Django的JSON编码器。
    "JWK_URL": None, # 包含公钥的URL,用于验证JWT签名。
    "LEEWAY": 0, # 允许的时钟偏差量,以秒为单位。用于在验证JWT的过期时间和生效时间时考虑时钟偏差。
    # 用于指定JWT在HTTP请求头中使用的身份验证方案。默认为"Bearer"
    "AUTH_HEADER_TYPES": ("Bearer",), 
    # 包含JWT的HTTP请求头的名称。默认为"HTTP_AUTHORIZATION"
    "AUTH_HEADER_NAME": "HTTP_AUTHORIZATION", 
     # 用户模型中用作用户ID的字段。默认为"id"。
    "USER_ID_FIELD": "id",
     # JWT负载中包含用户ID的声明。默认为"user_id"。
    "USER_ID_CLAIM": "user_id",
    
    # 用于指定用户身份验证规则的函数或方法。默认使用Django的默认身份验证方法进行身份验证。
    "USER_AUTHENTICATION_RULE": "rest_framework_simplejwt.authentication.default_user_authentication_rule",
    #  用于指定可以使用的令牌类。默认为"rest_framework_simplejwt.tokens.AccessToken"。
    "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",),
    # JWT负载中包含令牌类型的声明。默认为"token_type"。
    "TOKEN_TYPE_CLAIM": "token_type",
    # 用于指定可以使用的用户模型类。默认为"rest_framework_simplejwt.models.TokenUser"。
    "TOKEN_USER_CLASS": "rest_framework_simplejwt.models.TokenUser",
    # JWT负载中包含JWT ID的声明。默认为"jti"。
    "JTI_CLAIM": "jti",
    # 在使用滑动令牌时,JWT负载中包含刷新令牌过期时间的声明。默认为"refresh_exp"。
    "SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp",
    # 滑动令牌的生命周期。默认为5分钟。
    "SLIDING_TOKEN_LIFETIME": timedelta(minutes=5),
    # 滑动令牌可以用于刷新的时间段。默认为1天。
    "SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=1),
    # 用于生成access和刷refresh的序列化器。
    "TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainPairSerializer",
    # 用于刷新访问令牌的序列化器。默认
    "TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSerializer",
    # 用于验证令牌的序列化器。
    "TOKEN_VERIFY_SERIALIZER": "rest_framework_simplejwt.serializers.TokenVerifySerializer",
    # 用于列出或撤销已失效JWT的序列化器。
    "TOKEN_BLACKLIST_SERIALIZER": "rest_framework_simplejwt.serializers.TokenBlacklistSerializer",
    # 用于生成滑动令牌的序列化器。
    "SLIDING_TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainSlidingSerializer",
    # 用于刷新滑动令牌的序列化器。
    "SLIDING_TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSlidingSerializer",
}

# 我们项目中如何配置 settings.py中
from datetime import timedelta
SIMPLE_JWT ={
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=5),  # Access Token的有效期
    'REFRESH_TOKEN_LIFETIME': timedelta(days=7),  # Refresh Token的有效期
    "AUTH_HEADER_TYPES": ("TOKEN",),
}

3、创建userInfo表继承auth_user表自动签发

3.1、在模型表执行上面的继承auth操作即可

继承auth操作
(models,使用UserInfo用户继承auth_user表)
from django.contrib.auth.models import AbstractUser
class UserInfo(AbstractUser):

settings配置添加下面配置用于继承auth_user表备注
继承了auth.user表
AUTH_USER_MODEL = 'app01.UserInfo'

3.2、配置文件settings

使用一部分即可
SIMPLE_JWT = {
  	# 定义了访问令牌(access)的有效期
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=5),
  	# 定义了刷新令牌(token)的有效期
    'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
  	# 定义了HTTP授权头中使用的令牌前缀 认证头信息(Bearer后加一个空格)
  	# 默认情况下,DRF 期望令牌以 "Token" 作为前缀。这里您将其更改为 "Bearer",这是一种常见的做法,也是 OAuth 2.0 标准推荐的前缀。
    'AUTH_HEADER_TYPES': ('Bearer',),
  	# 指定了用于获取令牌的序列化器的路径
    'TOKEN_OBTAIN_SERIALIZER': 'app01.serializer.CommonTokenObtainSerializer',
}

3.3、序列化类-serializer.py

from rest_framework import serializers
class BookSerializer(serializers.ModelSerializer):
    class Meta:
        model = models.Book
        fields = '__all__'



#下面这个是展示的是比较详细的部分
class BookSerializer(serializers.ModelSerializer):
    publish = serializers.CharField(source='publish.name')
    authors = serializers.CharField(source='authors.name')
    class Meta:
        model = Book
        fields = ['id','name','price','publish','authors']
        extra_kwargs={
            # 'publish':{'write_only':True},
            # 'authors':{'write_only':True},
            'publish_detail': {'read_only':True},
            'author_list':{'read_only':True}
        }

4、自定义用户登陆认证返回格式

序列化类文件中写  继承TokenObtainPairSerializer--》重新validata方法   
补充功能: 定制载荷格式(@classmethod下的定义get_token)

# 使用simplejwt 登陆接口
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer

# 这里可以用作登陆获取信息jwt那段数据
class CommonTokenObtainSerializer(TokenObtainPairSerializer):
   # 定制荷载数据   {过期时间、用户id等}   ---加用户名
    @classmethod
    def get_token(cls, user):
        # super()--->代指父类对象--》
        # 对象调用类的绑定方法--->会自动把对象的类传入
        token = super().get_token(user)
        token['username'] = user.username
        return token

    # 重写全局钩子   校验
    def validate(self, attrs):
        # 校验  用户名 密码
        username = attrs.get('username')
        password = attrs.get('password')
        user = auth.authenticate(username=username, password=password)
        if user:
            self.user = user
            token = self.get_token(self.user)  # Token对象,可以当字典用
            # print(token)
            data = {
                'code': 100,
                'msg': 'OK',
                'username': self.user.username,
                'refresh': str(token),
                'access': str(token.access_token),
            }
            return data
        else:
            raise serializers.ValidationError('Unable to log in with provided credentials.')

# 当前情况未做: 也可以在settings.py中配置
# # 由于drf-jwt默认的只返回一个,如果要想返回更多的信心需要自己重写此方法。
# def response_user_login(token, user=None, request=None):
#     print(request.method)
#     return {
#         'status':100,
#         'msg':'登录成功',
#         'username':user.username,
#         'token':token
#     }
settings文件配置
SIMPLE_JWT ={
    'TOKEN_OBTAIN_SERIALIZER':'app01.serializer.CommonTokenObtainSerializer'
}

4.1、视图函数views.py

class BookListView(ModelViewSet): 
    queryset = Book.objects.all()
    serializer_class = BookSerializer
    # 权限和认证管理,使用的是jwt的内置认证,但是需要两者一起使用
    authentication_classes = [JsonAuthentication, ]
    permission_classes = [IsAuthenticated, ]
    def list(self,request,*args,**kwargs):
  			qs = self.filter_queryset(self.get_queryset())
        serializer = self.get_serializer(qs, many=True)
      # 如果只打印默认的结果的话,直接使用
      return super().list(request,*args,**kwargs)
      # 如果自定制返回结果---{'code':xxx,'msg':'打印图书结果见下,'result':serializer.data}
    

4.2、路由层配置

from rest_framework_simplejwt.views import token_obtain_pair
from rest_framework.routers import SimpleRouter

router = SimpleRouter()
router.register('books',BookView,'books')
urlpatterns = [
    path('v1/',include(router.urls)),
    path('login/', token_obtain_pair),  # 自动签发token  #127.0.0.1:8001/app01/login/    post
]

4.3、使用

1、登陆获取token
如果只在项目目录下的urls写了的话,路径只用: http://127.0.0.1:8000/login/即可
如果在应用的路由层下的话,需要考虑层级关系。当前项目的情况:项目名: drf_user_test_1 应用名:app01
路径: http://127.0.0.1:8000/app01/v1/books/

在这里插入图片描述

2、Authorization: 如何使用token
如果是默认的情况下就直接使用
jwt+空格 + '生成的token字符串/或者我这边自定制的access/refresh任意取一个即可'

自定制: settings.py文件自定制jwt格式
from datetime import timedelta
SIMPLE_JWT = {
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=5),
    'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
    'AUTH_HEADER_TYPES': ('Bearer',),
    'TOKEN_OBTAIN_SERIALIZER': 'app01.serializer.CommonTokenObtainSerializer',
}
由于使用了自定制,所以在查的时候需要事先插入Bearer + 空格 + 字符串

全部的规格是
authorization + 上面的格式(如Bearer+空格+jwt字符串)

在这里插入图片描述

5、自定制权限类

新建auth.py(权限类,自定义功能,可以用其他名称代替,但是需要知道其用意)
from rest_framework_simplejwt.authentication import JWTAuthentication
from .models import User
class JWTOurAuth(JWTAuthentication):
    def authenticate(self, request):
        # 取出用户携带的access---》放请求头中:Authorization
        token = request.META.get('HTTP_AUTHORIZATION')
        if token:
            # 校验token--》validated_token 返回的就是可以信任的payload
            validated_token = self.get_validated_token(token)
            user_id = validated_token['user_id']
            user = User.objects.filter(pk=user_id).first()
            return user, token
        else:
            raise AuthenticationFailed('请携带登录信息')
            

            
使用:
# 登陆后才能访问
from .authentication import JWTOurAuth
class PublishView(GenericViewSet):
    authentication_classes = [JWTOurAuth]
    def list(self,request):
        return Response('get')

6、自定义用户表,使用jwt手动签发token

"""
# 自定义用户表,不是扩写auth的user表
# 自己写登陆签发token
# 自己写认证类
"""
# 1- view.py
class UserOurJWTView(GenericViewSet):
    serializer_class = LoginOurJWTSerializer
    @action(methods=['POST'],detail=False)
    def login(self,request,*args,**kwargs):
        serializer=self.get_serializer(data=request.data)
        if serializer.is_valid():
            refresh = serializer.context.get('refresh')
            access = serializer.context.get('access')
            return Response({'code': 100, 'msg': '登录成功', 'refresh': refresh, 'access': access})
        else:
            return Response({'code': 101, 'msg': serializer.errors})

# 2- serializer.py
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from rest_framework_simplejwt.tokens import RefreshToken
from .models import User
class LoginOurJWTSerializer(serializers.Serializer):
    username = serializers.CharField() # 可能是 用户名  手机号  邮箱
    password=serializers.CharField()
    def _get_user(self,attrs):
        # 1 校验用户
        username = attrs.get('username')
        password = attrs.get('password')
        user=User.objects.filter(username=username,password=password).first()

        if user:
            return user
        else:
            raise ValidationError('用户名或密码错误')
    def validate(self, attrs):
        user=self._get_user(attrs)
        token = RefreshToken.for_user(user)
        self.context['access'] = str(token.access_token)
        self.context['refresh'] = str(token)
        return attrs
      
#3 模型类新建用户表 
class User(models.Model):
    username = models.CharField(max_length=32)
    password = models.CharField(max_length=32)
    user_type=models.IntegerField(choices=((1,'注册用户'),(2,'普通管理员'),(3,'超级管理员')),default=1)
    @property
    def is_authenticated(self):
        return True

# 4、路由
router.register('our_jwt', UserOurJWTView, 'our_jwt')
router.register('publish', PublishView, 'publish')

7、多方式登陆。(重要)

# 1 使用auth的user表---》只能传用户名 ,密码校验

# 2 项目中:手机号/用户名/邮箱 + 密码--》也可以登录成功--》simple-jwt就不行了

# 3 自己定制登陆接口--》使用auth的user表
	-签发自己签发
    -认证继续用 simple-jwt的认证即可
    
# 4 编写一个多方式登陆接口
	- 扩写auth的user表---》加入mobile字段
    	-坑:
        	-之前迁移过--》auth的user表已经生成了,就不能扩展写
            -方案一: 创建新项目,从头做
            -方案二:删库,删除迁移记录(咱们自己app和auth和admin)
        	-以后如果要扩写auth的user表,必须在迁移之前就定好,写好
            
    - 编写登陆接口
    

需求:
"""
1- 输入用户名,输入手机号,输入邮箱+密码
2- 使用的是UserInfo表,继承了auth.user表
"""
路由
from app01.views import LoginJWTView
from rest_framework.routers import SimpleRouter
from rest_framework_simplejwt.views import token_obtain_pair

router = SimpleRouter()

# 127.0.0.1:8000/app01/v1/jwt/login/--->post请求
router.register('user',LoginJWTView,'user')
urlpatterns = [
    # # 方案一: 前面可以再加前缀
    path('api/v1/', include(router.urls)),
    path('users/', UserView.as_view()),
    path('login/', token_obtain_pair), 
]
视图文件
class LoginJWTView(GenericViewSet):
    queryset = models.User.objects.all()
    serializer_class = serializer.LoginSerializer

    @action(methods=['post'], detail=False)
    def login(self, request, *args, **kwargs):
        # 正常逻辑:取出手机号/用户名/邮箱+密码--》去数据校验--》校验通过-->签发token--》返回给前端
        # 高级逻辑:使用序列化类做上述逻辑
        
        # context是上下文,是视图类和序列化类沟通的桥梁
        # ser = self.serializer_class(data=request.data,context={'request':request})
        ser = self.serializer_class(data=request.data)
        if ser.is_valid():
            # 校验通过:会把user,access和refresh都放到序列化类对象中--》返回给前端、
            # 现在在视图类中----》有个序列化类--》把视图类中变量给序列化类---》序列化类的变量给视图类--》借助于context给[字典]

            username = ser.context['username']
            access = ser.context['access']
            refresh = ser.context['refresh']
            return Response({'status': 100, 'msg': '登录成功','username': username,'access': access,'refresh': refresh})
        else:
            print(ser.errors)
            return Response({'status': 101, 'msg': '登录失败'})
序列化类文件:
class LoginJWTSerializer(serializers.Serializer):
    username = serializers.CharField() # 可能是 用户名  手机号  邮箱
    password=serializers.CharField()
    def _get_user(self,attrs):
        # 1 校验用户
        username = attrs.get('username')
        password = attrs.get('password')
        # 2 去数据库 查询用户---》username可能是手机号,邮箱,用户名--》查的字段不一样
        # 2.1 正则匹配,是不是手机号
        if re.match(r'^1[3-9][0-9]{9}$', username):
            user = UserInfo.objects.filter(mobile=username).first()
        elif re.match('^.+@.+$', username):
            user = UserInfo.objects.filter(email=username).first()
        else:
            user = UserInfo.objects.filter(username=username).first()

        if user and user.check_password(password):
            return user
        else:
            raise ValidationError('用户名或密码错误')
    def validate(self, attrs):
        # 取出 手机号/用户名/邮箱+密码--》数据库校验--》校验通过签发 access和refresh,放到context中
        user=self._get_user(attrs)
        # 签发token--》通过user对象,签发token
        token = RefreshToken.for_user(user)
        self.context['access'] = str(token.access_token)
        self.context['refresh'] = str(token)
        return attrs  # 不返回不行:因为源码中校验了是否为空--》
  • 3
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值