AI Library · 知识库

# HTTP JSON 客户端:请求成功不等于数据可信

学习目标:完成真实 HTTP 请求、超时、JSON 解码、运行时字段校验和用户错误报告,并用程序自己启动的 loopback 服务重现成功与失败。

前置知识:异常、函数、JSON 对象;Python 只需把线程理解为让测试服务器与阻塞客户端同时工作,JavaScript 需理解 Promise 和 await。选择 Python 与 JavaScript,对比阻塞请求和事件驱动请求的资源归属;均使用标准库,不引入第三方 HTTP 框架。

# 问题与边界

客户端读取用户资料,CLI 为 HOST PORT PATH 三个参数,执行一次 GET;无参数时启动自己的 127.0.0.1:随机端口 服务并依次请求预设路径。

响应由受控小型服务提供,正文会完整缓存;超大正文、慢速持续发送以及整次请求总时限不在本工具保证内。超时覆盖的具体阶段在下节说明,不能把一个参数宣称为所有网络风险的解决方案。

# 关键差异

维度 Python JavaScript
请求控制流 阻塞 HTTPConnection http.get 事件包装为 Promise
超时语义 套接字阻塞操作超时,单位秒 连接后套接字空闲超时,单位毫秒
超时后的动作 异常传播,finally 关闭连接 超时回调必须显式销毁请求
运行时整数判定 排除 bool,检查整数值与范围 Number.isInteger 与范围

Python 的 HTTPConnection 超时作用于阻塞操作,不是请求总预算。Node.js 的 request.setTimeout 在套接字连接后设置超时;单纯设置超时不会自动中止请求,因此下面主动 destroy。这两个 API 不提供完全等价的 DNS、连接和总耗时保证;本例共同验证的是已连接 loopback 服务不返回数据时能够停止等待。

# 各语言实现

# Python

执行 python main.py 运行本地服务与客户端;另可执行 python main.py 127.0.0.1 8080 /user 请求自己已有的 HTTP 服务。无第三方依赖,无文件写入。

Pythonimport http.client
import json
import re
import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

def reject_constant(text):
    raise ValueError("non-JSON numeric constant")

def validate_user(value):
    if not isinstance(value, dict):
        raise ValueError("invalid user")
    identifier, name = value.get("id"), value.get("name")
    if (type(identifier) not in (int, float)
            or not 1 <= identifier <= 65535 or int(identifier) != identifier
            or not isinstance(name, str)
            or re.fullmatch(r"[A-Za-z]{1,20}", name) is None):
        raise ValueError("invalid user")
    return {"id": int(identifier), "name": name}

def fetch_user(host, port, path, timeout=2):
    connection = http.client.HTTPConnection(host, port, timeout=timeout)
    try:
        connection.request("GET", path, headers={"Accept": "application/json"})
        with connection.getresponse() as response:
            if response.status != 200:
                raise ValueError(f"HTTP {response.status}")
            body = response.read()
        try:
            value = json.loads(body.decode("utf-8"), parse_constant=reject_constant)
        except (ValueError, UnicodeError) as error:
            raise ValueError("invalid JSON") from error
        return validate_user(value)
    except TimeoutError as error:
        raise ValueError("timeout") from error
    except (OSError, http.client.HTTPException) as error:
        raise ValueError("network error") from error
    finally:
        connection.close()

def demo():
    release = threading.Event()
    routes = {
        "/ok": (200, b'{"id":7,"name":"Ada"}'),
        "/status": (503, b'unavailable'),
        "/json": (200, b'{'),
        "/fields": (200, b'{"id":"7","name":"Ada"}'),
    }

    class Handler(BaseHTTPRequestHandler):
        def log_message(self, format, *args):
            pass  # 演示不输出访问日志;请求结果由客户端报告。

        def do_GET(self):
            if self.path == "/timeout":
                release.wait()
                self.close_connection = True
                return
            if self.path == "/disconnect":
                self.close_connection = True
                return
            status, body = routes.get(self.path, (404, b'not found'))
            self.send_response(status)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)

    for identifier in [1, 65535, 1.0]:
        assert validate_user({"id": identifier, "name": "A"})["id"] == identifier
    assert validate_user({"id": 1, "name": "A" * 20})["name"] == "A" * 20
    for value in [None, [], {}, {"id": True, "name": "Ada"},
                  {"id": 0, "name": "Ada"}, {"id": 65536, "name": "Ada"},
                  {"id": 1.5, "name": "Ada"}, {"id": 1, "name": ""},
                  {"id": 1, "name": "A" * 21}, {"id": 1, "name": "艾达"}]:
        try:
            validate_user(value)
        except ValueError as error:
            assert str(error) == "invalid user"
        else:
            raise AssertionError("invalid user accepted")
    server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
    thread = threading.Thread(target=server.serve_forever)
    thread.start()
    try:
        port = server.server_address[1]
        user = fetch_user("127.0.0.1", port, "/ok")
        assert user == {"id": 7, "name": "Ada"}
        print(f"user: {user['id']} {user['name']}")
        for path, message in [("/status", "HTTP 503"), ("/json", "invalid JSON"),
                              ("/fields", "invalid user"), ("/timeout", "timeout"),
                              ("/disconnect", "network error")]:
            try:
                fetch_user("127.0.0.1", port, path, 0.1 if path == "/timeout" else 2)
            except ValueError as error:
                assert str(error) == message
                print("error: " + str(error))
            else:
                raise AssertionError("failing response accepted")
    finally:
        release.set()
        server.shutdown()
        server.server_close()
        thread.join()

def main():
    if len(sys.argv) == 1:
        demo()
        return 0
    if (len(sys.argv) != 4 or re.fullmatch(r"[0-9]{1,5}", sys.argv[2]) is None
            or not 1 <= int(sys.argv[2]) <= 65535 or not sys.argv[3].startswith("/")):
        print("usage: python main.py [HOST PORT /PATH]", file=sys.stderr)
        return 2
    try:
        user = fetch_user(sys.argv[1], int(sys.argv[2]), sys.argv[3])
    except ValueError as error:
        print(f"error: {error}", file=sys.stderr)
        return 1
    print(f"user: {user['id']} {user['name']}")
    return 0

if __name__ == "__main__":
    sys.exit(main())

Python 中 bool 是 int 的子类,因此 isinstance(True, int) 不能表达本协议;这里精确限定数值类型再检查范围。标准 json 默认能接受非标准的 NaN 和 Infinity,本例用 parse_constant 拒绝,避免与 JavaScript JSON 解析的边界不一致。

服务器绑定端口 0 让系统分配空闲端口;仅绑定 loopback,不对局域网开放。/timeout 等待释放事件,而非睡眠后继续向已断开的客户端写数据;清理时先释放处理器,再停止服务。http.server 只用于本地验证,不建议作生产服务,见官方安全说明。

# JavaScript

位于独立 CommonJS 目录。执行 node main.js;独立请求为 node main.js 127.0.0.1 8080 /user。不依赖浏览器或全局 fetch,因此不引入额外的 fetch 运行时版本条件。

JavaScriptconst assert = require('node:assert/strict');
const http = require('node:http');
const { TextDecoder } = require('node:util');

function validateUser(value) {
  if (value === null || typeof value !== 'object' || Array.isArray(value) ||
      !Number.isInteger(value.id) || value.id < 1 || value.id > 65535 ||
      typeof value.name !== 'string' || !/^[A-Za-z]{1,20}$/.test(value.name) ||
      value.name.includes('\n')) throw new Error('invalid user');
  return { id: value.id, name: value.name };
}

function fetchUser(host, port, path, timeout = 2000) {
  return new Promise((resolve, reject) => {
    let timedOut = false;
    const request = http.get({ hostname: host, port, path, agent: false,
      headers: { Accept: 'application/json' } }, response => {
      const chunks = [];
      response.on('error', error => reject(new Error(
        timedOut ? 'timeout' : 'network error', { cause: error })));
      if (response.statusCode !== 200) {
        reject(new Error(`HTTP ${response.statusCode}`));
        response.destroy();
        return;
      }
      response.on('data', chunk => chunks.push(chunk));
      response.on('end', () => {
        let value;
        try {
          const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true })
            .decode(Buffer.concat(chunks));
          value = JSON.parse(text);
        } catch (error) {
          reject(new Error('invalid JSON', { cause: error }));
          return;
        }
        try { resolve(validateUser(value)); }
        catch (error) { reject(error); }
      });
    });
    request.setTimeout(timeout, () => {
      timedOut = true;
      request.destroy(new Error('timeout'));
    });
    request.on('error', error => reject(new Error(
      timedOut ? 'timeout' : 'network error', { cause: error })));
  });
}

async function demo() {
  for (const id of [1, 65535, 1.0]) {
    assert.equal(validateUser({ id, name: 'A' }).id, id);
  }
  assert.equal(validateUser({ id: 1, name: 'A'.repeat(20) }).name.length, 20);
  for (const value of [null, [], {}, { id: true, name: 'Ada' },
    { id: 0, name: 'Ada' }, { id: 65536, name: 'Ada' },
    { id: 1.5, name: 'Ada' }, { id: 1, name: '' },
    { id: 1, name: 'A'.repeat(21) }, { id: 1, name: '艾达' },
    { id: 1, name: 'Ada\n' }]) {
    assert.throws(() => validateUser(value), { message: 'invalid user' });
  }
  const routes = new Map([
    ['/ok', [200, '{"id":7,"name":"Ada"}']],
    ['/status', [503, 'unavailable']],
    ['/json', [200, '{']],
    ['/fields', [200, '{"id":"7","name":"Ada"}']],
  ]);
  const sockets = new Set();
  const server = http.createServer((request, response) => {
    if (request.url === '/timeout') return;
    if (request.url === '/disconnect') { request.socket.destroy(); return; }
    const [status, body] = routes.get(request.url) ?? [404, 'not found'];
    response.writeHead(status, { 'Content-Type': 'application/json',
      'Content-Length': Buffer.byteLength(body) });
    response.end(body);
  });
  server.on('connection', socket => {
    sockets.add(socket);
    socket.on('close', () => sockets.delete(socket));
  });
  await new Promise((resolve, reject) => {
    server.once('error', reject);
    server.listen(0, '127.0.0.1', resolve);
  });
  try {
    const port = server.address().port;
    const user = await fetchUser('127.0.0.1', port, '/ok');
    assert.deepEqual(user, { id: 7, name: 'Ada' });
    console.log(`user: ${user.id} ${user.name}`);
    for (const [path, message] of [['/status', 'HTTP 503'], ['/json', 'invalid JSON'],
      ['/fields', 'invalid user'], ['/timeout', 'timeout'], ['/disconnect', 'network error']]) {
      try {
        await fetchUser('127.0.0.1', port, path, path === '/timeout' ? 100 : 2000);
        assert.fail('failing response accepted');
      } catch (error) {
        assert.equal(error.message, message);
        console.log('error: ' + error.message);
      }
    }
  } finally {
    await new Promise((resolve, reject) => {
      server.close(error => error ? reject(error) : resolve());
      for (const socket of sockets) socket.destroy();
    });
  }
}

async function main() {
  const args = process.argv.slice(2);
  if (args.length === 0) return demo();
  if (args.length !== 3 || !/^[0-9]{1,5}$/.test(args[1]) || args[1].includes('\n') ||
      Number(args[1]) < 1 || Number(args[1]) > 65535 || !args[2].startsWith('/')) {
    console.error('usage: node main.js [HOST PORT /PATH]');
    process.exitCode = 2;
    return;
  }
  try {
    const user = await fetchUser(args[0], Number(args[1]), args[2]);
    console.log(`user: ${user.id} ${user.name}`);
  } catch (error) {
    console.error(`error: ${error.message}`);
    process.exitCode = 1;
  }
}
main().catch(error => { console.error(error); process.exitCode = 1; });

Node.js http 返回字节流,不替应用解析 JSON,见 HTTP 模块说明。这里同时处理请求和响应的错误;请求已经拿到响应头,不代表响应正文一定能完整读完。agent: false 禁用连接复用,缩小演示的资源生命周期;本地服务记录并关闭自己的套接字,不依赖新版运行时的额外关闭 API。

# 迁移误区与适用限制

# 运行与验证

两个无参数程序都自行监听 loopback 随机端口,结束时关闭服务和连接,无需公网,不创建配置或数据文件。预期 stdout 为:

纯文本user: 7 Ada
error: HTTP 503
error: invalid JSON
error: invalid user
error: timeout
error: network error

内置网络场景覆盖成功、非 200、非法 JSON、字段错误、已连接但无响应、服务端直接断连;字段断言覆盖 id 两端点、上下界外、小数、布尔、缺字段、顶层数组/null、名字长度 0/1/20/21 和非 ASCII。预期错误由 demo 调用方消费,因此整体应正常退出。独立 CLI 失败返回 1,参数错误返回 2;不会在错误后继续打印用户。

版本基线见主题首页,执行状态和实际工具链见验证记录。上述输出是待执行比对的预期,不是执行证据。无效 UTF-8、BOM、NaN、重复属性、未知字段的策略由正文与实现定义,但未全部加入内置网络路由;整次请求总时限、超大响应和公网部署不在验证范围。

# 相关阅读