前段时间接了个需求:在 Java 后台服务里加一个「按城市名查实时天气」的功能,用户输入的还不是国内城市,全是海外城市。第一反应就是调百度天气 API,毕竟国内天气接口里它对开发者最友好、文档清楚、免费配额也够用。真正动手才发现,坑全藏在「海外城市」这四个字后面。这篇文章把我从申请密钥到跑通全链路的完整过程写出来,包括接口选型、城市名转经纬度、DTO 设计、缓存和线程池优化,还有我实际踩过的 5 个坑,应该能帮正在做类似对接的 Java 开发者少走不少弯路。
1. 项目需求拆解:从调用一个接口到设计一条链路
1.1 选型:为什么最后用了百度天气 API
国内做天气对接,可选的方案其实不少:和风天气、彩云天气、高德天气、百度天气各有各的生态位。我选百度天气 API 有几个很实际的理由。
第一,它挂在百度地图开放平台下,很多团队做地图相关业务时早就有了百度地图的开发者账号,复用同一个账号体系,不需要额外去别家注册实名认证。我这次就是复用公司已有的百度地图应用,直接在里面加一个天气服务权限就能调。
第二,它的接口设计非常「粗暴直接」,一个 GET 请求,传城市编码或者经纬度,返回 JSON,没有任何复杂的 OAuth 签名流程,只需要在 URL 里带一个 AK(API Key)。对 Java 后端来说,几乎零学习成本。
第三,它的免费配额对个人项目和中小型应用完全够用。天气数据是低频数据,一个用户一天查几次就到顶了,不像位置上报这种高频接口压力大。
当然也有短板:百度天气的海外城市数据密度不如专门做全球天气的服务商,但城市级别的实时天气展示完全够用。我这次的需求就是「用户在 App 里选一个海外城市,看到当前温度和天气现象」,这个量级它撑得住。
从面试的角度说,这类第三方 HTTP 接口对接考察的核心无非是四个点:HTTP 客户端怎么选、JSON 怎么解析、异常怎么兜底、并发和缓存怎么做。这套代码写完,这几个知识点全都能讲清楚。
1.2 海外城市查询的真正难点是「城市名怎么变成坐标」
如果只查国内城市,百度天气 API 支持传行政区划编码(district_id),比如北京是 110000,查起来简单直接。但海外城市没有这套编码,直接传「伦敦」「纽约」这种中文名大概率返回空结果。
这就是海外城市查询和国内查询最本质的区别:海外城市必须用经纬度坐标查。接口文档里有一个 location 参数,格式是「经度,纬度」,它是走坐标点附近天气数据匹配的。
所以原本「城市名 -> 天气」的一步调用,变成了两步:
- 先把城市名通过地理编码接口换算成经纬度。
- 再用经纬度调天气接口拿实时天气。
第一步才是这个项目真正的核心链路,也是最容易翻车的地方。地理编码接口本身不复杂,复杂的是城市名到底怎么传才能被正确识别。我测试下来,「伦敦」这种通用译名能识别,但有些城市的译名有差异,比如「洛杉矶」没问题,但「旧金山」和「圣弗朗西斯科」可能指向不同结果。
更稳妥的方式是直接传英文城市名加国家,比如 London, UK、New York, US,识别准确率明显更高。海外城市的用户输入习惯往往也偏英文,我最后在接口层做了「优先英文名 -> 回退中文名」的识别策略,这个细节后面细说。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 开工前准备:环境、依赖与 AK 申请
2.1 JDK、Maven 和两个依赖就够
开发环境我用的是 JDK 11 + Maven,为什么选 11 而不是 8,一个核心原因:JDK 11 开始提供了官方 java.net.http.HttpClient,支持同步和异步两种模式,连接超时和读取超时都有现成 API,不用再额外引 OkHttp 或者 Apache HttpClient。这对只想快速对接第三方接口的场景来说,省了一个依赖就是省了一份维护成本。
如果你项目还在 JDK 8,也别慌,用 OkHttp 替代,代码结构几乎不用变,核心逻辑还是那几行。
Maven 依赖就加一个 Gson,用来做 JSON 解析。我没有选 Fastjson,原因很简单,Fastjson 历史上修漏洞修得太频繁,公司安全扫描经常报红,Gson 足够稳定,性能对这个场景也不是瓶颈,一天几万次调用的量级,Gson 完全抗得住。
xml复制<dependencies>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.10.1</version>
</dependency>
</dependencies>
就这一个依赖,配合 JDK 自带的 HttpClient,整个项目的第三方依赖只有 Gson,清爽得不像一个集成项目。
2.2 AK 申请和「服务端」应用类型
百度天气 API 的密钥在百度地图开放平台申请,注意不是百度智能云,是百度地图开放平台(lbsyun.baidu.com),这两个平台的账号体系虽然能打通,但应用和密钥是分开管理的。
流程不复杂:
- 注册并完成个人开发者实名认证。
- 进入控制台,创建应用。
- 应用类型选「服务端」,这一步很关键,选错后面会出问题。
- 创建完成后拿到 AK(API Key),一串 32 位的字符串。
为什么强调应用类型选「服务端」?因为如果选「浏览器端」,平台会强制校验 Referer 白名单,也就是只有指定来源的网页请求才能带这个 AK 访问。后端 Java 服务调用没有浏览器来源,Referer 校验会直接拒绝。我见过太多人卡在这一步,拿着浏览器端 AK 在 Postman 里调接口,返回 401 一头雾水。
拿到 AK 之后,还要确认这个应用有没有「天气服务」的权限。在控制台的应用详情页里可以看到已经开通的服务列表,如果是新建的应用,通常默认没有天气权限,需要在「服务列表」里找到天气服务并申请开通。这个权限审核一般是自动的,实名认证通过后很快就生效,不用等人工审核。
3. 完整实现:从城市名到天气结果的完整调用链
3.1 第一步:地理编码,把城市名换成经纬度
地理编码用的是百度地图的 geocoding 接口,请求格式如下:
code复制GET https://api.map.baidu.com/geocoding/v3/?address=London,UK&output=json&ak=你的AK
address 参数务必做 URL 编码,尤其是传入中文城市名的时候,不编码直接拼 URL,大概率会因为特殊字符解析失败或者被服务端拒绝。我用 URLEncoder.encode(cityName, StandardCharsets.UTF_8) 处理,这是最容易漏掉的一步。
响应是一个标准 JSON 结构,核心字段如下:
json复制{
"status": 0,
"result": {
"location": {
"lng": -0.1278,
"lat": 51.5074
}
}
}
status 为 0 表示成功,location 里的 lng 和 lat 就是城市中心点的经纬度。注意这里返回的是城市中心点,不是具体街道的坐标。对天气查询来说完全够用,因为天气服务本质是找坐标附近的观测站数据,城市级别的精度已经绰绰有余。
对应的 DTO 就三个类:
java复制public class GeoResponse {
public int status;
public String message;
public GeoResult result;
public static class GeoResult {
public GeoLocation location;
}
public static class GeoLocation {
public double lng;
public double lat;
}
}
然后封装一个地理编码方法:
java复制public GeoPoint geoCode(String cityName) throws IOException, InterruptedException {
String encodedAddress = URLEncoder.encode(cityName, StandardCharsets.UTF_8);
String url = "https://api.map.baidu.com/geocoding/v3/?address="
+ encodedAddress + "&output=json&ak=" + AK;
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(5))
.GET()
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
GeoResponse geoResponse = new Gson().fromJson(response.body(), GeoResponse.class);
if (geoResponse.status != 0) {
throw new RuntimeException("地理编码失败,status=" + geoResponse.status
+ ", message=" + geoResponse.message);
}
GeoPoint point = new GeoPoint();
point.lng = geoResponse.result.location.lng;
point.lat = geoResponse.result.location.lat;
return point;
}
GeoPoint 是我自定义的一个内部类,就两个 double 字段,代替 Map 存经纬度,可读性更好。
这里有个经验:如果 status 返回 0 但 result 为 null,说明地址太模糊,服务端没匹配到有效位置。我建议在解析前做一次空判断,避免 NPE 让调用方收到非常难排查的报错。加一行 if (geoResponse.result == null || geoResponse.result.location == null) 就能提前把错误信息抛清晰。
3.2 第二步:天气查询接口与 DTO 解析
拿到经纬度之后,就可以调天气接口了:
code复制GET https://api.map.baidu.com/weather/v3/?location=-0.1278,51.5074&data_type=now&output=json&ak=你的AK
参数说明:
location:经纬度,格式是「经度,纬度」,注意顺序不能反。data_type:类型,now表示实时天气,forecast表示预报,all表示全部。我只需要实时天气,传now就够了,响应体更小,解析更快。output:json,默认就是 json,显式写出来更明确。ak:密钥。
响应结构:
json复制{
"status": 0,
"message": "success",
"result": {
"location": {
"lng": -0.1278,
"lat": 51.5074
},
"now": {
"tmp": "15",
"cond_txt": "多云",
"wind_dir": "西南风",
"wind_sc": "3",
"humidity": "72"
},
"last_update": "2024-01-15T10:30+08:00"
}
}
注意 cond_txt 这个字段,即使查询的是海外城市,返回的天气现象描述仍然是中文,比如伦敦「多云」、纽约「小雨」。如果产品需要面向海外用户展示,就得拿这个中文文本做一层国际化映射,或者用 cond_code 字段对接自己的多语言文案表。我在扩展优化部分会再提这个点。
对应的 DTO 设计,我用了 Gson 的 @SerializedName 注解把 JSON 字段名和 Java 字段名解耦,这样 Java 侧可以用标准的驼峰命名,代码看起来更符合 Java 习惯:
java复制public class WeatherResponse {
public int status;
public String message;
public WeatherResult result;
public static class WeatherResult {
public LocationInfo location;
public NowWeather now;
public String last_update;
}
public static class LocationInfo {
public double lng;
public double lat;
}
public static class NowWeather {
@SerializedName("tmp")
public String temperature;
@SerializedName("cond_txt")
public String conditionText;
@SerializedName("wind_dir")
public String windDir;
@SerializedName("wind_sc")
public String windScale;
@SerializedName("humidity")
public String humidity;
}
}
字段类型全部用 String,看起来有点偷懒,但这是刻意为之。天气接口返回的温度可能带小数点、湿度可能是数字也可能是字符串,直接用 String 接收,避免 Gson 在做类型转换时因为类型不匹配直接抛异常。反正展示层最终也要转字符串,Java 侧用 String 最稳。
3.3 第三步:组合成一个直接能用的 WeatherClient
有了上面两步,组合逻辑就非常清晰了:
- 根据城市名拿到经纬度。
- 根据经纬度查实时天气。
- 校验 status,返回 DTO。
我把它封装成了一个独立的 WeatherClient 类,对外只暴露一个方法:
java复制public class WeatherClient {
private static final String GEOCODE_URL = "https://api.map.baidu.com/geocoding/v3/";
private static final String WEATHER_URL = "https://api.map.baidu.com/weather/v3/";
private static final String AK = "你的AK";
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
private final Gson gson = new Gson();
public WeatherResponse getWeatherByCity(String cityName) {
GeoPoint geoPoint = geoCode(cityName);
return fetchWeather(geoPoint.lng, geoPoint.lat);
}
private GeoPoint geoCode(String cityName) {
// 见 3.1 节代码,省略重复
}
private WeatherResponse fetchWeather(double lng, double lat) {
try {
String url = WEATHER_URL + "?location=" + lng + "," + lat
+ "&data_type=now&output=json&ak=" + AK;
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(5))
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
WeatherResponse weatherResponse = gson.fromJson(response.body(), WeatherResponse.class);
if (weatherResponse.status != 0) {
throw new RuntimeException("天气查询失败,status=" + weatherResponse.status
+ ", message=" + weatherResponse.message);
}
return weatherResponse;
} catch (IOException | InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("天气接口调用异常", e);
}
}
public static class GeoPoint {
public double lng;
public double lat;
}
}
调用方式:
java复制WeatherClient client = new WeatherClient();
WeatherResponse response = client.getWeatherByCity("London,UK");
System.out.println(response.result.now.temperature);
System.out.println(response.result.now.conditionText);
这套代码已经能解决「输入海外城市名,输出实时天气」的核心诉求。但拿到第一版之后,我一想,如果用户批量查 10 个城市,每个城市要打两次 HTTP 请求,总共 20 次,而且完全没有任何缓存,体验和成本都扛不住。这就是下一阶段要处理的问题。
4. 批量场景优化:缓存、线程池与统一异常处理
4.1 本地缓存:避免 5 分钟内重复打 API
天气数据有一个天然特性:短时间内不会剧烈变化。一个城市的实时温度在五分钟内的波动一般不超过 1 到 2 度,所以做一个短时本地缓存,能挡掉大量重复请求。
我用了一个带过期时间的 ConcurrentHashMap 实现,简单直接,不引入 Caffeine 这类本地缓存框架,避免过度设计:
java复制public class WeatherCache {
private static final long TTL_MILLIS = 5 * 60 * 1000L;
private final ConcurrentHashMap<String, CacheEntry> cache = new ConcurrentHashMap<>();
public WeatherResponse get(String key, Supplier<WeatherResponse> loader) {
CacheEntry entry = cache.get(key);
long now = System.currentTimeMillis();
if (entry != null && now - entry.timestamp < TTL_MILLIS) {
return entry.data;
}
WeatherResponse data = loader.get();
cache.put(key, new CacheEntry(data, now));
return data;
}
private static class CacheEntry {
final WeatherResponse data;
final long timestamp;
CacheEntry(WeatherResponse data, long timestamp) {
this.data = data;
this.timestamp = timestamp;
}
}
}
然后把 WeatherClient 里对天气接口的调用包一层缓存:
java复制public WeatherResponse getWeatherByCityWithCache(String cityName) {
return cache.get(cityName, () -> getWeatherByCity(cityName));
}
缓存 key 直接用城市名字符串,简单粗暴。如果同一个城市有中文名和英文名两种传法,会出现缓存双份。实际场景中,我会在入口处统一把城市名标准化,比如统一按英文名查,中文名映射成英文名之后再过缓存,保证同一个城市只对应一个 key。
这里有一个小优化点,缓存里存的 WeatherResponse 是整个响应对象,包括 location 和 last_update,其实我们真正消费的只有 now 字段。如果堆内存吃紧,可以在缓存层只存 NowWeather,而不是整个响应,体积能缩小很多。
4.2 线程池并发查询和第三方 QPS 控制
批量查 50 个城市,逐个串行调接口,每个城市 2 次请求,总共 100 次 HTTP 往返,按每次 100 毫秒算,就是 10 秒,太慢了。用线程池并发打,延迟能压缩到 1 秒左右。
线程池我建议直接用 Executors.newFixedThreadPool,线程数控制在 4 到 8 之间,不要贪多。百度天气这类免费配额接口对单 IP 的 QPS 有限制,并发太高容易触发限流,拿到一堆 302/403 错误码。
java复制ExecutorService pool = Executors.newFixedThreadPool(4);
List<String> cities = Arrays.asList("London,UK", "New York,US", "Paris,FR");
List<Future<WeatherResponse>> futures = cities.stream()
.map(city -> pool.submit(() -> weatherClient.getWeatherByCityWithCache(city)))
.toList();
for (Future<WeatherResponse> future : futures) {
WeatherResponse response = future.get(10, TimeUnit.SECONDS);
System.out.println(response.result.now.temperature);
}
pool.shutdown();
如果多个服务实例同时跑这种代码,单机限流就变成分布式限流问题。小型项目用 Redis 做滑动窗口计数器就行,简单实用。更省事的做法是给百度官方提工单申请提高 QPS,但免费额度一般不会轻易放开,还是本地控流最实际。
关于并发下线程中断的处理,catch (InterruptedException e) 之后一定要 Thread.currentThread().interrupt() 把中断标志位恢复,否则线程池里的线程状态会变得很诡异,后续任务莫名被中断。这个细节很多代码里都没写,但写不好会在高并发下出很隐蔽的问题。
这部分的面试考察点也集中:线程池参数怎么定、Future.get 为什么要带超时时间、中断标志为什么要恢复。我在地理编码那段代码里用了 Thread.currentThread().interrupt(),其实就是这个原因。
5. 常见问题排查与避坑速查表
5.1 我实际踩过的 5 个坑
第一个坑:应用类型选错。这个前面提过,选成「浏览器端」之后,后端调用一律 401。排查时我一度以为是 AK 复制错了,反复核对好几遍才发现是应用类型的问题。创建应用时直接选「服务端」,别犹豫。
第二个坑:海外城市名直接传中文。我最初把「伦敦」「巴黎」直接作为 address 参数传给地理编码接口,一部分城市能解析出来,一部分不行。后来调整策略,优先传英文城市名加国家代码,识别率基本 100%。如果用户只能提供中文名,就在代码里维护一份中文译名到英文名的映射表,把这层翻译逻辑挡在接口调用之前。
第三个坑:cond_txt 返回中文,海外用户看不懂。当时产品说海外城市也要展示英文天气,结果接口返回的是「多云」而不是 "Cloudy"。后来我根据 cond_code 字段做了一层国际化映射,维护了一个 code 到多语言文案的 Map,问题才解决。做海外业务的同学,这个坑基本必踩。
第四个坑:HTTP 响应体没读全就解析。HttpClient 同步调用时,HttpResponse.BodyHandlers.ofString() 会一次性把响应体完整读进内存,这个不会有截断问题。但如果你换成 BodyHandlers.ofInputStream() 自己读流,不读完就关闭,或者只读了前 1KB 就尝试解析,JSON 必然解析失败。我早期写 HttpURLConnection 实现时踩过这个坑,换 HttpClient 之后省心不少。
第五个坑:大量缓存导致内存不够。这是真实发生过的,测试环境查了全国几千个城市,每个城市缓存了全量响应(data_type=all),包括 7 天预报和 24 小时逐小时预报,堆内存直接涨了几个 G。排查下来发现进程可能触发 OutOfMemoryError,日志里出现 java.lang.OutOfMemoryError: insufficient memory。解决办法三管齐下:接口只请求 now 字段、缓存只存需要的字段、给缓存容器加上限(比如最多缓存 1000 个城市,超出后按 LRU 淘汰)。扩容堆内存只是治标,限制缓存容量才是治本。
5.2 排查速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 接口返回 401 | AK 错误或应用类型为浏览器端 | 检查 AK 是否完整,确认应用类型为服务端 |
| status 为 301 | AK 不合法 | 重新生成 AK,确认没有多余空格 |
| status 为 302 | 配额用尽或 QPS 超限 | 检查控制台配额,本地增加限流和缓存 |
| 地理编码返回 result 为 null | 城市名无法识别 | 改传英文名 + 国家代码,或维护译名映射表 |
| 天气接口返回空数据 | 经纬度传反或 data_type 传错 | 确认 location 参数为「经度,纬度」,data_type 为 now |
| 并发高时大量超时 | 线程池过大触发限流 | 线程数降到 4~8,增加重试和退避 |
| 堆内存上涨明显 | 缓存了过多完整响应 | 缓存只保留下游需要的字段,限制缓存条目数 |
| JSON 解析报错 | Gson 类型不匹配 | 字段类型统一用 String,用 @SerializedName 映射 |
我一般会在项目里加一个诊断接口,暴露出最近一次调用的 status、message、耗时和缓存命中率,排查问题时有数据支撑,不用靠猜。
5.3 部署后别忘了监控配额消耗
免费配额虽然够用,但不是无限量。我建议上线前在控制台确认一下配额上限,然后在代码里做一个简单的每日调用计数,打印日志或者上报到监控系统。尤其是面向 C 端的产品,一次热点事件可能带来几万次查询,配额半小时被耗尽是很正常的事。提前加监控,比线上突然挂掉再排查从容得多。
再补一个对上游接口的兜底策略:如果地理编码接口或者天气接口连续失败超过 3 次,直接把错误信息抛给上层,由业务方决定是重试还是降级展示静态数据。千万别在底层写死循环重试,第三方接口一旦不可用,重试只会加重双方负担。
最后分享一个小技巧
如果产品要求海外城市展示天气现象时支持英文,不要在代码里写死几十个 if 判断。我建议你查一下百度天气返回的 cond_code 字段,把它当成 key,维护一个 code 到多语言文案的映射表,比如经典字段值对应的天气现象是有限的,整理上一百来个就够覆盖绝大多数场景。这样中英文切换只是查表的事,不用重复调接口。这个项目本身逻辑不复杂,但把海外城市名解析、数据缓存、并发控制、国际化这几个细节都处理到位,整体工程质量就能上一个台阶。
