基于Flask实现的前后端分离API


1.起步与红图

1.新建入口文件

  • ginger目录下新建入口文件ginger.py

  • 实例化Flask对象:

    • ginger目录下新建app的包,再在其包下新建app.py文件
      • Flask对象相关的初始化或操作都放入app.py文件,使项目拥有良好拓展性
      • app=Flask(__name__)
  • 导入项目配置文件到Flask对象:

    • app目录下新建config的包,再在其包下新建secury.py(敏感配置)文件setting.py(通用配置)

    • 把配置文件装载到app.py中:

      app.config.from_object('app.config.setting')
      app.config.from_object('app.config.secure')
      
  • 在入口文件ginger.py中调用app

    app=create_app()

  • 判断当前文件是入口文件,调用app的run方法启动web服务器

    if __name__=='__main__':
        app.run(debug=True)
    
  • 使用postman进行测试:

    • 在GET中输入localhost:5000按回车测试
    • 因为还未编写视图函数,所以返回404

2.蓝图分离视图函数的缺陷

  • 在入口文件ginger.py中新建视图函数get_user

    • 利用app.route()装饰器中传递视图函数get_user的URL:

      @app.route('/v1/user/get')
      def get_user():
          return 'i am kikyo'
      
    • 使用postman进行测试:

      • 在GET中输入localhost:5000//v1/user/get按回车测试
      • 返回i am kikyo
  • 为什么不在入口文件ginger.py中新建视图函数

    • 视图函数较多,写在一个文件中不方便
    • 不同的视图函数有不同的作用对象,大型项目中同一个作用对象可能有很多视图对象,应该分门别类放在不同文件中
  • 所有视图函数都是对api的操作,在app的包下新建api包,在api包下再新建v1

    • v1包下新建user.pybook.py

    • 把视图函数get_user()get_book()拆分到user.pybook.py

      • 注册视图函数的路由时,需要Flask的核心对象app

        • ginger文件中的app直接导入,会导致循环导入

        • 使用蓝图blueprint注册路由

          • 实例化一个Blueprint(),第一个参数传递蓝图名称,第二个参数指定位置信息:book=Blueprint('book',__name__)
          • 使用蓝图下的route装饰器注册路由:@book.route('/v1/book/get')
        • 使蓝图生效

          • 把蓝图注册到核心对象app上

            • app.py文件中定义 register_blueprint( )函数注册

              def register_blueprint(app):
                  from app.api.v1.user import user
                  from app.api.v1.book import book
                  app.register_blueprint(user)
                  app.register_blueprint(book)
              
            • app=create_app()中调用 register_blueprint( )函数调用蓝图

          • 使用使用postman进行测试看蓝图是否能拆分:

            • 在GET中输入localhost:5000//v1/user/get按回车测试
            • 返回i am kikyo
        • 蓝图Blueprint不是用来拆分视图函数,而是一种模块级别的拆分

3.创建自己的红图

  • app下新建一个directory,命名为libs,用于存放自己定义的模块

    • libs下新建redprint.py,定义redprint类

      • book.py下实例化redprint:api=Redprint('book')

      • 创建v1蓝图被所有红图公用,在v1___init__.py下定义蓝图:

        def create_blueprint():
            bp_v1=Blueprint('v1',__name__)
            pass
        
    • 将红图注册到蓝图上:

      • 在蓝图定义函数create_blueprint()下使用user.api.register(bp_v1)
    • 把蓝图注册到核心对象app上

      • app.py文件中定义 register_blueprint( )函数注册

        def register_blueprint(app):
            from app.api.v1 import create_blueprint_v1
            app.register_blueprint(create_blueprint_v1())
        
  • 把视图函数中的v1挂载到蓝图上,把book挂载到红图上

  • 注册蓝图时给其附加一个前缀:app.register_blueprint(create_blueprint_v1(),url_prefix='/v1')

  • 把视图函数中的book挂载到红图上

    • 注册红图时给其附加一个前缀:user.api.register(bp_v1,url_prefix='/user')

4.实现Redprint

  • 传入红图的名字

    • 定义构造函数__init_方法传入名字

      def __init__(self,name):
          self.name=name
      
    • 实现api.route()的装饰器

      • 参考blueprint的route函数,蓝图把视图函数注册到蓝图上,自定义的红图Redprint里也需要视图函数注册到蓝图上

      • blueprint.py中的self是蓝图,但自定义的redprint.py中的self是红图,route中拿不到蓝图,需要先把相关参数先保存起来

        #rule注册的URL,option其他可选选择
        def route(self,rule,**options):
            #f装饰器作用的函数
            def decorator(f):
                self.mound.append((f,rule,options))
                return f
            return decorator
        
    • 将红图注册到蓝图时使用了register方法,所以还需要定义register方法

      • register方法中传入了蓝图参数,在register方法中完成视图函数向蓝图的注册

        def register(self,bp,url_prefix=None):
            for f,rule,options in self.mound:
                endpoint = options.pop("endpoint", f.__name__)
                #蓝图注册到视图函数上
                bp.add_url_rule(rule,endpoint,f,**options)
        

5.优化Redprint

  • 注册红图时url_prefix与Redprint中传入的名字一致,可以省去user.api.register(bp_v1,url_prefix='/user')中的url_prefix,在Redprint中定义好

    def register(self,bp,url_prefix=None):
        if url_prefix is None:
            url_prefix='/'+self.name
    

2.自定义异常对象

1.构建client验证器

  • v1包下新建client.py构建客户端路由create_client()

    • from app.libs.redprint import Redprint
      
      api=Redprint('client')
      
      @api.route('/register')
      def create_client():
          #注册 登陆
          #参数 校验 接受参数
          #WTForms 校验表单
          pass
      
  • libs包下新建enums.py定义客户端不同方式的各种枚举

    • from enum import Enum
      
      #客户端类型
      class ClientTypeEnum(Enum):
          USER_EMAIL=100
          USER_MOBLE=101
      
          #微信小程序
          USER_MINA=200
          #微信公众号
          USER_WX=201
          pass
      
  • app包下新建validators包进行客户端参数校验

    • 新建forms.py使用WTForms 校验表单

      • 定义ClientForm(Form)类对客户端表单验证

        • 验证时(wtforms.validators)账号和登陆类型必须传入(DataRequired)

        • 客户登陆类型,WTForms 表单验证中没有,需要自定义传入的是枚举类型enums.py中的一种

          #自定义客户端类型验证
          def validate_tppe(self,value):
              try:
                  client=ClientTypeEnum(value.data)
              except ValueError as e:
                  raise e
              pass
          

2.处理不同客户端注册的方案

  • client.pycreate_client()视图函数中使用client验证器进行参数的校验

    • 用json获取提交对象
      • 表单的提交对象用于网页中,json对象用于移动端中
    • 用实例化的表单验证类ClientForm(Form)接收获取到的json数据对表单进行验证
    • 定义一个字典promise为不同的客户端编写不同的注册代码
      • 键:登陆的枚举对象ClientTypeEnum.USER_EMAIL
      • 值:该登陆方式下用户注册的函数
    #client.py
    from flask import request
    from app.libs.enums import ClientTypeEnum
    from app.libs.redprint import Redprint
    from app.validators.forms import ClientForm
    
    api=Redprint('client')
    
    @api.route('/register',methods=['POST'])
    def create_client():
        data=request.json#获得客户端参数
        form=ClientForm(data=data)#实例化validators的forms客户端表单验证类
        if form.validate():
            promise={
                ClientTypeEnum.USER_EMAIL:__register_user_by_email,
                ClientTypeEnum.USER_WX: __register_user_by_wx
            }
            #switch不同的客户端编写不同的注册代码
        #request.args.to_dict()
        #表单 json
        #注册 登陆
        #参数 校验 接受参数
        #WTForms 校验表单
        pass
    
    #用户用emil注册的相关代码
    def __register_user_by_email():
        pass
    
    def __register_user_by_wx():
        pass
    

3.创建User模型

  • app下新建一个models包用来存放所有模型文件,新建用户模型文件user.py

    • 使用SQLALchemy定义User模型类

      • 定义id emil,昵称nickname,是否管理员auth,密码_password
        • 密码_password属性在SQLALchemy中没有定义验证的方法,需要自定义
      • 添加注册方法register_by_email()
      #models.user.py
      #User模型,继承自定义的base.py中的Base类
      class User(Base):
          id=Column(Integer,primary_key=True)
          emil=Column(String(24),unique=True,nullable=False)
          nickname=Column(String(24),unique=True)
          auth=Column(SmallInteger,default=1)
          _password=Column('password',String(100))
      
          #把类中定义的实例方法变成类属性
          @property
          def password(self):
              return self._password
      
          #@property对于新式类来说定义的属性是一个只读属性,如果需要可写,则需要一个@属性.setter装饰器装饰该函数
          @password.setter
          def password(self):
              self._password=generate_password_hash(raw)
      
          #注册方法
          @staticmethod#在对象下面再创建对象本身不合理,要用静态方法
          def register_by_email(nickname,account,secret):
              #在数据库中使用auto_commit()方法新增用户
              with db.auto_commit():
                  user=User()
                  user.nickname=nickname
                  user.emil=account
                  user.password=secret
                  db.session.add(user)
          pass
      
  • 使SQLALchemy生效

    • app.py定义register_plugin()方法

      • 导入SQLALchemy的实例化对象db
      • 进行db注册
      • 创建所有数据库的数据表
      #使SQLALcjhemy生效
      def register_plugin(app):
          from app.models.base import db#导入db
          db.init_app(app)#db注册
          #create_all要在app的上下文环境中进行操作
          with app.app_context():
              db.create_all()#创建所有数据库的数据表
          pass
      

4.完成客户端注册

  • 用户注册方法__register_user_by_email()

    • 调用User.py表单模型类中register_by_email()方法完成注册

    • 通过传入表单验证的实例化对象form=ClientForm(data=data),传入注册所需的accountsecret

    • nickname无法直接从form中获取,json中有用户提交的所有数据,有nickname,但是拿到的数据没有通过校验,需要在form.py中新建一个User验证的类

      #用户注册类验证
      class UserEmailForm(ClientForm):
          account = StringField(validators=[
              Email(message='invalidate email')
          ])
          secret = StringField(validators=[
              DataRequired(),
              # password can only include letters , numbers and "_"
              Regexp(r'^[A-Za-z0-9_*&$#@]{6,22}$')
          ])
          nickname = StringField(validators=[DataRequired(),
                                             length(min=2, max=22)])
      
          #验证账号是否已经被注册过
          def validate_account(self, value):
              if User.query.filter_by(email=value.data).first():
                  raise ValidationError()
      
    • User验证的类UserEmailForm接收用户提交的数据request.json进行验证,传入注册表单模型User.register_by_email

      #用户用emil注册的相关代码
      def __register_user_by_email():#从form的验证器中获取注册需要的参数
          #request.json['nickname']
          form=UserEmailForm(data=request.json)
          #验证通过
          if form.validate():
              User.register_by_email(form.nickname.data,form.account.data,form.secret.data)
      
  • 调用注册方法__register_user_by_email()

    • 通过字典拿到__register_user_by_email()
      • form.py中将登陆类型的数字转换成枚举对象,用form.type.data可获取登陆类型的枚举对象,再通过字典[键]获取键值promise[form.type.data]()

5.生成用户数据

  • 创建数据库ginger:CREATE DATABASE ginger;

  • 在配置文件secure.py中用SQLALCHEMY_DATABASE_URI连接数据库,并用SECRET_KEY设置密钥保证会话安全

    #连接数据库
    SQLALCHEMY_DATABASE_URI = 'mysql+cymysql://root:123@127.0.0.1:3306/ginger'
    # 设置密钥,保证会话安全
    SECRET_KEY = '\x8d\x7f\xaf\xc8"a\xa1]c\xba\xcb\x80x\xbc\x97s'
    
  • REST 细节特性:输入输出都要是json格式

  • 使用postman进行测试:

    • 在POST中输入localhost:5000/v1/client/register
    • 在Body中选择raw,选择格式为json
    • 在下方输入json格式的注册数据
    • 按send发送数据,在最底下显示success
  • 查看数据添加

    • 选择数据库use ginger;
    • 显示所有表格show tables;
    • 显示表格所有内容:select * from user;
    • 数据未添加对时无法在数据库中显示

6.自定义异常对象

  • pycharm运行时显示Adress already in use解决方法:

    • sudo lsof -i:5000查看哪些端口被占用
    • 再使用sudo kill (PID)结束进程,释放端口
  • 使用postman进行测试

    • 在下方输入错误的json格式的注册数据

      {"account":"777@qq.com","secret":"1234567","type":99,"nickname":"***"}

    • 按send发送数据,在最底下也能显示success

  • 在pycharm代码左侧点击设置断点进行debug

    • 断点1:client.pyform=ClientForm(data=data)
    • 断点2:client.pyreturn 'sucess'
    • 断点3:forms.py的客户端类型验证try
    • 点击ginger.py的debug,点击run to cursor运行到下一个断点,可以看到枚举类型是99,点击step into my code运行到raise e查看异常显示’99 is not a vaild ClientTypeEnum’,再点击run to cursor运行到下一个断点可以直接运行到return ‘sucess’而并不会在异常处中断。
    • form.validate()异常不会被wtform抛出,只会把异常信息记录在form的error属性中
  • 校验不通过时,手动抛出异常

    • from werkzeug.exceptions import HTTPException进入exceptions.py查看werkzeug自带的异常403 Not found

    • 继承HTTPException自定义异常:在libs下新建error_code.py

      • 创建自定义的error类ClientTypeError(HTTPException)
      • 添加状态码和描述
      #客户端类型错误
      class ClientTypeError(HTTPException):
          #401未授权 403禁止访问 404没有找到资源
          #500服务器产生一个未知的错误
          #200查询成功 201创建、更新成功 204删除成功
          #301 302重定向
          code=400#请求参数错误
          description = (
              "client is invalid"
          )
      
    • 验证form.validate()不通过时raise ClientTypeError()

  • 使用postman进行测试:

    • 在POST中输入localhost:5000/v1/client/register
    • 在Body中选择raw,选择格式为json
    • 在下方输入json格式的错误注册数据
    • 按send发送数据,在最底下选择preview显示自定义的错误描述client is invalid,状态码为400

7.异常返回的标准性

  • 返回信息分类
    • 业务数据信息
    • 操作成功提示信息
    • 错误异常信息

8.自定义json格式的APIException

  • API输入输出数据都必须是json格式,但继承HTTPException自定义的错误只能输出HTML格式的错误信息,需要重写HTTPException

  • libs下新建error.py自定义APIException(HTTPException)类继承HTTPException,对其重写

    • APIException要有些默认的msg(错误信息),error_code(错误码),code(错误状态码)
    • 有机制改变默认值
      • 重写构造函数,改变默认值
        • 用if判断是否传了参数,用传的参数替代默认参数
        • 用super继承父类HTTPException的构造方法
      • 重新get_body函数,改变HTML内容为json格式
        • 字典存储:错误信息,错误码,访问哪个api接口产生的(请求的http动词+’ ‘+当前请求的URL路径(不包括主机名和端口号))
          • 通过一个静态方法(和类本身没有交互)用request.full_path拿到完整路径,再用split去除掉问号后的路径
        • 通过json.dumps(body)把字典格式改成文本信息
      • 重写get_headers函数,使输出的http头是json
        • text/html改为application/json
    from flask import request, json
    from werkzeug.exceptions import HTTPException
    
    class APIException(HTTPException):
        code=500#错误状态码500服务器产生一个未知的错误
        msg='sorry,we have a mistake 😆'
        error_code=999#错误代码,未知错误
    
        #设置构造函数,改变默认值
        #headers是HTTP的头信息
        def __init__(self,msg=None,code=None,error_code=None,headers=None):
            #判断传了参数,用传的参数替代默认参数
            if code:
                self.code=code
            if error_code:
                self.error_code=error_code
            if msg:
                self.msg=msg
            #使用super继承HTTPException的构造方法
            #description是msg,
            super(APIException,self).__init__(msg,None)
    
        #重写get_body
        def get_body(self, environ=None):
            """Get the json body."""
            body=dict(
                msg=self.msg,
                error_code=self.error_code,
                #当前错误信息是访问哪个api接口产生的
                #当前请求的http动词,当前请求的URL路径(不包括主机名和端口号)
                request=request.method+' '+self.get_url_no_param()
            )
            #返回json文本信息
            text=json.dumps(body)
            return text
    
        def get_headers(self, environ=None):
            """Get a list of headers."""
            return [("Content-Type", "application/json; charset=utf-8")]
    
        #静态方法,类和实例化对象都能调用
        #不包含?的URL
        @staticmethod
        def get_url_no_param():
            full_path=str(request.full_path)#拿到完整的url的路径
            #分割?前后,只保留?前的url(?用来过滤信息)
            main_path=full_path.split('?')
            return main_path[0]
    
  • 自定义的客户端错误信息ClientTypeError类改为继承APIException

    • 传递code,error_code,msg参数
  • 使用postman进行测试:

    • 在POST中输入localhost:5000/v1/client/register

    • 在Body中选择raw,选择格式为json

    • 在下方输入json格式的错误注册数据

    • 按send发送数据,在最底下选择pretty显示自定义的错误的json信息,,状态码为400

      {
          "error_code": 1006,
          "msg": "client is invalid",
          "request": "POST /v1/client/register"
      }
      

3.修改WTForms

1.重写WTForms

  • 原来代码Accout、nickname和secret错误时,依旧抛出type_error,不正确

  • 其余的form.validate()时也需要抛出异常

  • 把记录在form的error属性中异常信息抛出:在validators下新建basy.py

    • 定义BaseForm继承Form验证类
    • 查看client.py需要传输验证数据data,需要Form.validate进行数据验证
      • 定义构造方法,通过super()继承Form验证类构造方法,传输数据
      • 通常是重写validate来更改验证,但这里我们希望保留原有验证方法,实现类似于validate方法
        • 通过super()继承Form的validate方法
        • 在验证不通过时,从form的error属性中取出错误信息,抛出通用参数错误异常
        • error_code.py下定义参数异常类
    from wtforms import Form
    from app.libs.error_code import ParameterException
    
    #通用异常验证
    class BaseForm(Form):
        def __init__(self,data):
            super().__init__(data=data)
        '''重写validate方法
        def validate(self):
            pass'''
        #保留原有validate方法,实现类似于validate方法
        def validate_for_api(self):
            valid=super().validate()#实现原有验证方法
            if not valid:
                #使不同错误参数传递不同错误信息,从form errors
                raise ParameterException(msg=self.errors)
    
  • forms.py验证时不再继承原有的Form,而是继承BaseForm

    • forms.py中使用from app.validators.base import BaseForm as Form,去除掉原来的from wtforms import Form
  • client.py中不再使用原来的form.validate()验证,而是用改写后的form.validate_for_api()

2.简化调用

  • 每次用request.json·获取post的内容,再传到clientform中,可以在baseform中就获取request.jso

    class BaseForm(Form):
        def __init__(self):
            data=request.json
            super().__init__(data=data)
    
  • 每次都先实例化form,再一行来form.validate_for_api()验证,可在baseform中返回form(return self),就可改写为一行

  • 注册成功返回的是字符串,要再在error_code.py中定义成功的json格式

3.已知异常和未知异常

  • 注册信息不是json格式时,使用postman测试,返回的是HTML格式

  • 异常类型

    • 可以预知的,已知异常,抛出APIException

    • 未知异常,全局获取异常,统一处理

      • 在入口文件ginger.py中定义函数framework_error(),通过flask装饰器@app.errorhandler(Exception)捕获所有基类异常,返回成已知定义的APIException
        • 异常e是APIException,直接返回e
        • 异常e是HTTPException,转换成APIException所需要的json格式,再返回APIException
        • 异常e是未知异常,调试模式把错误信息全爆出,其他情况爆出定义的have a mistake信息
      #捕获全局异常
      @app.errorhandler(Exception)
      def framework_error(e):
          #e可能是APIException,HTTPException,Exception
          if isinstance(e,APIException):
              return e
          if isinstance(e,HTTPException):
              code=e.code
              msg=e.description
              error_code=1007
              return APIException(msg,code,error_code)
          else:
              #调试模式把错误信息全爆出,其他情况爆出定义的have a mistake信息
              if not app.config['DEBUG']:
                  return ServerError()
              else:
                  raise e
      
  • 在if和else上分别加上断点调式

    • 给错误的type,产生apiexception
    • 将post改为get,产生httpexception
    • client.py中的路由下添加一个未知错误1/0,点击step into到else的未知错误,点击run to coursor运行结束,报未知错误

4.Token与HTTPBasic验证

1.获取token令牌

  • API在验证客户账号密码后,返回一个token到客户端,客户端管理和存储token,下一次访问API,依旧需要携带token

  • Token有有效期,可以标识用户的身份:存储用户的ID号,需要加密

  • v1下新建token.py编写token的视图函数get_token()

    • 实例化validators的forms客户端表单验证类

    • 使用字典记录登陆类型,并通过字典的键获取email类型的账号密码验证函数

      api=Redprint('token')
      #登陆
      @api.route('',methods=['POST'])
      def get_token():
          form = ClientForm().validate_for_api()  #实例化validators的forms客户端表单验证类
          promise = {
              ClientTypeEnum.USER_EMAIL:User.verity
          }
          identify=promise[ClientTypeEnum(form.type.data)](
              form.account.data,
              form.secret.data
          )
      
      • 需要通过过滤查询账号,所以在models.user.py中写验证函数

        • 需要在token.py中调用,且未用到User类中方法,用静态方法,通过query查询not user和not password情况下raise 异常(在error_code.py中定义)
        #获取账号验证的方法
        @staticmethod
        def vertify(email,password):
            user=User.query.filter_by(email=email).first()
            if not user:
                raise NotFound(msg='该用户还未注册')
            if not user.check_password(password):
                raise AuthFailed()
            return {'uid':user.id}
        
        #检查账号密码
        def check_password(self,raw):
            if not self._password:
                return False
            return check_password_hash(self._password,raw)
        
    • 编写生成令牌函数generate_auth_token(uid,ac_type,scope=None,expiration=7200)

      • 传入uid用户ID,ac_type客户端类型,scope权限作用域,expiration过期时间
      • 通过Serializer()实例化一个序列器,设置密钥和有效期
      • 通过.dumps()把字典格式改为字符串格式创建一个json网页
      #生成令牌
      #uid用户ID,ac_type客户端类型,scope权限作用域,expiration过期时间
      def generate_auth_token(uid,ac_type,scope=None,expiration=7200):
          #实例化一个序列化器,SECRET_KEY设置密钥,保证会话安全,expires_in有效期
          s=Serializer(current_app.config['SECRET_KEY'],expires_in=expiration)
          #把字典格式改为字符串格式
          return s.dumps(
              {
                  'uid':uid,
                  'type':ac_type.value
              }
          )
      
    • 调用令牌函数

      • 方便过期时间调整,在配置文件setting.py中写入过期时间TOKEN_EXPIRATION=30*24*3600,再通过current_app.config['TOKEN_EXPIRATION']导入
      • 调用的令牌是字符串格式,API输出都得是json格式,先生成字典格式,再通过jsonify()序列化将字典格式转换成json串
      expiration=current_app.config['TOKEN_EXPIRATION']
      token=generate_auth_token(identify['uid'],
                                form.type.data,
                                None,
                                expiration)
      #返回的数据需要是json格式,原来的是字符串
      t={
          'token':token.decode('ascii')
      }
      #返回序列化的t,http状态码
      return jsonify(t),201
      
  • 用postman测试

    • 输入localhost:5000/v1/token,采用post方式
    • 在raw的json格式下输入登陆需要数据
    • 返回加密的json格式的token数据
      • 报无法获取ClientTypeEnum
        • 检查token.py中promise获取枚举对象时是否代码写错
        • 检查type类型是否写成字符串而非数字格式

2.传输token

  • token用处:做数据删除或修改的接口,不希望每次都输入账号和密码,携带未过期的令牌并验证令牌:是否过期,是否合法
  • 避免在视图函数中写入重复验证代码,在需要令牌保护的视图函数上加上装饰器
    • 在自定义文件libs下新建token_auth.py
      • 用户发送携带有token的http请求访问clent.pyget_user()视图函数,在执行视图函数前调用token_auth.py里自定义的verify_password(account,password)函数,把token取出当做参数传入该函数,在该函数中验证token,合法则访问视图函数
      • 通过实例化flask自带的auth=HTTPBasicAuth,使用@auth.login_required装饰器拦截接口,访问视图函数时跳转到使用@auth.verify_password装饰器的verify_password(account,password)函数
        • 在视图函数的return处,verify_password()函数中间pass处打上断点
        • 采用GET方法,body改为none,输入localhost:5000/v1/user,没有进return断点,点击run to cursor,发现进入verify_password()函数的pass断点,因为未验证,再点击点击run to cursor走完断点,显示Unauthorized Access
      • 通过HTTPBasicAuth获取账号和密码,把账号和密码放到HTTP头
        • verify_password()函数的return True添加断点,在v1.user.py的GET视图函数return添加断点
        • 在postman下选择GET输入localhost:5000/v1/user,把key:Authorization, value:basic 编码的base64格式的账号:密码放到header下进行调试,账号和密码被传输进去,点击run to cursor因return True默认token 被验证跳到下一断点,再点击run to cursor运行完显示i am kikyo
      • 通过HTTPBasicAuth获取token
        • 在postman下选择GET输入localhost:5000/v1/user,在Authorization的type下选择BasicAuth,把先前的token放到username中,token被传输进去

3.验证token

  • token_auth中定义验证token函数verify_auth_token(token)

    • 实例化一个序列化器,SECRET_KEY设置密钥,保证会话安全
    • 通过try解码token,except BadSignature和SignatureExpired验证token是否合法或过期,抛出AuthFailed()异常
    • 获取生成令牌时生成的uid和ac_type.value,返回namedtuple的对象数据
    #验证token
    def verify_auth_token(token):
        s=Serializer(current_app.config['SECRET_KEY'])
        try:
            data=s.loads(token)#解密token
        except BadSignature:#通过特定异常检测token是否合法
            raise AuthFailed(msg='token不合法',error_code=1002)
        except SignatureExpired:#检测token是否过期
            raise AuthFailed(msg='token过期',error_code=1003)
        uid=data['uid']
        ac_type=data['type']
        return User(uid,ac_type,'')
    
  • verify_password(token,password)中调用验证token函数verify_auth_token(token),验证成功返回True

@auth.verify_password
def verify_password(token,password):
    #通过http传递参数
    #header key:value
    #key Authorization
    #value basic base64(kikyo:123456)
    user_info=verify_auth_token(token)
    if not user_info:
        return False
    else:
        g.user=user_info
        return True

4.重写first_or_404与get_or_404

  • get_user(uid)视图函数下通过query获取用户查询不到时用get_or_404会抛出一个异常,但不是json格式,需重写,get_or_404是query下的方法,所以在重写的base.py下重写的Query类下重写

    • 看basequery下怎么写,接受id(ident)作为参数传输进入,通过get(ident)获取参数,不存在抛出error_code.py中定义的NotFound方法
    #ident传入的id号
    def get_or_404(self, ident):
        rv = self.get(ident)
        if not rv:
            raise NotFound()
        return rv
    

5.模型对象与序列化

1.理解序列化时的default函数

  • 直接在视图函数中返回模型对象user用postman测试localhost:5000/v1/user/1会报错,显示它不是一个序列化对象,通常做法是把user中相关数据读取出来,拼装成一个字典,再通过jsonify(dict)把字典序列化,返回回去
  • 打开site-packages中的flsk包下的json模块下的init,找到class JSONEncoder查看default函数传递参数o是什么
    • def default(self, o)函数内部打上断点调试,用postman测试localhost:5000/v1/user/1,点击run to cursor运行到断点,显示o是传入的User 1模型
    • 不是调用jsonify函数就会调用default函数,可以用jsonify(dict)把字典序列化,再用postman测试localhost:5000/v1/user/1会直接显示序列化后的结果{ "age": 18, "nickname": "kikyo” },不会进入断点
    • flask知道怎么去序列化传入的数据结构,不会调用default函数,default函数把不能够序列化的对象,转化成可以序列化的数据结构

2.不完美对象序列化

  • v1.user.py中定义一个简单kikyo类,拥有nickname和age属性,使用jsonify(kikyo())序列化其实例化对象

  • app.py中定义一个新的JSONEncoder类继承flask.json中的JSONEncoder对其重写

    from flask.json import JSONEncoder as _JSONEncoder
    class JSONEncoder(_JSONEncoder):
        def default(self, o):
            pass
    
  • 测试是否调用自定义序列化类

    • 在自定义default函数下pass设置断点,用postman测试localhost:5000/v1/user/1,点击run to cursor运行到断点,会运行到系统自带的JSONEncoder下

    • 重写Flask类,使得Flask调用重写JSONEncoder

      from flask import Flask as _Flask
      class Flask(_Flask):
          json_encoder = JSONEncoder
      
    • 再次用postman测试localhost:5000/v1/user/1,点击run to cursor运行到断点,会运行到自定义的JSONEncoder下

  • 把对象转换成字典

    • 使用对象的内置属o.__dict__并在此处设置断点,用postman测试localhost:5000/v1/user/1,dict中没有传入nickname和age是空的,因为类对象不会被传入__dict__中,实例对象才会

    • kikyo类中新定义一个实例化属性

      class Kikyo():
          name='kikyo'
          age=18
      
          def __init__(self):
              self.gender='female'
      
    • 再次用postman测试localhost:5000/v1/user/1,gender对象被传入__dict__中

3.深入理解dict机制

  • __dict__只能获得实例化对象的属性,想获得对象的全部属性

    • 将实例化的kikyo()传入生成的dict对象中,会调用keys()函数调用其键,想要通过dict[‘键’]的方式获得类的键值,需要重写__getitem(self,key)__

      class Kikyo():
          name='kikyo'
          age=18
      
          def __init__(self):
              self.gender='female'
      
          def keys(self):
              return ('name','age','gender')
      
          def __getitem__(self, item):
              return getattr(self,item)
      
      k=Kikyo()
      print(dict(k))#{'name': 'kikyo', 'age': 18, 'gender': 'female'}
      

4.序列化SQLALchemy模型

  • return jsonify(user)处打上断点,用postman测试localhost:5000/v1/user/1,查看user模型下有哪些对象,对象很多,但我们只需要传入我们需要的,因此在models/user.py中定义keys和__getitem__函数

    def keys(self):
        return ['id','email','nickname','auth']
    
    def __getitem__(self, item):
        return getattr(self,item)
    
  • 再次用postman测试localhost:5000/v1/user/1,成功返回序列化对象

    {
        "auth": 1,
        "email": "777@qq.com",
        "id": 1,
        "nickname": "kikyo"
    }
    

5.完善序列化

  • 每个模型类都要被序列化,都要写keys和__getitem__方法,可以把一些公共的方法__getitem__写到基类base中去

  • app.py中重写的JSONEncoder下的default函数过于理想化,只考虑到对象同时含有key和value的情况,需要先用hasattr()做一个判断

    class JSONEncoder(_JSONEncoder):
        def default(self, o):
            if hasattr(o,'keys') and hasattr(o,'__gtitem__'):
                return dict(o)
            raise ServerError()
    
  • JSONEncoder下的default函数是调用可迭代对象,对与datetime类型是不可迭代的,所以要将其改成可迭代对象strftime()

    • 查看是否是可迭代对象:
    from collections import Iterable
    from datetime import datetime
    isinstance(datetime.now,Iterable)#False
    isinstance(datetime.now().strftime('%Y-%m-%d %H:%M:%S'),Iterable)#True
    
    • 改为可迭代对象
    from datetime import datetime
    
    #重写JSONEncoder中的default函数
    class JSONEncoder(_JSONEncoder):
        def default(self, o):
            if hasattr(o,'keys') and hasattr(o,'__getitem__'):
                return dict(o)
            if isinstance(o,datetime):
                return o.strftime('%Y-%m-%d %H:%M:%S')
            raise ServerError()
    
    • 再次用postman测试localhost:5000/v1/user/1,成功返回带时间的序列化对象
    {
        "addtime": "2020-11-20 11:27:38",
        "auth": 1,
        "email": "777@qq.com",
        "id": 1,
        "nickname": "kikyo"
    }
    
  • app.py下经常会变的放入__init_.py

6.View_models对API有意义么

  • 为视图层提供个性化视图模型
  • SQLALChemy是原始的数据模型,和数据库格式保持一致
  • 前端所需要的数据格式不一定和数据库的一样
    • 如auth为1,2在前端希望显示普通用户和管理员
    • 需要把数据库中1,2转换成前端需要格式,需要在视图函数中写伪代码user.auth,根据视图函数要求将其转换成其他格式,污染视图函数层,不利于复用
    • 可根据前端要求编写很多个View_models,在对应的视图函数中实例化相应的View_models,将原始数据模型传到View_models中,View_models进行转换,实现代码复用

6.scope权限控制

1.删除模型注意事项

  • 删除用户模型不需要显示用户模型的各项信息,只需要返回成功删除的信息,不是和登陆一样返回序列化对象
  • 传入可变参数用户uid,用户提交数据后通过query查询出相关数据删除掉,删除成功后返回在error_code.py中定义的删除成功信息。
@api.route('/<int:uid>',methods=['DELETE'])
@auth.login_required
def delete_user(uid):
    with db.auto_commit():
        user=User.query.get_or_404(uid)
        user.delete()
    return DeleteSuccess()
  • 采用软删除,并不在物理层删除,只是更改了status的状态,在base.py中采用
def delete(self):
    self.status = 0
  • 用postman在delete模式下测试localhost:5000/v1/user/1,此时同样需要token令牌,不返回东西,因为定义的删除状态码204是not content,把状态码改为202成功返回删除信息
{
    "error_code": -1,
    "msg": "成功删除",
    "request": "DELETE /v1/user/1"
}
  • 已经显示删除成功,但再次用postman测试,依旧能删除成功,因为get_or_404只会查询物理真实删除的,可以改成basy.py中特殊处理过的filter_by查询,只有status为1的数据才会被查询出来

    user=User.query.filter_by(id=uid).first_or_404()

  • 再次用postman在delete模式下测试localhost:5000/v1/user/1,会显示找不到该资源

{
    "error_code": 1001,
    "msg": "对不起,资源没有找到",
    "request": "DELETE /v1/user/1"
}

2.g变量中读取uid防止超权

  • 1号用户携带令牌token删除2号用户资料,会造成超权

  • 不能让用户任意的指定uid,但删除某个用户又需要知道该用户uid,可以直接在token中传递uid,而不是在视图函数中传递uid(可能1号用户传递2号的uid)

  • 通过flask中的g变量获取uid

    @api.route('',methods=['DELETE'])
    @auth.login_required
    def delete_user():
        uid=g.user.uid
    
    • g变量是专门保存用户的数据,在一次请求中的所有代码的地方,都是可以使用的,是flask程序全局的一个临时变量,充当中间者的媒介的作用
    • g变量和session的区别:session是可以跨request的,只有session还未失效,不同的request请求会获取到同一个session,g对象不需要管过期的时间,请求一次g对象就改变一次
    • 写user时在token_auth.py中用的是nametuple,是一个对象,user也是一个对象,用user.uid就能拿到uid,不然需要用字典的格式user[‘uid’]
  • 同一个时刻有两个用户同时访问这个delete接口,g是线程隔离,不会发生数据错乱,线程号不同,g变量指向的请求是不同的

  • 把数据库中uid为1的用户status改回1,用postman在delete模式下测试localhost:5000/v1/user测试,成功返回删除信息(注意视图函数中不用带/,带/时测试必须在末尾加/,不然报错,flask机制是可多写/,不可少写)

3.生成超级管理员账号

  • 管理员用户可以获得带uid的视图函数,非管理员用户不可以访问管理员用户的视图函数
  • 生成超级用户可以直接在数据库中添加一个auth为2的账户,但密码是加密的,不好直接添加,可以注册一个普通用户,把auth改成2,也可以写一个离线的包fake.py
from app import create_app
from app.models.base import db
from app.models.user import User

app = create_app()#调用app
with app.app_context():#在上下文环境中进行操作
    with db.auto_commit():
        # 创建一个超级管理员
        user = User()
        user.nickname = 'Super'
        user.password = '123456'
        user.email = '666@qq.com'
        user.auth = 2
        db.session.add(user)
  • 不需要通过接口的方式去访问,在本地运行fake.py就可以创建超级用户

4.权限管理方案

  • 生成令牌时把用户是否是管理员的信息携带进去来判断是否是管理员,生成token前会先进行账号验证,可以把auth信息放到models/user.py的账号验证中
#获取账号验证的方法
@staticmethod
def verify(email,password):
    user=User.query.filter_by(email=email).first_or_404()
    if not user.check_password(password):
        raise AuthFailed()
    is_admin=True if user.auth==2 else False
    return {'uid':user.id,'is_admin':is_admin}
  • token.py调用的token的None改成is_admin
token=generate_auth_token(identify['uid'],
                          form.type.data,
                          identify['is_admin'],
                          expiration)
  • 在生成令牌时生成is_admin参数
def generate_auth_token(uid,ac_type,is_admin=None,expiration=7200):
    #实例化一个序列化器,SECRET_KEY设置密钥,保证会话安全,expires_in有效期
    s=Serializer(current_app.config['SECRET_KEY'],expires_in=expiration)
    #把字典格式改为字符串格式
    return s.dumps(
        {
            'uid':uid,
            'type':ac_type.value,
            'is_admin':is_admin
        }
  • token_auth.py验证token后读取到的is_admin放到生成的user的namedtuple对象中
User=namedtuple('User',['uid','ac_type','is_admin'])

#验证token
def verify_auth_token(token):
    uid=data['uid']
    ac_type=data['type']
    is_admin=data['is_admin']
    return User(uid,ac_type,is_admin)
  • 在超级管理员的视图函数下通过g变量获取token中的is_admin,通过if语句来判断是否是管理员
#管理员
@api.route('/<int:uid>',methods=['GET'])
@auth.login_required
def super_get_user(uid):
    #url不应该包含动词
    is_admin=g.user.is_admin
    if not is_admin:
        raise AuthFailed()
    user=User.query.filter_by(id=uid).first_or_404()
    return jsonify(user)

5.比较好的权限管理方案

  • 多种管理员权限情况下不适用前面权限管理方案
  • 可以把每种管理员拥有的权限分别写到不同的表中,通过token 获得管理员的种类,再查表得知拥有什么权限,而不是用简单的if语句,表可以写在MySQL,Redis和代码中
    • 查询比较频繁,写在配置文件中可减少MySQL负担
    • 可以在进入相应视图函数之前,判断某个携带token请求是否有访问某个接口的权利
  • 同样把auth信息放到models/user.py的账号验证中,其余地方也同不太好的方法的一样
#获取账号验证的方法
@staticmethod
def verify(email,password):
    user=User.query.filter_by(email=email).first_or_404()
    if not user.check_password(password):
        raise AuthFailed()
    scope='AdminScope' if user.auth==2 else 'UserScope'
    return {'uid':user.id,'scope':scope}
  • 在自定义的libs包下新建scope放权限作用域表

    • 定义管理员权限类AdminScope定义用户限类UserScope
    • 定义各种权限下能进入的视图函数
    • 定义一个判断某个权限下能访问的视图函数
    #判断某个scope是否能访问某个视图函数
    def is_in_scope(scope,endpoint):
        if endpoint in scope.allow_api:
            return True
        else:
            return False
    
  • 为了实现在进入相应视图函数之前,判断某个携带token请求是否有访问某个接口的权利,可以在token验证函数下,先验证当前权限,再调用判断某个权限下能访问的视图函数is_in_scope(scope,request.endpoint)#request当前请求要访问的视图函数,验证当前权限是否能访问,当前访问的视图函数,不能抛出异常。

  • 因为在token_auth函数下已经对该权限下能否访问视图函数做了判断,所以user.py下管理员用户对权限的判断可以删除

    scope=g.user.scope
    if not scope:
        raise AuthFailed()
    
  • 用postman测试

    • 输入localhost:5000/v1/token,采用post方式

    • 在raw的json格式下输入登陆需要数据{"account":"777@qq.com","secret":"123456","type":100}生成普通用户的token

    • 在raw的json格式下输入登陆需要数据{"account”:"666@qq.com","secret":"123456","type":100}生成管理员用户用户的token

    • 输入localhost:5000/v1/user/1,采用get方式

      • 在aurthorization下先采用普通用户的token
      • token_auth.py的验证token的s=Serializer(current_app.config['SECRET_KEY'])打上断点,运行报错'str' object has no attribute 'allow_api'
      • 因为scope只是一个字符串,无法scope.allow_api,需要通过类的名字AdminScope得到类的对象AdminScope,可以通过globals()来实现
      • 查看globals()是什么,在globals()上打上断点运行,发现是一个字典,可以把当前模块下所有变量都变成一个字典
      def is_in_scope(scope,endpoint):
          scope=globals()[scope]()#将字符串的类对象实例化
          if endpoint in scope.allow_api:
              return True
          else:
              return False
      
    • 再次用postman验证普通用户,抛出禁止访问的异常

    {
        "error_code": 1004,
        "msg": "禁止访问,您没有该类权限",
        "request": "GET /v1/user/1"
    }
    
    • 再次用postman验证管理员用户,判断某个scope是否能访问某个视图函数依旧return false,发现endpoint是v1.super_get_user(视图函数注册在蓝图上,带了蓝图的前缀,注册在app上就不带),但我们定义时定义的可访问视图函数是allow_api=['super_get_user’],需要修改为v1.super_get_user,成功返回
    {
        "addtime": "2020-11-20 11:27:38",
        "auth": 1,
        "email": "777@qq.com",
        "id": 1,
        "nickname": "kikyo"
    }
    

6.scope优化

  • scope.py不同的管理者所能访问的视图函数,可能存在大量重复,可使用函数实现权限相加
#视图函数权限相加
def __init__(self):
    self.add(UserScope())
def add(self, other):
    self.allow_api+=other.allow_api
  • add函数由于没有返回任何东西,是None,所以调用self.add(UserScope())返回的是None,无法再使用.add()增加权限,可以通过在add函数返回self实现
  • 希望其他管理者也能使用add函数添加权限,将add函数改成一个基类,需要的类对其继承
#添加权限
class scope:
    def add(self, other):
        self.allow_api+=other.allow_api
        return self
  • 可以直接把add函数改成重载运算符__add___,权限相加时就不用self.add(UserScope()),而是self+UserScope()
  • 多个权限相加时可能造成重复加,可使用set去重
#添加权限
class scope:
    def __add__(self, other):
        allow_api=[]#视图函数
        self.allow_api+=other.allow_api
        #去重
        self.allow_api=list(set(self.allow_api))
        return self
  • 某个管理员支持user模块下所有视图函数,不太适用于一个个添加,而是查看是否在该模块列表下,因此要在管理员类下添加能访问的模块列表allow_module=['v1.user’],在验证某个管理员的函数下添加模块验证

    if red_name in scope.allow_module:#某个模块名是否在用户权限模块名下

    • 模块名通过拿红图名,修改redprint.py中endpoint,不仅拿到视图函数名,还要拿到红图名endpoint = self.name+'+'+options.pop("endpoint", f.__name__)#self.name当前红图名

    • 通过split分割endpoint得到red_name`

      splits=endpoint.split('+')
      red_name=splits[0]
      
  • 去除掉管理员用户可以访问的视图函数,使用模块列表,再次用postman验证管理员用户,成功返回

    {
        "addtime": "2020-11-20 11:27:38",
        "auth": 1,
        "email": "777@qq.com",
        "id": 1,
        "nickname": "kikyo"
    }
    
  • 想要A支持除B 中几个视图函数以外的其他视图函数,可以在A的构造函数中将B的所有权限加进来,然后列个禁止列表,在判断权限函数中,在endpoint在禁止列表中时,return false,就能实现

#用户权限
class UserScope:
    forbidden=['v1.user+super_get_user','v1.user+super_delete_user']
    def __init__(self):
        self+AdminScope()
        
#判断某个scope是否能访问某个视图函数
def is_in_scope(scope,endpoint):
    scope=globals()[scope]()#将字符串的类对象实例化
    if endpoint in scope.forbidden:
        return False
posted @ 2020-12-14 14:01  一路向暖  阅读(1846)  评论(10编辑  收藏  举报