Python Flask 实现移动端应用接口(API)
引言
目前,Web 应用已形成一种趋势:业务逻辑被越来越多地移到客户端,逐渐完善为一种称为富互联网应用(RIA,rich Internet application)的架构。在 RIA 中,服务器的主要功能 (有时是唯一功能)是为客户端提供数据存取服务。在这种模式中,服务器变成了 Web 服务或应用编程接口(API,application programming interface)。
Flask 是开发 REST架构(RIA 采用的一种与 Web 服务通信的协议) Web 服务的理想框架,因为 Flask 天生轻量。本文将实际操作,实现一个简单的API。
一、项目简介
使用Flask实现一个接口(API),提供给移动端(iOS应用)调用,实现首页数据获取。同时展示了一种较为通用的项目架构及目录结构。
- 本文客户端iOS代码不做详细说明。
- Flask部署不做阐述,如需要,可参考之前的文章:Python Flask Web 框架入门。
- 接口功能只是最基本的实现,很多功能需要在真实项目中进行完善:包括身份验证、全量的错误处理、缓存与备份、负载与并发、复杂的数据库操作、数据库迁移、日志、版本迭代管理等等。
- 服务端部署只是使用到Flask自带的Web服务器。
- 客户端页面如下,首页接口返回数据包括:轮播图(两个条目)+下方三个分组(每个分组4个条目)
二、环境准备
1、服务端
- python包 :python(3.7)、pip、虚拟环境(virtualenv)、Flask、flask-sqlalchemy、pymysql
- 其他:CentOS7 ECS服务器(本地测试也可以)、MySQL数据库、Git、
2、其他端
- 本地开发:Mac、Pycharm、同上的python环境、Navicat(连接数据库)、Git、Postman(接口测试)
- 客户端:xcode编写iOS客户端
3、虚拟环境和库
- 如何创建虚拟环境不做介绍了
- 在Pycharm中使用已经存在的虚拟环境(从Pycharm偏好设置进入)
- 在Pycharm中添加库
三、项目步骤及核心代码
项目目录结构总览(请分清层次)
- 使用tree命令查看
- Pycharm中查看
(1)app文件夹为业务代码的存放处,包括视图+模型+静态文件,也叫做应用包。
(2)static、templates、migrations、tests 本文中没有使用到,可跳过。
(3)config.py 和 manage.py是启动应用和配置应用的关键。
(4)requirements.txt 里面存放当前环境使用到的库,当我们将项目迁移到别的服务器(环境)时,可以通过这个文件,快速导入依赖的所有库。
pip3 freeze -l > requirements.txt #导出 pip3 install -r requirements.txt #导入
① 从 manage.py 开始
1 # 启动程序 2 from app import create_app 3 4 """ 5 development: 开发环境 6 production: 生产环境 7 testing: 测试环境 8 default: 默认环境 9 10 """ 11 # 通过传入当前的开发环境,创建应用实例,不同的开发环境配置有不同的config。这个参数也可以从环境变量中获取 12 app = create_app('development') 13 14 if __name__ == '__main__': 15 # flask内部自带的web服务器,只可以在测试时使用 16 # 应用启动后,在9001端口监听所有地址的请求,同时根据配置文件中的DEBUG字段,设置flask是否开启debug 17 app.run(host='0.0.0.0', port=9001, debug=app.config['DEBUG'])
(1)每个flask项目,必须有一个应用实例。这里把实例的创建,推迟到了init中定义的create_app方法(工厂函数)。这样做,可以动态修改配置,给脚本配置应用“留出时间”,还能够创建多个应用,单元测试时也很有用。
(2)关于debug:在这个模式下,开发服务器默认会加载两个便利的工具:重载器和调试器。
- 启用重载器后,Flask 会监视项目中的所有源码文件,发现变动时自动重启服务器。在开 发过程中运行启动重载器的服务器特别方便,因为每次修改并保存源码文件后,服务器都 会自动重启,让改动生效。
- 调试器是一个基于 Web 的工具,当应用抛出未处理的异常时,它会出现在浏览器中。此时,Web 浏览器变成一个交互式栈跟踪。(本文中,没有用到调试器)
(3)from app import create_app ,会去app模块中,找去__init__.py ,将其中的对应内容引用进来。
② app模块中 __init__.py
from flask_sqlalchemy import SQLAlchemy from flask import Flask from config import config # 创建数据库 db = SQLAlchemy() def create_app(config_name): # 初始化 app = Flask(__name__) # 导致指定的配置对象:创建app时,传入环境的名称 app.config.from_object(config[config_name]) # 初始化扩展(数据库) db.init_app(app) # 创建数据库表 create_tables(app) # 注册所有蓝本 regist_blueprints(app) return app def regist_blueprints(app): # 导入蓝本对象 # 方式一 from app.api import api # 方式二:这样,就不用在app/api/__init__.py(创建蓝本时)里面的最下方单独引入各个视图模块了 # from app.api.views import api # from app.api.errors import api # 注册api蓝本,url_prefix为所有路由默认加上的前缀 app.register_blueprint(api, url_prefix='/api') def create_tables(app): """ 根据模型,创建表格(可以有两种写法) 1、模型必须在create_all方法之前导入,模型类声明后会注册到db.Model.metadata.tables属性中 不导入模型模块,就不会执行模型中的代码,也就无法完成注册。 2、但是,如果db是在模型模块中创建的,同时在此处 from app.models import db 引用db,则就实现了 模型和数据库的绑定,不需要再单独导入模型模块了。 """ from app.models import Video db.create_all(app=app)
(1)创建应用实例,并且导入config.py文件,来配置app。
(2)创建数据库实例,然后一定要在create_app中初始化db.init_app(就是和app关联起来)。
(3)创建数据库表:先创建模型类(在models.py中),然后通过ORM(flask_sqlalchemy)映射为数据库中的表。如上面代码注释所说,一定注意导入模型的时机。
(4)注册蓝本,此处我们使用的蓝本名称是 api,蓝本实例的创建在api模块的__init_.py 中。
(5)关于蓝本的补充:
- 将视图方法模块化,既当大量的视图函数放在一个文件中,很明显是不合适的,最好的方案是根据功能将路由合理的划分到不同的文件中。
-
转换成应用工厂函数的操作(通过create_app创建应用实例)让定义路由变复杂了,现在应用在运行时创建,只有调用create_app() 之后才能使用 app.route 装饰器,这时定义路由就太晚了。使用蓝本,在蓝本中定义的路由处于休眠状态,直到蓝本注册到应用上之后,它们才真正成为应用的一部分。
③ api蓝本模块中的 __init__.py
from flask import Blueprint # 两个参数分别指定蓝本的名字、蓝本所在的包或模块 api = Blueprint('api', __name__) """ 导入路由模块、错误处理模块,将其和蓝本关联起来 1、应用的路由保存在包里的 views.py 和 errors.py 模块中 2、导入这两个模块就能把路由与蓝本关联起来 3、注意,这些模块在 app/__init__.py 脚本的末尾导入,原因是: 为了避免循环导入依赖,因为在 app/views.py 中还要导入api蓝本,所以除非循环引用出现在定义 api 之后,否则会致使导入出错。 """ from app.api import views, error
④ 配置文件 config.py
1 # 配置环境的基类 2 class Config(object): 3 4 # 每次请求结束后,自动提交数据库中的变动,该字段在flask-sqlalchemy 2.0之后已经被删除了(有bug) 5 SQLALCHEMY_COMMIT_ON_TEARDOWN = True 6 7 # 2.0之后新加字段,flask-sqlalchemy 将会追踪对象的修改并且发送信号。 8 # 这需要额外的内存,如果不必要的可以禁用它。 9 # 注意,如果不手动赋值,可能在服务器控制台出现警告 10 SQLALCHEMY_TRACK_MODIFICATIONS = False 11 12 # 数据库操作时是否显示原始SQL语句,一般都是打开的,因为后台要日志 13 SQLALCHEMY_ECHO = True 14 15 16 # 开发环境的配置 17 class DevelopmentConfig(Config): 18 """ 19 配置文件中的所有的账号密码等敏感信息,应该避免出现在代码中,可以采用从环境变量中引用的方式,比如: 20 username = os.environ.get('MYSQL_USER_NAME') 21 password = os.environ.get('MYSQL_USER_PASSWORD') 22 23 本文为了便于理解,将用户信息直接写入了代码里 24 25 """ 26 DEBUG = True 27 # 数据库URI 28 SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://root:123456@172.17.180.2/cleven_development' 29 30 # 也可如下来写,比较清晰 31 # SQLALCHEMY_DATABASE_URI = "mysql+pymysql://{username}:{password}@{hostname}/{databasename}".format(username="xxxx", password="123456", hostname="172.17.180.2", databasename="cleven_development") 32 33 34 # 测试环境的配置 35 class TestingConfig(Config): 36 37 TESTING = True 38 SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://root:123456@172.17.180.3:3306/cleven_test' 39 40 41 """ 42 测试环境也可以使用sqlite,默认指定为一个内存中的数据库,因为测试运行结束后无需保留任何数据 43 也可使用 'sqlite://' + os.path.join(basedir, 'data.sqlite') ,指定完整默认数据库路径 44 """ 45 # import os 46 # basedir = os.path.abspath(os.path.dirname(__file__)) 47 # SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL') or 'sqlite://' 48 49 50 # 生产环境的配置 51 class ProductionConfig(Config): 52 53 SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://root:123456@172.17.180.4:3306/cleven_production' 54 55 56 # 初始化app实例时对应的开发环境声明 57 config = { 58 'development': DevelopmentConfig, 59 'production': ProductionConfig, 60 'testing': TestingConfig, 61 'default': DevelopmentConfig 62 }
(1)给配置文件设置一个基类,让不同的配置环境,继承自他。
(2)关于 flask-sqlalchemy 的一些配置选项列表,不在这里展开了介绍了。
(3)配置文件中可以写入其他各种配置信息,比如以后使用到的 redis、MongoDB,甚至一些业务代码中使用到的配置相关的“常量”也可以定义在这里(注意代码的整洁)。
⑤ 模型文件 models.py
1 from app import db 2 from flask import abort 3 4 class Video(db.Model): 5 """ 6 视频 Model 7 """ 8 __tablename__ = 'videos' 9 # 主键 10 id = db.Column(db.Integer, primary_key=True) 11 # 视频id 12 vid = db.Column(db.String(50)) 13 # 封面图片 14 coverUrl = db.Column(db.Text) 15 # 详情描述 16 desc = db.Column(db.Text) 17 # 概要 18 synopsis = db.Column(db.Text) 19 # 标题 20 title = db.Column(db.String(100)) 21 # 发布时间 22 updateTime = db.Column(db.Integer) 23 # 主题 24 theme = db.Column(db.String(10)) 25 # 是否已删除?(逻辑) 26 isDelete = db.Column(db.Boolean, default=False) 27 28 def to_json(self): 29 """ 30 完成Video数据模型到JSON格式化的序列化字典转换 31 """ 32 json_blog = { 33 'id': self.vid, 34 'coverUrl': self.coverUrl, 35 'desc': self.desc, 36 'synopsis': self.synopsis, 37 'title': self.title, 38 'updateTime': self.updateTime 39 } 40 return json_video
(1)本文中使用的是“视频”模型,相应表的字段已经声明
(2)关于 flask-sqlalchemy 的模型属性类型
(3)常用 SQLAlchemy 列选项
(4)补充:常用 SQLAlchemy 关系选项(本文并没有使用到,可以跳过)
此处可参阅:flask-sqlalchemy用法详解
⑥ 业务的核心视图函数 views.py
1 from flask import make_response, jsonify 2 from app.api import api 3 from app.models import getHomepageData 4 5 @api.route('/v1.0/homePage/', methods=['GET', 'POST']) 6 def homepage(): 7 """ 8 上面 /v1.0/homePage/ 定义的url最后带上"/": 9 1、如果接收到的请求url没有带"/",则会自动补上,同时响应视图函数 10 2、如果/v1.0/homePage/这条路由的结尾没有带"/",则接收到的请求里也不能以"/"结尾,否则无法响应 11 """ 12 response = jsonify(code=200, 13 msg="success", 14 data=getHomepageData()) 15 16 return response 17 # 也可以使用 make_response 生成指定状态码的响应 18 # return make_response(response, 200) 19
(1)这个视图,包含一个路由:获取ios应用首页的数据。
(2)getHomepageData 方法是在models.py中定义的一个函数,用来查询首页数据。
⑦ 在models.py里添加查询函数
from app import db from flask import abort class Video(db.Model): """ 视频 Model """ __tablename__ = 'videos' # 主键 id = db.Column(db.Integer, primary_key=True) # 视频id vid = db.Column(db.String(50)) # 封面图片 coverUrl = db.Column(db.Text) # 详情描述 desc = db.Column(db.Text) # 概要 synopsis = db.Column(db.Text) # 标题 title = db.Column(db.String(100)) # 发布时间 updateTime = db.Column(db.Integer) # 主题 theme = db.Column(db.String(10)) # 是否已删除?(逻辑) isDelete = db.Column(db.Boolean, default=False) def to_json(self): """ 完成Video数据模型到JSON格式化的序列化字典转换 """ json_blog = { 'id': self.vid, 'coverUrl': self.coverUrl, 'desc': self.desc, 'synopsis': self.synopsis, 'title': self.title, 'updateTime': self.updateTime } return json_blog def getHomepageData(): result = {} # 获取banner banners = Video.query.filter_by(theme='banner') result['banner'] = [banner.to_json() for banner in banners] # 获取homepage first = Video.query.filter_by(theme='hot').all() second = Video.query.filter_by(theme='dramatic').all() third = Video.query.filter_by(theme='idol').all() if len(first) and len(second) and len(third): homepage = [{'Hot Broadcast': [item.to_json() for item in first]}, {'Dramatic Theater': [item.to_json() for item in second]}, {'Idol Theatre': [item.to_json() for item in third]}] result['homepage'] = homepage return result else: abort(404)
(1)上面使用到了flask_sqlalchemy的数据库查询方法,模型类.query即可查询模型对应的表。关于查询的其他常用操作符,只做简单介绍:
(2)abort(404)将请求阻断,并响应flask的errorhandler,在errors.py中实现了errorhandler装饰器装饰的响应函数。回顾一下,errors.py模块,也是在蓝本api中注册过的,所以可以响应abort抛出的错误。
(3)在下面运行和测试的时候会给出一个完整的json,可做参考。
⑧ 错误处理模块 errors.py
from flask import jsonify from . import api # 使用errorhandler装饰器,只有蓝本才能触发处理程序 # 要想触发全局的错误处理程序,要用app_errorhandler @api.app_errorhandler(404) def page_not_found(e): """这个handler可以catch住所有abort(404)以及找不到对应router的处理请求""" return jsonify({'error': '没有找到您想要的资源', 'code': '404', 'data': ''}) @api.app_errorhandler(500) def internal_server_error(e): """这个handler可以catch住所有的abort(500)和raise exeception.""" return jsonify({'error': '服务器内部错误', 'code': '500', 'data': ''})
四、运行与测试
现在服务端的代码都写完了,关于iOS端,代码很简单,就是一个tableView+SDCycleScrollView+AFN网路请求,不沾代码了。下面开始测试。
1、在本地,导出所有使用的库:pip3 freeze -l > requirements.txt,然后Git提交代码,服务端同步代码,并且在虚拟环境中安装好所有包:pip3 install -r requirements.txt。
2、启动应用:python3 manage.py ,如下,成功。
3、启动成功之后,应该在数据库(cleven_development)中创建出了videos这张表,我们用Navicat连接数据库,并添加一些测试数据:
图片用的是公司项目的资源,打个码~,大家可以随便找点图片,放到自己的服务器上进行测试
4、postman或者浏览器先测试一下 : http://服务器地址:9001/api/v1.0/homePage/,得到数据应该是
1 { 2 code = 200; 3 data = { 4 banner = ( 5 { 6 coverUrl = "http://xxxxx.comcomicApi/v1.0/fileserver/getfile/DM/publicimg/fuyao.jpg"; 7 desc = "\U8d85\U7ea7\U65e0\U654c\U597d\U770b\U7684\U4e0d\U884c"; 8 id = D20171117092809862; 9 synopsis = "\U8d2b\U7620\U7684\U53e4\U53bf\U57ce\U5373\U5c06\U6380\U8d77\U4e00\U573a\U8840\U96e8\U8165\U98ce"; 10 title = "\U7261\U4e39\U4ed9\U5b50\U4e4b\U7687\U5e1d\U8bcf\U66f0"; 11 updateTime = 1550122242716; 12 }, 13 { 14 coverUrl = "http://xxxxx.comcomicApi/v1.0/fileserver/getfile/DM/publicimg/muhouzhiwang.jpg"; 15 desc = "\U73b0\U4ee3\U793e\U4f1a\U771f\U5b9e\U5199\U7167\Uff0c\U7cbe\U5f69\U65e0\U4e0e\U4f26\U6bd4"; 16 id = 20181130164518024; 17 synopsis = "\U59d0\U5f1f\U604b\U73b0\U5b9e\U7248"; 18 title = "\U7f8e\U5bb9\U9488"; 19 updateTime = 1550122242716; 20 } 21 ); 22 homepage = ( 23 { 24 "Hot Broadcast" = ( 25 { 26 coverUrl = "http://xxxxx.comcomicApi/v1.0/fileserver/getfile/DM/publicimg/zhengyangmenxiaxiaonvren.jpg"; 27 desc = "<null>"; 28 id = 20181017153841718; 29 synopsis = "<null>"; 30 title = "\U6b63\U9633\U95e8\U4e0b\U5c0f\U5973\U4eba"; 31 updateTime = 1553853355; 32 }, 33 { 34 coverUrl = "http://xxxxx.comcomicApi/v1.0/fileserver/getfile/DM/publicimg/simeiren.jpg"; 35 desc = "<null>"; 36 id = D20171117093709878; 37 synopsis = "<null>"; 38 title = "\U601d\U7f8e\U4eba"; 39 updateTime = 1553853355; 40 }, 41 { 42 coverUrl = "http://xxxxx.comcomicApi/v1.0/fileserver/getfile/DM/publicimg/jiangye.jpg"; 43 desc = "<null>"; 44 id = 20181031171606549; 45 synopsis = "<null>"; 46 title = "\U5c06\U591c"; 47 updateTime = 1553853355; 48 }, 49 { 50 coverUrl = "http://xxxxx.comcomicApi/v1.0/fileserver/getfile/DM/publicimg/aishangnizhiyuwo.jpg"; 51 desc = "<null>"; 52 id = 20180628144552415; 53 synopsis = "<null>"; 54 title = "\U730e\U6bd2\U4eba"; 55 updateTime = 1553853355; 56 } 57 ); 58 }, 59 { 60 "Dramatic Theater" = ( 61 { 62 coverUrl = "http://xxxxx.comcomicApi/v1.0/fileserver/getfile/DM/publicimg/nanfangyouqiaomu.jpg"; 63 desc = "<null>"; 64 id = D20171117092809831; 65 synopsis = "<null>"; 66 title = "\U5357\U65b9\U6709\U4e54\U6728"; 67 updateTime = 1553853356; 68 }, 69 { 70 coverUrl = "http://xxxxx.comcomicApi/v1.0/fileserver/getfile/DM/publicimg/zuihaodeyujian.jpg"; 71 desc = "<null>"; 72 id = 20180329103639147; 73 synopsis = "<null>"; 74 title = "\U6700\U597d\U7684\U9047\U89c1"; 75 updateTime = 1553853356; 76 }, 77 { 78 coverUrl = "http://xxxxx.comcomicApi/v1.0/fileserver/getfile/DM/publicimg/zhaoyao.jpg"; 79 desc = "<null>"; 80 id = 20190118091609760; 81 synopsis = "<null>"; 82 title = "\U62db\U6447"; 83 updateTime = 1553853356; 84 }, 85 { 86 coverUrl = "http://xxxxx.comcomicApi/v1.0/fileserver/getfile/DM/publicimg/nihewodeqingchengshiguang.jpg"; 87 desc = "<null>"; 88 id = 20181107131541789; 89 synopsis = "<null>"; 90 title = "\U4f60\U548c\U6211\U7684\U503e\U57ce\U65f6\U5149"; 91 updateTime = 1553853356; 92 } 93 ); 94 }, 95 { 96 "Idol Theatre" = ( 97 { 98 coverUrl = "http://xxxxx.comcomicApi/v1.0/fileserver/getfile/DM/publicimg/langmanxingxing.jpg"; 99 desc = "<null>"; 100 id = 20190123094947961; 101 synopsis = "<null>"; 102 title = "\U6d6a\U6f2b\U661f\U661f"; 103 updateTime = 1553853357; 104 }, 105 { 106 coverUrl = "http://xxxxx.comcomicApi/v1.0/fileserver/getfile/DM/publicimg/wodetiyulao.jpg"; 107 desc = "<null>"; 108 id = 20180124165920835; 109 synopsis = "<null>"; 110 title = "\U6211\U7684\U4f53\U80b2\U8001\U5e08"; 111 updateTime = 1553853357; 112 }, 113 { 114 coverUrl = "http://xxxxx.comcomicApi/v1.0/fileserver/getfile/DM/publicimg/aidesudi.jpg"; 115 desc = "<null>"; 116 id = 20180709103825926; 117 synopsis = "<null>"; 118 title = "\U7231\U7684\U901f\U9012"; 119 updateTime = 1553853357; 120 }, 121 { 122 coverUrl = "http://xxxxx.comcomicApi/v1.0/fileserver/getfile/DM/publicimg/aishangnizhiyuwo.jpg"; 123 desc = "<null>"; 124 id = 20180905132122384; 125 synopsis = "<null>"; 126 title = "\U7231\U4e0a\U4f60\U6cbb\U6108\U6211"; 127 updateTime = 1553853357; 128 } 129 ); 130 } 131 ); 132 }; 133 msg = success; 134 }
里面有一些小问题需要处理,比如<null>这种情况(iOS这边对返回的空对象会解析成NSNull对象,打印出来就是<null>,理论上后端不应该把空对象返回给移动端),咱们就不单独处理了。
5、xcode打开app,应该可以拿到数据并展示了,good ~
五、总结
算是完成了一个简单的移动端应用和Python服务端的通信。当然,里面还有很多问题需要优化,我们也没有加上服务器分发以及uWSGI等部署,同时数据库也就一张表,没有出现连表查询、关系存储等等,所以,只能算是一个双端通信的模型demo,用作大家交流探讨。
开发移动端API和其他web应用相比,在设计思想和细节上还是有很多不同的。服务端无法全量掌控业务代码,客户端也是独立开发,服务端必须考虑到客户端设备性能、网络状态、平台兼容、统一的数据结构、稳定的访问、文档的提供、友好的用户体验、规范的版本管理等等问题。虽然看上去,服务端只是给客户端手机提供了想要的“资源”,但是,稳定性和规范化,比一般应用要求的还要高很多,换个角度说,为移动端开发API,要求有较高的“容错性”设计。
后面如果有时间,把demo整理一下,打包上来。