Garland +

写一个 flask 扩展路由功能的小插件

厂里的几个后台大部分是管理员和商家共用的,而且接口的命名都很混乱,对于资源的增删改查,大部分都是放在查询字符串或者 post 表单里,而且命名也不统一,所以对于同一个资源的每个接口,就会写大量的参数校验,权限校验,不多说,举个例子。

  1. 管理员 /manage/service/admin/api/finish_live.json
  2. 主播 /manage/service/shops/api/finish_live.json

上面是我们后台老的 API 结构,层级基本可以分为 /后台/业务/身份/uri类型/具体行为,对于同一个资源会有很多行为,就拿这个 live 来讲

  1. 获取直播绑定的业务: /live_obj_show.json
  2. 获取推流地址: /live_push_url.json
  3. 结束直播: /live_finish.json
  4. 获取直播弹幕: /live_danmaku.json
  5. 在这个直播禁言用户: /live_forbid_send_msg.json
  6. 获取这个直播被禁言的用户: /live_group_shutted_user.json
  7. 在这个直播解禁用户: /live_unforbid_send_msg.json

详细讲的话还有更多,这时遇到了第一个问题,权限校验,管理员有不同的权限,主播也有不同的权限,有的权限是只读,有的权限是可读可写,这样就写了大量的装饰器,然后每个接口都要加上权限装饰器,这样写很恶心,装饰器太多的话,后面新来的人根本没办法维护,而且最主要的是,新加的接口很容易忘了或者用错了装饰器。

第二个问题是前面说的,资源ID 都是放在查询字符串或者 post 表单里,所以每个接口首先得判断有没有这个资源,然后再判断当前登录的用户有没有对这个资源操作的权限,这样就会写大量的差不多但是又有那么一点点不一样的代码。

最后定义的接口大概是这样的格式 /后台/业务/身份/uri类型/资源/id/行为,具体到一开始的例子就是:

  1. 管理员 /manage/service/admin/api/live/<int:id>/finish.json
  2. 主播 /manage/service/shops/api/live/<int:id>/finish.json

利用 url 的树形结构我们可以画这样一张图

  1. /manage(+validator)
  2. /\
  3. / \
  4. /service1 /service2
  5. /\
  6. / \
  7. /shops/api /admin/api(+validator)
  8. /\
  9. / \ |route('/push_url.json')
  10. /course/<id> /live/<id> --|route('/finish.json')
  11. |route('/danmaku.json')

权限校验可以在各个节点上加校验器,在拼装 path 的时候就做校验,没有权限的直接返回;对于资源的 校验在资源节点就判断资源的有无,当前用户对资源有没有权限,校验失败就直接返回,这样对于具体的 view 函数内就只用处理内部的逻辑。

前前后后大概实现了三种方法,最后用了最后一种

大体思路是,在 flask app 初始化的时候构造出一颗 path 树,每个节点的名称是 path 对应的节点名,上面有各自的校验器。新的请求在 app.before_request 的时候会从上到下一级一级进行校验,失败了就直接返回。

当时是这样的实现:

  1. # coding: utf-8
  2. # /privilege.py
  3. from flask import g
  4. class _Node(object):
  5. def __init__(self, name):
  6. self.name = name
  7. self.validator = None
  8. self.children = {}
  9. class Validators(object):
  10. @classmethod
  11. def privilege_a_required(cls):
  12. if not g.me:
  13. return False
  14. if not g.me.has_privilege('a'):
  15. return False
  16. return True
  17. @classmethod
  18. def privilege_b_required(cls):
  19. if not g.me:
  20. return False
  21. if not g.me.has_privilege('b'):
  22. return False
  23. return True
  24. @classmethod
  25. def privilege_c_required(cls):
  26. if not g.me:
  27. return False
  28. if not g.me.has_privilege('c'):
  29. return False
  30. return True
  31. _paths = {
  32. Validators.privilege_a_required: [
  33. '/manage/service1/god'
  34. ],
  35. Validators.privilege_b_required: [
  36. '/manage/service1/god/api/courses/create.json',
  37. '/manage/service1/god/api/courses/edit.json',
  38. ],
  39. Validators.privilege_c_required: [
  40. '/manage/service1/god/api/courses/show.json',
  41. '/manage/service1/god/api/courses/search.json'
  42. ],
  43. }
  44. def build_tree_from_paths():
  45. root = _Node('manage')
  46. for validator, paths in _paths.items():
  47. for path in paths:
  48. path_dirs = path.lstrip('/').rstrip('/').split('/')
  49. father = root
  50. for idx, dir_name in enumerate(path_dirs):
  51. if dir_name not in father.children:
  52. father.children[dir_name] = _Node(dir_name)
  53. father = father.children[dir_name]
  54. if idx == len(path_dirs) - 1:
  55. father.validator = validator
  56. return root
  57. def validate_path(path):
  58. path_dirs = path.lstrip('/').rstrip('/').split('/')
  59. if path_dirs[0] != g.tree.name:
  60. return True
  61. father = g.tree
  62. for dir_name in path_dirs:
  63. if dir_name not in father.children:
  64. return True
  65. father = father.children[dir_name]
  66. validator = father.validator
  67. if validator is None:
  68. continue
  69. if not validator():
  70. return False
  71. return True

当时在思考这种实现的时候还沿用的是之前的 path 结构。首先定义树的节点,节点名是 path 节点去掉 / 的名称,每个节点上可以绑定一个校验器,校验器的返回值是 True 或者 False,还有它的儿子们。

定义一堆校验器,将校验器和对应使用该校验器的 path 列出来构造成一个 map。这个 map 就是 flask app 初始化的时候构造出的树,校验器可以加在 path 任意一个节点上。使用方式如下

  1. # coding: utf-8
  2. import json
  3. from flask import Flask, request, url_for, abort
  4. from privilege import build_tree_from_paths, validate_path
  5. app = Flask(__name__)
  6. app.tree = build_tree_from_paths()
  7. @app.before_request
  8. def before_request():
  9. # load user
  10. g.tree = app.tree
  11. if not validate_path(request.path):
  12. if request.path.endswith('json'):
  13. return json.dumps({'status': 'error'})
  14. return abort(401)
  15. if __name__ == '__main__':
  16. app.run(debug=True, port=9000)

在 app 初始化的时候 load 一下树,每个请求来的时候在最后回去根据 path 一级一级校验,这样其实是可以丢掉大部分的权限装饰器的。

不过这样还是有一些问题,一个是需要维护一个校验器的 map ,每次新加 view 函数都要去加一条, 这个很容易忘记,忘记了也不好发现;另一个是没有解决上面说的第二个问题。

既然手动构造的话会忘记,所以就有了第二种方法,用 blueprint,blueprint 的好处是很多的, 可以 route 可以 before/after_request,如果它支持 blueprint of blueprint 的话。

你能想到的,别人一定早想过了!

我找了下 Falsk 的 issue,果然发现了一个: Nestable blueprints ,继承了下 Flask 的 Blueprint 加上了自定义的 register_blueprint 方法,可以让 blueprint register blueprint,实现如下:

  1. # coding: utf-8
  2. from flask import Blueprint
  3. class CustomBlueprint(Blueprint):
  4. def register_blueprint(self, blueprint, **options):
  5. def deferred(state):
  6. url_prefix = (state.url_prefix or u"") + (options.get('url_prefix', blueprint.url_prefix) or u"")
  7. if 'url_prefix' in options:
  8. del options['url_prefix']
  9. state.app.register_blueprint(blueprint, url_prefix=url_prefix, **options)
  10. self.record(deferred)

然后可以把定义都放到一起,像下面这样

  1. # coding: utf-8
  2. # route.py
  3. import json
  4. from flask import request, abort, g
  5. from common.utils.render import error
  6. from libs.blueprint import CustomBlueprint
  7. base_bp = CustomBlueprint('manage', __name__)
  8. service1_bp = CustomBlueprint('service1', __name__)
  9. god_api_bp = CustomBlueprint('god_api_bp', __name__)
  10. courses_bp = CustomBlueprint('courses', __name__)
  11. base_bp.register_blueprint(service1_bp, url_prefix='/service1')
  12. service1_bp.register_blueprint(god_api_bp, url_prefix='/god/api')
  13. god_api_bp.register_blueprint(courses_bp, url_prefix='/courses')
  14. @courses_bp.before_request
  15. def privilege():
  16. if not g.me or not g.me.has_privilege('a'):
  17. if request.path.endswith('json'):
  18. return json.dumps({'status': 'error'})
  19. return abort(401)

使用的时候

  1. # coding: utf-8
  2. import json
  3. from flask import Flask, request, url_for, abort
  4. from route import base_bp, courses_bp
  5. app = Flask(__name__)
  6. app.register_blueprint(base_bp, url_prefix='/manage')
  7. @app.before_request
  8. def before_request():
  9. # load user
  10. @courses_bp.route('/show.json')
  11. def service1_god_api_courses_all():
  12. pass
  13. if __name__ == '__main__':
  14. app.run(debug=True, port=9000)

这个方法的好处是,开发新增的资源接口时不用去考虑上层的权限校验,也不会出现忘记的情况,因为不再使用 app.route() 了, 不过老大嫌这种方式会改变对原本的 blueprint 的理解,希望不依赖 flask 的东西做成一个纯插件的形式,所以这个方法也就此作罢

不能用 blueprint 那就只能自己实现以下这个 route 方法了。思路还是从上向下可以一级一级加节点,校验部分参考了 before/after_request 的实现,自己实现的 route 最后还是要传给 flask 的 route 所以需要把 flask 的 app 对象传进去,代码如下:

  1. # coding: utf-8
  2. from functools import wraps
  3. def setupmethod(f):
  4. def wrapper_func(self, *args, **kwargs):
  5. return f(self, *args, **kwargs)
  6. return wrapper_func
  7. class Kotori(object):
  8. def __init__(self, app, prefix=None, father=None):
  9. """
  10. :param: app: flask application object
  11. :param: prefix: path prefix
  12. :param: father: father object of current node
  13. """
  14. self.app = app
  15. self.prefix = prefix or ''
  16. self.father = father
  17. self._path = None
  18. self.before_request_funcs = []
  19. self.after_request_funcs = []
  20. def add_prefix(self, prefix):
  21. """add child path prefix
  22. :param: prefix: node of url path
  23. """
  24. return Kotori(self.app, prefix, self)
  25. @property
  26. def path(self):
  27. """get path of current node"""
  28. if self._path is None:
  29. x = [self.prefix]
  30. k = self
  31. while k.father is not None:
  32. x.append(k.father.prefix)
  33. k = k.father
  34. self._path = ''.join(reversed(x))
  35. return self._path
  36. @setupmethod
  37. def before_request(self, f):
  38. """register a function for current node to run before each request"""
  39. self.before_request_funcs.append(f)
  40. @setupmethod
  41. def after_request(self, f):
  42. """register a function for current node to run after each request"""
  43. self.after_request_funcs.append(f)
  44. def _run_route_func(self, func_attribute_name):
  45. funcs = []
  46. current = self
  47. while current.father is not None:
  48. for f in getattr(current, func_attribute_name, []):
  49. funcs.append(f)
  50. current = current.father
  51. for f in reversed(funcs):
  52. res = f()
  53. if res is not None:
  54. return res
  55. def _run_after_request(self):
  56. return self._run_route_func('after_request_funcs')
  57. def _run_before_request(self):
  58. return self._run_route_func('before_request_funcs')
  59. def route(self, rule, **options):
  60. """a decorator that is used to register a view function for a given end-url-rule,
  61. params are the same as Flask.route
  62. """
  63. rule = self.path + rule
  64. def _(f):
  65. @self.app.route(rule, **options)
  66. @wraps(f)
  67. def deco(*args, **kwargs):
  68. before_request_res = self._run_before_request()
  69. if before_request_res is not None:
  70. return before_request_res
  71. res = f(*args, **kwargs)
  72. after_request_res = self._run_after_request()
  73. if after_request_res is not None:
  74. return after_request_res
  75. return res
  76. return deco
  77. return _

使用方式如下:

  1. # coding: utf-8
  2. import json
  3. from flask import Flask
  4. from kotori import Kotori
  5. app = Flask(__name__)
  6. kotori_app = Kotori(app)
  7. manage_app = kotori_app.add_prefix('/manage')
  8. @app.before_request
  9. def before_request():
  10. # load user
  11. service1 = manage_app.add_prefix('/service1')
  12. god_api = service1.add_prefix('/god/api')
  13. courses = god_api.add_prefix('/courses/<int:id>')
  14. @service1.before_request
  15. def service1_validator():
  16. # do some validation
  17. pass
  18. @courses.before_request
  19. def courses_validator():
  20. # do some validation
  21. pass
  22. @courses.route('/show.json')
  23. def service1_god_api_courses_all():
  24. pass
  25. if __name__ == '__main__':
  26. app.run(debug=True, port=9000)

完全的纯插件形式,从上向下一级一级加节点,可以在每个节点加入多个 hook,完全兼容 flask 的 route 格式, 资源ID放在 url 里可以在具体操作的父节点就进行校验,避免了判断资源是否存在、当前用户对资源操作权限的代码 在每个 view 函数都写一遍,后期也可以对参数校验的部分增加更详细的控制,而且对于 url path 的格式也能规范。

这个模块先是在我们的课程直播后台试了下,也把之前的老代码慢慢迁移过来,去掉了之前的不少痛点,hhh 真好使。

言:
三尺书生,一介微命。

Blog

Thoughts

Project