4、请求与响应设计:请求体格式(JSON/XML/Form-Data)、响应体结构设计(统一封装)、分页/排序/过滤参数设计

好,咱们今天聊点实在的。请求和响应,说白了就是API的「门面」。用户怎么把数据递给你,你又怎么把结果还回去,这里面门道不少。我见过太多项目,接口设计得随心所欲,前端对接的时候恨不得骂娘。嗯,咱们今天就把它理清楚。

4.1 请求体格式:JSON、XML、Form-Data

先说说请求体。你想想看,客户端要往服务端塞数据,总得有个格式吧?目前主流就三种:JSON、XML、Form-Data。我个人习惯,90%的场景直接用JSON,简单、轻量、好解析。

4.1.1 JSON(推荐)

JSON现在几乎是RESTful API的标配。结构清晰,Python里用json模块一解析就完事。我在项目中遇到过,前端传了个嵌套对象,后端用request.get_json()直接拿到字典,处理起来非常顺手。

# Flask 示例
from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route('/api/users', methods=['POST'])
def create_user():
    data = request.get_json()
    # data 就是一个字典
    username = data.get('username')
    email = data.get('email')
    # 业务逻辑...
    return jsonify({'message': 'User created'}), 201
小提示:记得设置请求头 Content-Type: application/json。我曾经见过有人忘了设,Flask解析不出来,debug了半天。

4.1.2 XML(遗留系统常用)

XML现在用得少了,但你要是对接银行、物流之类的老系统,还是得会。解析起来比JSON麻烦,得用xml.etree.ElementTree或者lxml。说实话,我个人不太喜欢XML,太啰嗦了。

# 解析 XML 请求体
import xml.etree.ElementTree as ET

xml_data = request.data  # 获取原始字节流
root = ET.fromstring(xml_data)
username = root.find('username').text
注意:XML解析容易遇到命名空间问题,处理不当会报错。建议用 lxml 库,容错性更好。

4.1.3 Form-Data(文件上传场景)

Form-Data主要用于文件上传,或者混合了文件和字段的表单。比如用户头像上传,既有图片文件,又有用户ID。这时候用multipart/form-data最合适。

# Flask 处理文件上传
from werkzeug.utils import secure_filename

@app.route('/api/upload', methods=['POST'])
def upload_file():
    file = request.files['avatar']
    user_id = request.form.get('user_id')
    filename = secure_filename(file.filename)
    file.save(f'uploads/{filename}')
    return jsonify({'filename': filename}), 200
核心原则:能用JSON就用JSON。只有文件上传才考虑Form-Data。XML能不用就不用,除非对方强制要求。

4.2 响应体结构设计:统一封装

响应体设计,我踩过最大的坑就是「每个接口返回格式都不一样」。有的返回{'code': 0, 'data': ...},有的直接返回{'id': 1, 'name': 'xxx'}。前端对接的时候,每个接口都要写不同的解析逻辑,累死人。

所以,我强烈建议:统一响应体结构。不管成功还是失败,格式必须一致。

4.2.1 标准响应体格式

我个人习惯用这个结构:

{
    "code": 0,          # 业务状态码,0表示成功
    "message": "success",  # 提示信息
    "data": { ... }     # 实际数据,可以是对象、列表或null
}

为什么这么设计?

  • code:业务状态码,不是HTTP状态码。比如0成功,1001参数错误,1002未登录。这样前端可以统一判断。
  • message:给前端或用户看的提示信息。成功时写"success",失败时写具体原因。
  • data:真正的业务数据。没有数据时返回null,不要返回空字符串或空对象。
# 统一响应封装函数
def success_response(data=None, message="success"):
    return jsonify({
        "code": 0,
        "message": message,
        "data": data
    })

def error_response(code, message, data=None):
    return jsonify({
        "code": code,
        "message": message,
        "data": data
    }), 400  # HTTP状态码可以灵活设置
避坑指南:我曾经把HTTP状态码和业务状态码混为一谈。比如用户未登录,我返回了401,但前端只看code字段,结果没处理。后来统一用code做业务判断,HTTP状态码只做传输层区分(200成功、400参数错误、500服务器错误)。

4.2.2 错误响应示例

# 参数校验失败
{
    "code": 1001,
    "message": "用户名不能为空",
    "data": null
}

# 未登录
{
    "code": 1002,
    "message": "请先登录",
    "data": null
}

4.3 分页、排序、过滤参数设计

列表接口,几乎每个项目都有。用户不可能一次看几万条数据,所以分页是必须的。排序和过滤,则是让用户能快速找到想要的数据。这三个东西,我建议统一设计,别每个接口各搞一套。

4.3.1 分页参数

我推荐用页码+每页条数的方式,也就是pagepage_size。为什么不用游标分页?因为大多数场景下,用户需要跳转到第N页,游标分页做不到。

参数名 类型 默认值 说明
page int 1 当前页码,从1开始
page_size int 20 每页条数,建议限制最大值(比如100)
# 分页参数解析
from flask import request

def get_pagination_params():
    page = request.args.get('page', 1, type=int)
    page_size = request.args.get('page_size', 20, type=int)
    # 限制 page_size 最大值,防止恶意请求
    page_size = min(page_size, 100)
    return page, page_size
注意:一定要限制page_size的最大值。我曾经见过有人传page_size=999999,直接把数据库查崩了。限制到100或200就够用了。

4.3.2 排序参数

排序我习惯用sort_bysort_order。前者指定字段名,后者指定升序或降序。

参数名 类型 默认值 说明
sort_by string created_at 排序字段,比如 id、created_at、name
sort_order string desc 排序方向:asc 或 desc
# 排序参数解析
def get_sort_params(allowed_fields=None):
    if allowed_fields is None:
        allowed_fields = ['id', 'created_at', 'updated_at']
    
    sort_by = request.args.get('sort_by', 'created_at')
    sort_order = request.args.get('sort_order', 'desc')
    
    # 校验字段是否在白名单内
    if sort_by not in allowed_fields:
        sort_by = 'created_at'
    
    # 校验排序方向
    if sort_order not in ['asc', 'desc']:
        sort_order = 'desc'
    
    return sort_by, sort_order
安全提示:排序字段一定要做白名单校验!否则用户传个sort_by=password_hash,你的用户密码就暴露了。我见过有人直接把前端传的字段名拼到SQL里,结果被SQL注入,教训深刻。

4.3.3 过滤参数

过滤参数,说白了就是条件查询。我建议用field__operator=value的格式,比如price__gte=100表示价格大于等于100。

参数示例 说明
status=active 精确匹配
price__gte=100 大于等于(greater than or equal)
price__lte=500 小于等于(less than or equal)
name__contains=张三 模糊匹配(like)
created_at__gte=2024-01-01 日期范围过滤
# 过滤参数解析示例(简化版)
def parse_filters(allowed_filters=None):
    if allowed_filters is None:
        allowed_filters = ['status', 'price__gte', 'price__lte', 'name__contains']
    
    filters = {}
    for key, value in request.args.items():
        if key in allowed_filters:
            filters[key] = value
    return filters
避坑指南:我曾经在过滤参数上吃过亏。前端传了个name__contains=%,结果SQL里直接拼了LIKE '%%%',查出了所有数据。后来我强制对模糊搜索的输入做转义,把%_替换掉,才彻底解决。

4.3.4 分页响应结构

分页接口的响应,除了数据本身,还要返回分页信息。我习惯这样设计:

{
    "code": 0,
    "message": "success",
    "data": {
        "items": [ ... ],   # 当前页的数据列表
        "total": 100,       # 总记录数
        "page": 1,          # 当前页码
        "page_size": 20,    # 每页条数
        "total_pages": 5    # 总页数(可选)
    }
}
# 分页响应封装
def paginated_response(query, page, page_size):
    total = query.count()
    items = query.offset((page - 1) * page_size).limit(page_size).all()
    
    # 序列化 items(假设有序列化函数)
    serialized_items = [item.to_dict() for item in items]
    
    return success_response({
        "items": serialized_items,
        "total": total,
        "page": page,
        "page_size": page_size,
        "total_pages": (total + page_size - 1) // page_size
    })

好了,这一章的内容就这些。说白了,请求体和响应体设计,核心就是「统一」二字。统一了格式,前后端对接就少了很多扯皮。下一章咱们聊聊认证与授权,那又是另一个大坑了。