
宝可梦学院 | 第三集 | 皮卡丘与伊布初次接近两个独立服务的第一次集成如果你已经做过两集微服务练习你会发现一个尴尬的事实单个服务写得再漂亮也只是“单机精灵”。真正到了业务要跑起来的阶段皮卡丘必须学会认识伊布伊布也得知道去哪找皮卡丘。这一集要讲的就是两个技术栈完全不同的服务如何进行第一次“交流”。很多初学者在写第一个单体项目时所有的类都在同一个工程里调用一个方法只需要import数据直接在内存里穿梭。可一旦把系统拆成“皮卡丘服务”和“伊布服务”事情就变了它们可能一个用 Java一个用 Python甚至部署在不同机器上。此时“import”失效数据库也不能随便共享。唯一靠谱的方式是通过网络接口互相访问。这就是我在这篇文章里要带你跑通的场景Java Spring Boot 编写的用户服务代号皮卡丘与 Python FastAPI 编写的数据分析服务代号伊布实现第一次跨服务 HTTP 调用。这一集的内容会围绕“初次接近”过程中最关键的几个点展开接口契约怎么定、两个服务如何配置、跨域问题怎么处理、如何用 Docker Compose 一键启动、如何验证联调成功以及最常见的排错路径。读完你不仅能把示例跑起来还能理解微服务之间协作的基本原则为后续的网关、注册中心、链路追踪等内容打下基础。先说结论两个服务能不能顺利“接近”不取决于用的是 Java 还是 Python而是取决于你是否在写代码之前把接口契约、数据格式、异常处理、超时策略想清楚。代码反而不是最难的最难的是双方对同一个字段的理解保持一致。1. 这一集要解决的问题两个“精灵”之间的第一次联调在我们这个“宝可梦学院”系列项目里前两集分别完成了两个独立服务的基础搭建。皮卡丘服务是一个基于 Spring Boot 的精灵档案服务负责维护所有宝可梦的基础信息比如编号、名称、属性、身高、体重以及当前训练师。伊布服务则是一个基于 FastAPI 的数据分析服务它的职责是根据用户请求返回精灵的战斗能力评估和进化建议。问题出现在第二集结束之后的联调阶段伊布服务需要知道皮卡丘服务里的档案数据才能做分析。按最初的设想负责伊布服务的同学准备直接连皮卡丘的数据库表。这听起来很直接但稍微想想就会发现问题如果两个服务直接共享数据库那么数据库表结构一改双方都得跟着改数据库连接信息暴露给多个服务权限管理会很混乱最重要的是服务之间的依赖关系会变得完全不可控。于是我们决定采用更合理的方式皮卡丘服务通过 REST API 暴露数据访问能力伊布服务通过 HTTP 调用这些接口。这一集的任务就是让这两个“精灵”完成第一次网络层面的接触。这一集的代码量并不大但它意味着一次思维方式的转变从“共享内部状态”转向“通过接口通信”。如果你之前只写单体应用或者只在本机调用过自己写的接口这次联调会让你提前感受到分布式系统最基础也最容易踩坑的部分。下面先补充一个背景判断两个服务之间哪怕只隔着一层 HTTP 调用复杂度也远高于同一个进程里的方法调用。网络会超时、服务会重启、数据格式会变化、安全凭证需要传递这些都是初次联调时必然遇到的现实。所以不要因为“只是调一个接口”就掉以轻心。2. 核心概念服务A与服务B的“初次接近”到底是什么要理解这一集在做什么可以先从最简单的场景说起。想象一下你在写一个单体的商城系统用户下单后需要扣减库存。传统写法是直接在 Service 层里调用inventoryService.deduct()两个模块在同一个 JVM 进程内参数和返回值都是 Java 对象。这种调用方式是“进程内调用”速度快、类型安全、调试方便。微服务化之后用户服务和库存服务被拆成了两个独立进程甚至可能用不同语言编写。此时你不能再直接调用方法必须通过网络协议传递数据。最常见的做法是库存服务提供一个 HTTP 接口用户服务在需要时发起一次 HTTP 请求把参数传过去库存服务处理完把结果返回。这个过程中专业一点的说法叫“跨进程通信”具体到本集就是“基于 REST API 的服务间调用”。“初次接近”真正要面对的不是怎么发请求而是如何保证双方对接口的理解一致。这就引出一个关键概念接口契约。简单说接口契约就是“请求长什么样、响应长什么样、出错时返回什么”的约定。它不是写代码时才临时决定的而是在编码之前由双方共同确认的。这里用一个表格来对比“进程内调用”和“跨服务调用”的差异你会发现很多习惯在新模式下必须改变。维度进程内调用跨服务 HTTP 调用数据传递直接传 Java 对象 / Python 对象序列化为 JSON 或 XML 后再传输失败模式抛出异常调用方直接捕获网络超时、连接拒绝、HTTP 状态码异常类型安全编译期强校验运行时才能发现字段不匹配性能纳秒级毫秒级甚至更高配置复杂度低无需关注地址需要配置服务地址、端口、超时时间部署依赖同一进程内两个服务可独立部署不难看出跨服务调用最大的“坑”并不是写代码而是接口契约没有对齐。比如皮卡丘服务返回的字段叫name伊布服务读取的字段叫pokemon_name结果就是请求成功但数据全是空值。这种问题不会报错排查起来却很费劲。除了接口契约还有一个高频概念是“跨域”。如果你只是在后端服务之间调用跨域问题通常不会出现因为服务端之间的请求不受浏览器同源策略限制。但如果你以后需要从前端页面直接调用这两个服务就会遇到 CORS。这一集里我们仍然会在皮卡丘服务中配置 CORS原因是后续做前后端联调时这几乎是必踩的问题。从原理层面另一个值得理解的概念是“服务发现”。在采用注册中心比如 Nacos、Consul之后服务之间不需要写死 IP 和端口而是通过服务名去发现对方。但这一集我们不会引入注册中心因为它的复杂度较高不适合作为“初次接近”的起点。先用写死地址的方式跑通一次完整链路你才能真正理解注册中心解决的是什么问题。3. 前置条件与环境准备在开始写代码之前先确认你的本机环境。因为示例中会同时使用 Java 和 Python所以比单一技术栈的教程多一步环境检查。推荐环境如下并不代表必须严格匹配但版本不要太旧。JDK11 或更高版本。Spring Boot 2.7 以上在高版本 JDK 下运行需要关注兼容性建议 JDK 11 或 17。Maven3.6 以上用于构建皮卡丘服务。Python3.9 以上用于运行伊布服务。Docker可选但强烈建议安装。Docker Compose 可以帮助你一条命令启动两个服务。HTTP 客户端curl、Postman 或 Apifox 均可。需要说明的是本文不准备把所有版本号写死因为工具链更新太快。如果你用的是 Spring Boot 3.x请将 javax 包名替换为 jakarta如果用 Python 3.12httpx 和 uvicorn 的安装命令不变。先创建项目根目录。这里我们用pokemon-academy作为总目录下面再分成两个子项目。mkdir -p pokemon-academy/pikachu-service mkdir -p pokemon-academy/eevee-service cd pokemon-academy这里的pikachu-service是皮卡丘服务eevee-service是伊布服务。为了避免 IDE 或系统混淆两个项目建议每个子目录都独立初始化不要混在一起。接下来你需要确保 Maven 和 pip 都能正常使用。在终端中执行下面的检查命令。java -version mvn -version python --version pip --version docker --version docker compose version如果某个命令提示不存在先安装对应工具再继续。Docker Compose 不是必须项但如果你希望体验“一键启动两个服务”的效果建议提前安装。后面的示例会展示完整的 docker-compose.yml不想用 Docker 的话也可以分别在两个目录中启动服务只是需要一个终端窗口运行一个服务。4. 整体架构与数据契约设计在写代码前先设计整体架构。这是很多初学者最容易跳过的一步但也是“初次接近”的关键。4.1 架构设计整体结构非常简单一共涉及三个角色。皮卡丘服务Spring Boot开放GET /api/v1/pokemon/{code}接口根据精灵编号返回档案信息。伊布服务FastAPI开放GET /api/v1/analyze/{code}接口内部调用皮卡丘服务获取档案信息再附加一段分析文本返回。客户端可以是 curl 或浏览器直接请求伊布服务验证最终效果。这种设计的好处是伊布服务对外提供了一个“增值接口”而真正的数据来源仍然是皮卡丘服务。两个服务各司其职一个负责数据维护一个负责数据分析。后续就算你把伊布服务整个替换掉只要数据分析能力不变对调用方的影响也能控制在最小范围。4.2 先定契约再写代码接下来是关键步骤数据契约。皮卡丘服务返回的 JSON 结构我们定义为{ code: 025, name: 皮卡丘, type: 电, height: 0.4, weight: 6.0, trainer: 小智 }伊布服务收到这份数据后会追加分析结果并返回给客户端{ code: 025, name: 皮卡丘, type: 电, analysis: 速度与特攻潜力突出适合在电属性队伍中作为高速压制手出场。, suggestion: 优先培养速度和特攻配招可考虑十万伏特与高速移动。 }这里有几个细节值得注意。第一字段名全部使用小驼峰命名因为在 JSON 中这是最通用、最不容易出错的风格。第二所有字段都是字符串或数字没有使用嵌套对象目的是降低初次联调的理解成本。第三我们预留了analysis和suggestion两个扩展字段这正是伊布服务的价值所在。这里其实隐藏了一个工程经验接口契约最好写在文档里而不是只存在某人的脑子里。你可以使用 Swagger、Apifox或者简单的 Markdown 文档。对于本集示例你用注释写清格式即可但养成“先写契约”的习惯后后续接 Kafka、Dubbo、gRPC 都会受益。5. 代码实现皮卡丘服务Java Spring Boot现在开始写皮卡丘服务的代码。这个服务负责暴露精灵档案接口本集暂时不连接数据库而是使用一个内存 Map 充当数据源。这样可以把注意力集中在接口定义和跨服务调用上。5.1 创建 Maven 项目结构在pikachu-service目录下创建标准 Maven 结构cd pikachu-service mkdir -p src/main/java/com/pokemon/academy/pikachu mkdir -p src/main/resources5.2 编写 pom.xml在pikachu-service/pom.xml中添加 Spring Boot 依赖。为了减少配置我们使用 Spring Web 模块。?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent groupIdcom.pokemon.academy/groupId artifactIdpikachu-service/artifactId version1.0.0/version namepikachu-service/name description皮卡丘服务精灵档案服务/description properties java.version11/java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency /dependencies build plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId /plugin /plugins /build /project这个 pom.xml 依赖了两个核心模块Spring Web 用来提供 REST APIValidation 用来做参数校验。为了保持代码简洁我并没有引入数据库和 MyBatis因为本集的主角是“服务间通信”。5.3 编写启动类创建主启动类// 文件路径src/main/java/com/pokemon/academy/pikachu/PikachuApplication.java package com.pokemon.academy.pikachu; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; SpringBootApplication public class PikachuApplication { public static void main(String[] args) { SpringApplication.run(PikachuApplication.class, args); } }SpringBootApplication是 Spring Boot 的核心注解它等价于Configuration、EnableAutoConfiguration和ComponentScan三个注解的组合。这里不需要额外配置扫描包因为启动类位于根包下。5.4 编写实体类与数据访问层定义精灵档案实体// 文件路径src/main/java/com/pokemon/academy/pikachu/model/Pokemon.java package com.pokemon.academy.pikachu.model; public class Pokemon { private String code; private String name; private String type; private Double height; private Double weight; private String trainer; public Pokemon() { } public Pokemon(String code, String name, String type, Double height, Double weight, String trainer) { this.code code; this.name name; this.type type; this.height height; this.weight weight; this.trainer trainer; } public String getCode() { return code; } public void setCode(String code) { this.code code; } public String getName() { return name; } public void setName(String name) { this.name name; } public String getType() { return type; } public void setType(String type) { this.type type; } public Double getHeight() { return height; } public void setHeight(Double height) { this.height height; } public Double getWeight() { return weight; } public void setWeight(Double weight) { this.weight weight; } public String getTrainer() { return trainer; } public void setTrainer(String trainer) { this.trainer trainer; } }这里采用最朴素的 POJO 方式。如果你使用 Lombok可以用Data注解简化但为了让初学者不被额外插件干扰本文保留完整的 getter 和 setter。接下来创建数据仓库类。本集使用一个线程安全的并发 Map 来模拟数据库// 文件路径src/main/java/com/pokemon/academy/pikachu/repository/PokemonRepository.java package com.pokemon.academy.pikachu.repository; import com.pokemon.academy.pikachu.model.Pokemon; import org.springframework.stereotype.Repository; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; Repository public class PokemonRepository { private final MapString, Pokemon store new ConcurrentHashMap(); public PokemonRepository() { store.put(025, new Pokemon(025, 皮卡丘, 电, 0.4, 6.0, 小智)); store.put(133, new Pokemon(133, 伊布, 一般, 0.3, 6.5, 小春)); } public Pokemon findByCode(String code) { return store.get(code); } }这里给025和133两条数据正好对应皮卡丘和伊布。后续如果要做数据库版本把PokemonRepository换成 MyBatis 或 JPA 实现即可对外接口不用变。5.5 编写 Controller接下来是皮卡丘服务对外暴露的接口// 文件路径src/main/java/com/pokemon/academy/pikachu/controller/PokemonController.java package com.pokemon.academy.pikachu.controller; import com.pokemon.academy.pikachu.model.Pokemon; import com.pokemon.academy.pikachu.repository.PokemonRepository; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; RestController RequestMapping(/api/v1/pokemon) public class PokemonController { private final PokemonRepository pokemonRepository; public PokemonController(PokemonRepository pokemonRepository) { this.pokemonRepository pokemonRepository; } GetMapping(/{code}) public ResponseEntityPokemon getPokemon(PathVariable String code) { Pokemon pokemon pokemonRepository.findByCode(code); if (pokemon null) { return ResponseEntity.notFound().build(); } return ResponseEntity.ok(pokemon); } }这段代码的含义很直接用户通过GET /api/v1/pokemon/025获取编号为 025 的精灵信息。如果查不到数据就返回 404而不是返回一个空对象。这个设计很重要因为伊布服务可以根据 404 状态码判断“这个精灵不存在”从而返回更友好的提示。5.6 配置跨域与端口在pikachu-service/src/main/resources/application.yml中配置端口和跨域规则server: port: 8081 spring: application: name: pikachu-service跨域配置我建议用 Java 配置类单独管理而不是在 Controller 上加CrossOrigin。因为后续接口越来越多时统一配置更易维护。// 文件路径src/main/java/com/pokemon/academy/pikachu/config/CorsConfig.java package com.pokemon.academy.pikachu.config; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOrigins(*) .allowedMethods(GET, POST, PUT, DELETE) .allowedHeaders(*) .maxAge(3600); } }注意这里的allowedOrigins(*)表示允许所有来源适合开发环境。生产环境你应当把它改成具体的域名或服务名。如果你使用的是 Spring Boot 3.x跨域类名和方法签名基本一致不受影响。5.7 启动皮卡丘服务在pikachu-service目录下执行mvn spring-boot:run看到类似下面的日志表示启动成功Tomcat started on port(s): 8081 (http)然后用 curl 验证接口curl http://localhost:8081/api/v1/pokemon/025预期返回{code:025,name:皮卡丘,type:电,height:0.4,weight:6.0,trainer:小智}到这里皮卡丘服务已经准备好了。接下来我们让伊布服务学会“找到皮卡丘”。6. 代码实现伊布服务Python FastAPI伊布服务使用 Python FastAPI 编写因为 FastAPI 的异步特性和自动生成接口文档的能力很适合做数据分析类的服务。6.1 创建虚拟环境与安装依赖在eevee-service目录下创建虚拟环境并安装依赖。cd eevee-service python -m venv venv source venv/bin/activate # Windows 用户使用 venv\Scripts\activate pip install fastapi uvicorn httpx python-dotenv安装的四个库分别负责什么fastapiWeb 框架提供路由与参数校验。uvicornASGI 服务器用于启动 FastAPI 应用。httpx现代 Python HTTP 客户端用于调用皮卡丘服务。python-dotenv读取.env配置文件。6.2 编写主应用创建main.py# 文件路径eevee-service/main.py import os import httpx from dotenv import load_dotenv from fastapi import FastAPI, HTTPException load_dotenv() PIKACHU_SERVICE_URL os.getenv(PIKACHU_SERVICE_URL, http://localhost:8081) app FastAPI(title伊布服务, version1.0.0) app.get(/api/v1/analyze/{code}) async def analyze_pokemon(code: str): 根据精灵编号获取档案并生成分析与培养建议。 async with httpx.AsyncClient(timeout5.0) as client: response await client.get(f{PIKACHU_SERVICE_URL}/api/v1/pokemon/{code}) if response.status_code 404: raise HTTPException(status_code404, detail精灵不存在请检查编号) response.raise_for_status() pokemon response.json() analysis generate_analysis(pokemon) suggestion generate_suggestion(pokemon) return { code: pokemon[code], name: pokemon[name], type: pokemon[type], analysis: analysis, suggestion: suggestion } def generate_analysis(pokemon: dict) - str: 根据精灵属性生成简单的分析文本。 if pokemon[type] 电: return 速度与特攻潜力突出适合在电属性队伍中作为高速压制手出场。 if pokemon[type] 一般: return 能力均衡可根据队伍需求选择不同进化路线练习成本较低。 return 属性能力有待更多数据验证建议在实战中持续观察。 def generate_suggestion(pokemon: dict) - str: if pokemon[type] 电: return 优先培养速度和特攻配招可考虑十万伏特与高速移动。 if pokemon[type] 一般: return 建议优先规划进化方向再根据最终形态分配培养资源。 return 建议结合具体技能池选择培养重点。这段代码有几个关键点。第一httpx.AsyncClient放在async with里可以确保连接被正确关闭。第二超时时间设置为 5 秒避免皮卡丘服务无响应时伊布服务一直挂起。第三先判断 404再调用raise_for_status()这样其他 HTTP 错误也能被统一处理。这里需要注意httpx的raise_for_status()会在响应状态码为 4xx 或 5xx 时抛出异常。我们单独处理 404是为了给客户端返回更友好的错误信息其他错误则交给 FastAPI 的异常机制处理。6.3 创建环境变量配置文件创建.env文件# 文件路径eevee-service/.env PIKACHU_SERVICE_URLhttp://localhost:8081使用.env管理服务地址是推荐做法。后续如果皮卡丘服务部署到其他环境只需要修改这个文件不需要改动代码。6.4 启动伊布服务在eevee-service目录下执行uvicorn main:app --host 0.0.0.0 --port 8082 --reload这里使用了--reload参数开发阶段修改代码后会自动重启。启动日志中会显示Uvicorn running on http://0.0.0.0:8082如果你在浏览器中打开http://localhost:8082/docs能看到 FastAPI 自动生成的 Swagger 文档。这也是 FastAPI 非常受欢迎的原因之一接口文档不需要额外维护。7. 用 Docker Compose 让两个服务一起运行虽然你已经可以在两个终端窗口中分别启动服务但为了让项目更具可复现性我会用 Docker Compose 把它们编排在一起。7.1 为皮卡丘服务编写 Dockerfile在pikachu-service目录下创建Dockerfile# 文件路径pikachu-service/Dockerfile FROM maven:3.8-openjdk-11 AS build COPY src /home/app/src COPY pom.xml /home/app/pom.xml WORKDIR /home/app RUN mvn clean package -DskipTests FROM openjdk:11-jre-slim COPY --frombuild /home/app/target/pikachu-service-1.0.0.jar /app/pikachu-service.jar EXPOSE 8081 ENTRYPOINT [java, -jar, /app/pikachu-service.jar]这个 Dockerfile 使用了两阶段构建。第一阶段用 Maven 镜像编译项目第二阶段只保留 JRE 和生成的 jar 包这样可以显著减小镜像体积。如果你的环境是 ARM 架构比如 Apple Silicon可以把基础镜像换成maven:3.8-openjdk-11或适配 ARM 的版本但有部分旧镜像在 ARM 上可能不够稳定遇到时可以换eclipse-temurin:11-jre系列。7.2 编写 docker-compose.yml在项目根目录pokemon-academy下创建docker-compose.ymlversion: 3.8 services: pikachu-service: build: ./pikachu-service container_name: pikachu-service ports: - 8081:8081 networks: - pokemon-network eevee-service: build: ./eevee-service container_name: eevee-service ports: - 8082:8082 environment: - PIKACHU_SERVICE_URLhttp://pikachu-service:8081 depends_on: - pikachu-service networks: - pokemon-network networks: pokemon-network: driver: bridge注意这里的一个关键点当两个服务同时在 Docker Compose 网络中运行时伊布服务通过服务名pikachu-service访问皮卡丘服务而不是localhost。因为在容器网络里每个容器有自己的网络命名空间localhost指向的是容器自身。如果你直接在宿主机上运行两个进程才用localhost。7.3 为伊布服务编写 Dockerfile在eevee-service目录下创建Dockerfile# 文件路径eevee-service/Dockerfile FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 8082 CMD [uvicorn, main:app, --host, 0.0.0.0, --port, 8082]对应的requirements.txt内容如下fastapi uvicorn httpx python-dotenv这里可以固定版本号也可以让 pip 自动选择当前可用版本。对于生产项目建议把版本固定下来比如fastapi0.104.1避免环境升级导致意外问题。本文没有固定为某个版本因为 Python 包更新较快你按照本机实际安装结果为准即可。7.4 一键启动在项目根目录执行docker compose up --build这个命令会构建两个镜像并启动容器。看到两个容器状态均为 running 之后就说明整个环境已经准备就绪。8. 运行结果与效果验证联调是否成功不能只看服务是否启动还要执行一次完整的请求链路。8.1 通过伊布服务访问接口先请求伊布服务curl http://localhost:8082/api/v1/analyze/025预期响应{ code: 025, name: 皮卡丘, type: 电, analysis: 速度与特攻潜力突出适合在电属性队伍中作为高速压制手出场。, suggestion: 优先培养速度和特攻配招可考虑十万伏特与高速移动。 }这个响应说明什么说明伊布服务成功调用了皮卡丘服务的接口拿到了name和type并在此基础上生成了新的分析字段。整条链路完整跑通。8.2 验证 404 场景再请求一个不存在的精灵编号curl http://localhost:8082/api/v1/analyze/999预期响应{ detail: 精灵不存在请检查编号 }这里的detail是 FastAPI 的默认错误格式。这个用例很有价值它验证了异常处理链路是通的伊布服务收到皮卡丘服务的 404 后正确转换为自己的 404 响应。8.3 验证直接访问皮卡丘服务直接请求皮卡丘服务接口curl http://localhost:8081/api/v1/pokemon/133预期响应{ code: 133, name: 伊布, type: 一般, height: 0.3, weight: 6.5, trainer: 小春 }这条验证的意义在于皮卡丘服务作为独立服务即使不被伊布服务调用自身也能正常工作。这是微服务“独立部署”的基本要求。8.4 判断成功的标准综合来看这次联调成功的判断标准可以总结为以下几条。两个服务能分别独立启动且端口不发生冲突。皮卡丘接口能返回正确的 JSON 数据。伊布接口能正确调用皮卡丘接口并返回增强后的数据。不存在的资源能返回 404而不是空数据或 500 错误。如果使用 Docker Compose两个容器之间能通过服务名互相访问。如果以上几点都能满足说明两个“精灵”已经完成了初次接近可以进入下一阶段了。9. 常见问题与排查思路初次联调最容易出现的问题很多不是“代码逻辑错了”而是网络、配置、依赖层面的坑。下面列出一个排查表格覆盖本文例子里最可能遇到的场景。问题现象可能原因排查方式解决方案伊布服务启动时报ModuleNotFoundError: No module named httpx依赖没有安装到当前虚拟环境检查当前 Python 环境是否为 venv执行pip install httpx或重新安装 requirements伊布接口返回 500皮卡丘服务未启动或端口不对先 curl 皮卡丘接口确认服务可访问启动皮卡丘服务或修改.env中的服务地址伊布接口返回 502 或连接拒绝Docker Compose 中使用了localhost访问对方查看 eevee-service 的环境变量改为http://pikachu-service:8081请求成功但返回的字段名对不上两个服务的数据契约不一致分别打印双方 JSON 接口响应统一字段命名以接口契约为准中文乱码控制台或 HTTP 响应编码不正确检查 IDE 与终端的字符编码设置 UTF-8并在 HTTP 响应头中确认Content-Type: application/json; charsetutf-8Docker 构建速度慢基础镜像较大且没有缓存查看构建日志使用国内镜像源或换用更精简的基础镜像端口冲突本机已有服务占用 8081 或 8082执行lsof -i:8081Mac/Linux或netstat -anoWindows释放端口或修改配置文件中的端口号FastAPI 接口 doc 打不开启动时没有带--reload或 URL 输入错误确认启动日志中的监听地址访问http://localhost:8082/docs在这些问题里最常见的其实是第一种和第三种。第一种是 Python 环境混乱新手经常把包装到了系统全局 Python而不是虚拟环境。第三种是 Docker 网络与本地网络的差异很多人在本地跑通了一上 Docker 就失败主要原因就是localhost指向错了位置。还有一个容易忽略的问题超时。如果皮卡丘服务响应很慢伊布服务的httpx客户端设置了 5 秒超时就可能出现请求成功但客户端提前放弃的情况。这时候你需要先排查皮卡丘服务为什么慢而不是盲目增大超时时间。10. 最佳实践与工程建议跑通示例只是第一步真正在项目里实践时有几条工程建议值得在写代码前就记住。10.1 先定义接口契约再并行开发如果你不是一个人在写代码而是皮卡丘服务由一个同学负责伊布服务由另一个同学负责那么两个团队至少要抽出十分钟把接口的路径、请求参数、响应字段、错误码约定清楚。最理想的方式是用一份 Markdown 文档或 Apifox 接口文档作为唯一事实来源。否则两个人对字段名的理解稍有偏差联调时就要花大量时间互相质疑。10.2 设置合理的超时和重试策略这一集里伊布服务调用了外部依赖皮卡丘服务。只要涉及调用外部服务就必须思考一个问题如果对方一直不返回怎么办httpx的timeout5.0是一种兜底策略。更完善的做法是设置重试机制但要谨慎因为对于查询类接口重试通常是安全的对于写操作可能需要考虑幂等性。本集的例子是只读查询所以可以安全重试但我没有在代码中加重试原因是“初次接近”阶段先把最简链路跑通就好。10.3 不要直接暴露内部服务端口在真实项目中皮卡丘服务不一定适合对公网直接开放。通常的做法是让内部服务只监听内网地址或者不映射到宿主机端口。在 Docker Compose 中你可以只把伊布服务的端口暴露给宿主机皮卡丘服务仅在pokemon-network网络内通信。这样既完成了服务间调用又缩小了攻击面。10.4 规范错误处理接口返回错误时不要只返回一个空对象或裸的 500。像本文示例一样用 HTTP 状态码表达错误类型并附上可读的错误说明。这样调用方可以准确判断是“资源不存在”还是“服务内部异常”从而决定是否重试、是否告警。10.5 日志与链路追踪意识现在只有两个服务排查问题还比较简单。当服务数量增加后你需要知道一个请求从伊布服务到皮卡丘服务到底在哪一步出了问题。简单的做法是在关键位置打印日志并带上一个request_id。更复杂的做法是接入 SkyWalking、Zipkin 这类链路追踪系统。这一集不需要引入但你至少要养成在调用外部接口前后打印日志的习惯。10.6 数据契约版本兼容接口一旦被多个服务依赖就不要轻易删除字段。如果伊布服务不再需要某个字段宁可保留也不要立刻删除。因为你不知道是否还有其他服务在调用同一个接口。对于更复杂的场景可以在接口路径中加入版本号例如/api/v2/pokemon而不是直接修改/api/v1/pokemon。11. 总结与下一集预告这一集的核心收获不是写出两个接口而是建立起“服务之间通过网络接口协作”的基本思维。皮卡丘服务负责数据伊布服务负责分析两者通过一个明确的数据契约完成第一次握手。整个过程里代码只占一半工作量另一半是在设计契约、处理异常、考虑超时和规划部署方式。建议你把这个示例完整跑通后做三件额外的小练习。第一在伊布服务中新增一个接口根据type返回所有同属性的精灵列表。这需要让皮卡丘服务增加一个按属性查询的接口你会再次体会契约变化对两端的影响。第二把皮卡丘服务的内存数据源替换成 H2 或 MySQL验证接口行为是否保持一致。这能帮你理解“数据存储和接口实现解耦”的意义。第三尝试把两个服务部署到同一个 Docker 网络但只暴露伊布服务端口观察安全性差异。下一集会更有意思我们会让“接近”变得更加动态皮卡丘与伊布通过注册中心互相发现不再写死地址。那意味着即使皮卡丘服务迁移到另一台机器伊布服务也不需要改任何配置。为了让那时候更从容现在先把第一次联调中的每一个细节都搞懂。如果遇到问题重点检查网络连通性和数据契约这两个方向多数情况下问题都出在那里。