🧪 第14章 实作物件侦测 API
📄 一句话概括

三层分开:应用包管启动、detector 管模型、views 管路由。 create_app 加一个 config 参数,测试和线上就能跑同一份代码。 启动时该做的是读配置、建目录、注册蓝图,不该下载权重。

📝 本章和前面几章的关系

这一篇对应教材 14.1 到 14.3.3:目录架构与模组、准备实作、编写启动程式码、加载组态建立应用、 编写通用设定、确认运作情况。 第 13 章定了规格,这一篇开始把它写出来。先立壳,再填肉。

一、三层分开

detect_api/
    __init__.py        应用包,create_app 在这儿
    config.py          配置
    detector/
        detector.py    读图、前处理、推理
        postprocess.py 坐标还原、筛选、NMS
    views/
        detect.py      路由与错误处理
wsgi.py                入口,给部署平台用

实测这颗树的形状就是这样,wsgi.py 在包外面。 为什么把模型和路由分成两个目录:换模型的时候只动 detector/,改接口的时候只动 views/。 混在一起的项目,改一次要读完所有文件。 detector/ 里又拆成两个文件:detector.py(读图、前处理、推理)和 postprocess.py(坐标还原、 筛选、NMS)。拆开的理由是后处理不需要模型,它是纯数学,可以单独测。

这个分法跟第 3 章那个蓝图结构是一个思路,只是粒度粗一点——因为整个 API 只有一组端点, 不值得再拆蓝图。

二、create_app 六行

def create_app(config=None):
    app = Flask(__name__, instance_relative_config=True)
    app.config.from_object(Config)
    if config:
        app.config.update(config)
    app.json.ensure_ascii = False
    os.makedirs(app.instance_path, exist_ok=True)
    app.register_blueprint(detect_bp)
    return app

每一行都能往上追到前面某一章:工厂是第 2 章,配置类是第 2 章,蓝图是第 4 章, ensure_ascii 是第 12 章。 instance_relative_config=True 值得单说:它让 instance 目录相对于应用包算, 而不是相对于当前工作目录。换个地方启动也不会跑偏。 config 那个参数是给测试用的——第 11 章那些 fixture 就是这么传内存库的。 工厂模式的好处在这里兑现:同一个应用能造出很多个互不相干的实例。

三、配置里哪些走环境变量

配置 来源 为什么
MAX_CONTENT_LENGTH 环境变量 MAX_MB 运维要调,改代码要重建镜像
INPUT_SIZE 写死 416x416 模型决定的,改了效果就变
CONF_THRESHOLD 写死 0.30 同上
NMS_IOU 写死 0.45 同上

这是「谁该调」的判断,不是技术判断。 上传上限是运维的事:本地 2MB 够用,线上可能要 10MB。 写在环境变量里,改一下重启就行,不用重新构建镜像。 另外三个是模型行为,改了结果就变,应该跟代码一起进版本库——否则「昨天结果好好的,今天不对了」, 查半天才发现是环境变量被改了。

第 2 章那个配置分层在这里又用上一次:from_object 给默认值,update 覆盖。 测试传内存库、线上读环境变量,同一份代码。

四、跑起来看两个端点

请求 结果
GET /api/health 200 + {"input":[416,416],"status":"ok"}
GET /nope 404 + HTML(还是第 10 章那个错误页)

健康检查端点几乎是每个 API 的标配。 部署平台靠它判断「这个实例活了没有」,负载均衡靠它判断「要不要把流量切走」。 它返回的东西越简单越好:一个状态、几个关键配置。 别在里面查数据库、别在里面调外部接口——它会被高频调用,而且它挂了就等于整个实例挂了。 /nope 返回的还是 HTML,这是这一篇最后要补的洞。先把功能跑通,再把错误响应统一成 JSON。

五、启动时该干什么

该做 不该做
读配置 连数据库跑一堆查询
建目录 下载模型权重(网络可能不通)
注册蓝图 起后台线程做定时任务
设日志级别 打印一大堆调试信息

模型加载放哪是个具体问题:模块级懒加载(第 8 章那个写法),还是启动时主动加载一次? 推荐后者:启动时加载,加载失败就启动失败。 这样部署平台立刻知道这个实例有问题,而不是等第一个用户来才 500。 第 8 章那个懒加载省事,但它把错误推迟到了运行时。两种都有人用,知道差别在哪就行。

不该做的那些有个共同点:它们都可能失败,而且失败的方式不可控。启动流程里每多一个外部依赖, 部署就多一种失败模式。

六、动手 Demo:目录架构与启动代码:先把壳立起来

这个 Demo 照教材 14.1 造出整套目录,然后真的起服务、真的发请求。 第四个那两行响应是实测的:一个 200 带 JSON,一个 404 还是 HTML。

import os
from flask import Flask
from .config import Config
from .views.detect import bp as detect_bp


def create_app(config=None):
    app = Flask(__name__, instance_relative_config=True)
    app.config.from_object(Config)
    if config:
        app.config.update(config)
    app.json.ensure_ascii = False
    os.makedirs(app.instance_path, exist_ok=True)
    app.register_blueprint(detect_bp)
    return app

实跑输出:

--------------------------------------------------------------------
目录架构与启动代码:先把壳立起来
--------------------------------------------------------------------

  ① 目录长这样
  --------------------------------------------------------------------
  |- api1/
    |- wsgi.py
    |- detect_api/
      |- __init__.py
      |- config.py
      |- detector/
        |- __init__.py
        |- detector.py
        |- postprocess.py
      |- views/
        |- __init__.py
        |- detect.py
  ! 三层:detect_api/(应用包)、detector/(模型相关)、views/(路由)。
  ! 为什么把模型和路由分开:换模型的时候只动 detector/,改接口的时候只动 views/。
  混在一起的那些项目,改一次要读完全部文件。
  ! wsgi.py 在包外面。它是「入口」,被 gunicorn 之类的服务器调用,第 15 章部署的时候会用到它。

  ② create_app 工厂
  --------------------------------------------------------------------

  [代码原样]
  | def create_app(config=None):
  |     app = Flask(__name__, instance_relative_config=True)
  |     app.config.from_object(Config)
  |     if config:
  |         app.config.update(config)
  |     app.json.ensure_ascii = False
  |     os.makedirs(app.instance_path, exist_ok=True)
  |     app.register_blueprint(detect_bp)
  |     return app
  六行,每一行都是第 2 章那个工厂的扩展。

  ! instance_relative_config=True 让 instance 目录相对于应用包算,而不是相对于当前工作目录。
  换个地方启动也不会跑偏。
  ! create_app(config) 那个参数是给测试用的——第 11 章那些 fixture 就是这么传内存库的。
  工厂模式的好处在这里兑现:同一个应用能造出很多个互不相干的实例。

  ③ 配置从环境变量来
  --------------------------------------------------------------------

  [代码原样]
  | class Config:
  |     MAX_CONTENT_LENGTH = int(os.environ.get("MAX_MB", "2")) * 1024 * 1024
  |     INPUT_SIZE = (416, 416)
  |     CONF_THRESHOLD = 0.30
  |     NMS_IOU = 0.45
  三个模型参数直接写死,上传上限从环境变量读。

  ! 为什么只有上传上限走环境变量?
  因为它是运维要调的:本地 2MB 够用,线上可能要 10MB,改代码就得重新构建镜像。
  ! 另外三个是模型行为,改了效果就变,应该跟代码一起进版本库——这属于「谁该调」的判断,
  不是技术判断。

  ④ 真的跑起来
  --------------------------------------------------------------------
  GET /api/health -> 200
  响应体 -> {"input":[416,416],"status":"ok"}
  GET /nope -> 404(还是 HTML 错误页)
  ! 健康检查端点几乎是每个 API 的标配。
  部署平台靠它判断「这个实例活了没有」,负载均衡靠它判断「要不要把流量切走」。
  ! /nope 返回的还是第 10 章那个 HTML 错误页——这一篇最后会把它改成 JSON。

  ⑤ 启动时该干什么、不该干什么
  --------------------------------------------------------------------

| 该做 | 不该做 |
|---|---|
| 读配置 | 连数据库跑一堆查询 |
| 建目录 | 下载模型权重(网络可能不通) |
| 注册蓝图 | 起后台线程做定时任务 |
| 设日志级别 | 打印一大堆调试信息 |

  ! 模型加载放哪是个具体问题:放模块级第一次调用时懒加载(第 8 章那个写法),
  还是启动时主动加载一次?
  ! 推荐后者:启动时加载,加载失败就启动失败。
  这样部署平台立刻知道这个实例有问题,而不是等第一个用户来才 500。
  ! 第 8 章那个懒加载省事,但它把错误推迟到了运行时。两种都有人用,知道差别就行。

第一节最外面那个 api1/ 就是项目根,wsgi.py 跟 detect_api/ 平级。 第四节两个状态码:一个 200 一个 404,差别在于有没有对应的路由。 第五节那张表右边四条,共同点是「都可能失败」。

30 秒实验

  1. 把 instance_relative_config=True 去掉,然后从另一个目录启动,看 instance 跑哪去了。
  2. 给 /api/health 加一项 model_loaded,返回模型有没有加载。
  3. 想一想:为什么配置里只有上传上限该走环境变量?

常见误区

  1. 模型和路由混在一个文件里:换模型要读完全部代码。
  2. 启动时下载模型权重:网络不通就启动不了,而且每次重启都下。
  3. 健康检查里查数据库:它挂了就等于实例挂了。
  4. 配置全写死:换个环境要改代码重新构建。
自测三问(点开对答案) 1. 为什么把 detector 和 views 分成两个目录? —— 换模型只动 detector,改接口只动 views,互不牵连。 2. create_app 的 config 参数是干什么的? —— 让调用方覆盖默认配置,测试传内存库、线上读环境变量。 3. 模型加载放启动时还是懒加载? —— 推荐启动时,失败就启动失败,部署平台立刻能发现。

壳立起来了,下一篇填肉:前处理和后处理这两头最容易被忽略的活。

相关笔记