概述
上一篇把 cloud-demo 拆成了 order-service 和 user-service 两个独立服务,遗留了一个尾巴:查询订单接口里的 user 字段永远是 null。这篇就把它填上------用最朴素的 RestTemplate 讲清楚微服务远程调用的本质:发一次 HTTP 请求。
纲要
- 问题回顾:
user字段为什么是 null,为什么不能直接查库解决 - 远程调用技术选型:RPC(Dubbo / gRPC)与 HTTP(RestTemplate / Feign)对比
- 远程调用的本质:像浏览器一样发 HTTP 请求
- 动手实现(三步)
- 在
OrderApplication启动类注册RestTemplateBean - 在
OrderService中用getForObject发起 GET 请求并反序列化 OrderController返回带用户信息的完整Order
- 在
- 完整调用链时序图:浏览器 → order-service → user-service → MySQL
- 服务提供者与服务消费者:角色是相对的
- 硬编码
localhost:8081的三个风险,引出注册中心 - 实战坑:URL 拼接、两份
User类字段不一致、超时未配置
user 为什么是 null
上一篇拆完服务,验证结果是这样的:
bash
curl http://localhost:8080/order/101
# {"id":101,"price":699900,"name":"Apple 苹果 iPhone 12 ","num":1,"userId":1,"user":null}
需求是"根据订单 ID 查询订单的同时,把订单所属的用户信息一起返回"。现在只做到了前半句。
有人会想:订单服务里直接写一条 SQL 查用户表不就完了?不行。上一篇已经定了规矩------数据独立,cloud_order 库里根本没有 tb_user 这张表,你想查也查不到。而且就算把表复制一份过来,用户模块的查询逻辑就被订单模块重复实现了一遍,拆分直接白做。
数据库这条路走不通,只剩一条:订单服务向用户服务发起远程调用 。用户服务已经对外暴露了 Restful 接口 GET /user/{id},浏览器里输入 http://localhost:8081/user/1 一定能拿到用户数据。既然浏览器能发这个请求,订单模块凭什么不能?问题就变成了:如何在 Java 代码里发 HTTP 请求。
远程调用方式怎么选
Java 里做服务间调用的技术大体分两类:
| 类别 | 代表技术 | 通信方式 | 特点 | 适用场景 |
|---|---|---|---|---|
| RPC | Dubbo、gRPC | 自定义二进制协议 | 性能高、传输体积小,但要求双方用同一套框架和序列化 | 公司内部、技术栈统一的系统 |
| HTTP | RestTemplate、Feign(OpenFeign) | 标准 HTTP + JSON | 语言无关、门槛低、调试方便,性能略逊 | 对外开放接口、异构系统、微服务入门 |
RPC 性能好但绑定性强;HTTP 胜在简单通用------只要对方暴露了 HTTP 接口,不管它是 Java 写的还是 Python 写的,都能调。这篇先用 RestTemplate 把远程调用的本质讲透,Feign 是后面篇章的升级方案。
动手:三步完成远程调用
改动只发生在 order-service,user-service 一行代码都不用动------它已经把接口提供好了。
tree
order-service/src/main/java/cn/itcast/order/
├── OrderApplication.java # 改动一:注册 RestTemplate Bean
├── web/OrderController.java # 不变,返回 Order(user 字段将不再是 null)
├── service/OrderService.java # 改动二:发起远程调用,填充 user
├── mapper/OrderMapper.java # 不变
└── pojo/
├── Order.java # 不变,user 字段派上用场
└── User.java # 不变,用于接收远程调用的返回值
注册 RestTemplate
RestTemplate 是 Spring 提供的 HTTP 客户端工具,各种 HTTP 请求都能发。它是个普通类,要交给 Spring 容器管理才能在业务代码里注入使用。@Bean 只能写在配置类里,而启动类本身就带 @SpringBootApplication(内含 @Configuration),所以直接写在启动类里最省事:
java
package cn.itcast.order;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.web.client.RestTemplate;
@MapperScan("cn.itcast.order.mapper")
@SpringBootApplication
public class OrderApplication {
public static void main(String[] args) {
SpringApplication.run(OrderApplication.class, args);
}
/**
* 注册 RestTemplate 到 Spring 容器,供业务代码注入使用
*/
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
在 OrderService 里发起远程调用
OrderService 的 queryOrderById 方法目前只查了订单。订单对象里带着 userId,拿着它去调用户服务的接口,结果填进 order.user 即可:
java
package cn.itcast.order.service;
import cn.itcast.order.mapper.OrderMapper;
import cn.itcast.order.pojo.Order;
import cn.itcast.order.pojo.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private RestTemplate restTemplate;
public Order queryOrderById(Long orderId) {
// 1.查询订单
Order order = orderMapper.findById(orderId);
// 2.利用 RestTemplate 向 user-service 发起 HTTP 远程调用,查询用户
String url = "http://localhost:8081/user/" + order.getUserId();
User user = restTemplate.getForObject(url, User.class);
// 3.将用户信息封装进订单对象
order.setUser(user);
// 4.返回
return order;
}
}
几个关键点:
getForObject对应 GET 请求;发 POST 用postForObject,API 命名和 HTTP 方法一一对应。- 第一个参数是 URL 字符串,效果和你在浏览器地址栏输入一模一样。
order.getUserId()从订单里动态取值,千万别写死成/user/1。 - 第二个参数告诉
RestTemplate返回值要转成什么类型。用户服务返回的是 JSON,RestTemplate会自动完成反序列化,拿到手的就是User对象,不用手动解析。
OrderController 返回完整 Order
Controller 层不需要任何改动,user 字段被 Service 填充后自然就带出来了。完整的调用链从入口看一遍:
java
package cn.itcast.order.web;
import cn.itcast.order.pojo.Order;
import cn.itcast.order.service.OrderService;
import org.springframework.beans.factory.annotation.Autowired;
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("order")
public class OrderController {
@Autowired
private OrderService orderService;
@GetMapping("{orderId}")
public Order queryOrderByUserId(@PathVariable("orderId") Long orderId) {
// 根据id查询订单并返回
return orderService.queryOrderById(orderId);
}
}
两个实体类,Order 里的 user 字段就是为这一刻准备的:
java
package cn.itcast.order.pojo;
import lombok.Data;
@Data
public class Order {
private Long id;
private Long price;
private String name;
private Integer num;
private Long userId;
private User user;
}
java
package cn.itcast.order.pojo;
import lombok.Data;
@Data
public class User {
private Long id;
private String username;
private String address;
}
User 在两个服务里各定义了一份,字段要和 user-service 返回的 JSON 对得上,不然反序列化会丢字段(后面坑的部分细说)。
完整调用链
重启 OrderApplication,整条链路是这样的:
MySQL user-service (8081) order-service (8080) 浏览器 MySQL user-service (8081) order-service (8080) 浏览器 #mermaid-svg-d4lRjqzzNpSuvWxa{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-d4lRjqzzNpSuvWxa .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-d4lRjqzzNpSuvWxa .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-d4lRjqzzNpSuvWxa .error-icon{fill:#552222;}#mermaid-svg-d4lRjqzzNpSuvWxa .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-d4lRjqzzNpSuvWxa .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-d4lRjqzzNpSuvWxa .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-d4lRjqzzNpSuvWxa .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-d4lRjqzzNpSuvWxa .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-d4lRjqzzNpSuvWxa .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-d4lRjqzzNpSuvWxa .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-d4lRjqzzNpSuvWxa .marker{fill:#333333;stroke:#333333;}#mermaid-svg-d4lRjqzzNpSuvWxa .marker.cross{stroke:#333333;}#mermaid-svg-d4lRjqzzNpSuvWxa svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-d4lRjqzzNpSuvWxa p{margin:0;}#mermaid-svg-d4lRjqzzNpSuvWxa .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-d4lRjqzzNpSuvWxa text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-d4lRjqzzNpSuvWxa .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-d4lRjqzzNpSuvWxa .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-d4lRjqzzNpSuvWxa .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-d4lRjqzzNpSuvWxa .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-d4lRjqzzNpSuvWxa #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-d4lRjqzzNpSuvWxa .sequenceNumber{fill:white;}#mermaid-svg-d4lRjqzzNpSuvWxa #sequencenumber{fill:#333;}#mermaid-svg-d4lRjqzzNpSuvWxa #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-d4lRjqzzNpSuvWxa .messageText{fill:#333;stroke:none;}#mermaid-svg-d4lRjqzzNpSuvWxa .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-d4lRjqzzNpSuvWxa .labelText,#mermaid-svg-d4lRjqzzNpSuvWxa .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-d4lRjqzzNpSuvWxa .loopText,#mermaid-svg-d4lRjqzzNpSuvWxa .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-d4lRjqzzNpSuvWxa .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-d4lRjqzzNpSuvWxa .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-d4lRjqzzNpSuvWxa .noteText,#mermaid-svg-d4lRjqzzNpSuvWxa .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-d4lRjqzzNpSuvWxa .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-d4lRjqzzNpSuvWxa .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-d4lRjqzzNpSuvWxa .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-d4lRjqzzNpSuvWxa .actorPopupMenu{position:absolute;}#mermaid-svg-d4lRjqzzNpSuvWxa .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-d4lRjqzzNpSuvWxa .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-d4lRjqzzNpSuvWxa .actor-man circle,#mermaid-svg-d4lRjqzzNpSuvWxa line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-d4lRjqzzNpSuvWxa :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} GET /order/101 select * from tb_order where id=101 订单数据(含 userId=1) HTTP GET /user/1 select * from tb_user where id=1 用户数据 JSON(自动反序列化为 User) Order(user 字段已填充)
验证:
bash
curl http://localhost:8080/order/101
# {"id":101,"price":699900,"name":"Apple 苹果 iPhone 12 ","num":1,"userId":1,
# "user":{"id":1,"username":"柳岩","address":"湖南省衡阳市"}}
curl http://localhost:8080/order/103
# user 字段对应 userId=3,华沉鱼,湖北省十堰市
user 字段有值了,跨服务的远程调用完成。
服务提供者与服务消费者
调用关系里有两个角色:
| 角色 | 定义 | 本例中 |
|---|---|---|
| 服务提供者 | 一次业务中,被其它微服务调用的服务 | user-service(提供 GET /user/{id}) |
| 服务消费者 | 一次业务中,调用其它微服务的服务 | order-service(发起 HTTP 调用) |
这对角色不是绝对的,而是相对某一次业务而言。如果服务 A 调 B、B 又调 C,那么:
- 对"A 调 B"这次业务:A 是消费者,B 是提供者
- 对"B 调 C"这次业务:B 是消费者,C 是提供者
也就是说一个服务可以同时身兼两职。随着调用链变长,绝大多数中间服务都是这种双重身份。
硬编码的 URL,撑不了多久
现在的写法有个致命细节:http://localhost:8081 是写死在代码里的。本地玩玩可以,生产环境三个问题立刻暴露:
- IP 和端口会变。用户服务换个机器部署、改个端口,订单服务就得改代码重新发版。
- 多实例没法负载均衡。user-service 部署了 3 个实例,URL 只指向其中一个,另外两台闲着,写死的那台被打爆。
- 不知道对方是否健康。user-service 宕机了,订单服务每次请求都等到超时才报错,故障还在服务间扩散。
解法是引入注册中心 (Eureka、Nacos):服务启动时把自己的地址注册上去,消费者按服务名取地址列表再做负载均衡。这样 URL 就能写成 http://userservice/user/1 这种形式。具体怎么搭,下一篇展开。
几个真实的坑
URL 拼接少斜杠。 http://localhost:8081user/1------字符串拼接时把 / 丢了,RestTemplate 会直接抛 IllegalArgumentException 或 404。拼接后先打印一遍 URL 核对,比对着报错猜半天快得多。
两份 User 类字段不一致。 order-service 和 user-service 各有一份 User,是两份独立的代码。如果订单服务这份少写了 address 字段,反序列化不会报错,只是 address 静静地变 null------页面上一片空白才知道出问题。排查手法:先 curl 用户服务的原始 JSON,确认数据在源头是完整的,再核对自己这边的字段名。字段名大小写、JSON 键与 Java 属性的映射,任何一处对不上都是静默丢失。
超时未配置,请求挂死。 new RestTemplate() 出来的实例默认没有连接和读取超时(底层依赖 JDK 的默认值,可能长达几分钟)。用户服务一慢,订单服务的 Tomcat 线程就被一个个占满,最后整个服务雪崩。至少要配上连接超时和读取超时:
java
package cn.itcast.order.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate restTemplate() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(1000); // 建立连接超时:1 秒
factory.setReadTimeout(3000); // 等待响应超时:3 秒
return new RestTemplate(factory);
}
}
超时了宁可快速失败,也别把线程吊死在等一个不响应的服务上。
API 速览
| API | 用途 | 说明 |
|---|---|---|
restTemplate.getForObject(url, User.class) |
发 GET 请求并反序列化 | 第二个参数指定目标类型,JSON 自动转对象 |
restTemplate.postForObject(url, body, Result.class) |
发 POST 请求 | 第二个参数是请求体 |
restTemplate.exchange(...) |
完全控制请求 | 可自定义 Header、方法,返回 ResponseEntity |
官方文档
总结
- 数据独立之后,跨服务拿数据只有一条路:远程调用。它的本质就是发一次 HTTP 请求,和浏览器访问没有任何区别。
RestTemplate三步走:注册 Bean、getForObject发请求并指定返回类型、把结果组装进业务对象。- 提供者与消费者是相对角色,一个服务完全可以同时扮演两者。
- 写死
localhost:8081在变更、扩容、健康检查三件事上全都无解,注册中心就是为此而生的------下一篇从 Eureka 开始。