Java框架 SpringCloud 快速入门: 服务拆分之服务远程调用

概述

上一篇把 cloud-demo 拆成了 order-service 和 user-service 两个独立服务,遗留了一个尾巴:查询订单接口里的 user 字段永远是 null。这篇就把它填上------用最朴素的 RestTemplate 讲清楚微服务远程调用的本质:发一次 HTTP 请求。

纲要

  • 问题回顾:user 字段为什么是 null,为什么不能直接查库解决
  • 远程调用技术选型:RPC(Dubbo / gRPC)与 HTTP(RestTemplate / Feign)对比
  • 远程调用的本质:像浏览器一样发 HTTP 请求
  • 动手实现(三步)
    • 在 OrderApplication 启动类注册 RestTemplate Bean
    • 在 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 是写死在代码里的。本地玩玩可以,生产环境三个问题立刻暴露:

  1. IP 和端口会变。用户服务换个机器部署、改个端口,订单服务就得改代码重新发版。
  2. 多实例没法负载均衡。user-service 部署了 3 个实例,URL 只指向其中一个,另外两台闲着,写死的那台被打爆。
  3. 不知道对方是否健康。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 开始。
相关推荐
努力努力再努力wz1 小时前
【CUDA入门系列】CUDA编程实战:设备选择、显存管理、Kernel、Stream 与 Event 全流程梳理
开发语言·c++
Wang's Blog1 小时前
Java框架 SpringCloud 快速入门: 路由断言工厂
java·spring·spring cloud
ym hyd 1111 小时前
门诊挂号系统源码 Java+SpringBoot+Vue3 前后分离
java·vue.js·spring boot·毕设
杨丰玮4181 小时前
C语言核心语法
java·c语言·开发语言·学习方法
ttwuai1 小时前
Go开源后台管理系统推荐:README、源码和License应该怎么验
开发语言·golang·开源
Java后端的Ai之路1 小时前
Python 进阶探索30 - Python中的装饰器
开发语言·人工智能·python·文件处理·装饰器模式
沫璃染墨1 小时前
《从零入门Linux系统篇(五十八):线程篇·十一——线程安全与死锁详解:从可重入到多锁管理》
linux·服务器·开发语言·c++·驱动开发·安全·架构
海宇AI1 小时前
Java数据工程:利用海宇婚恋风险报告优化高端婚恋实名与涉诉核验合规体验
java·人工智能
CEZ1 小时前
中小企业上 WMS 该先上哪几块:JeeWMS 开源 Java 仓库管理系统的分批上线清单
java·开源