概述
提供者那头已经把 UserServiceImpl 用 @DubboService 暴露出去、地址也写进了 Nacos。消费者这边要做的其实就两件实事:先把服务接口拿到手,再把手写的 @Autowired 换成 @DubboReference。剩下的都是配置和启动顺序的问题,而启动顺序恰好是这个环节最容易卡住人的地方。
纲要
- 消费者在链路里的位置
- 从 HTTP 入口到 RPC 出站的完整调用路径(时序图)
- 消费者与提供者的职责对比表
- 编写 Dubbo 消费者六步落地
- 建模块引依赖:
web+dubbo-spring-boot-starter+dubbo-registry-nacos - 依赖接口模块:为什么消费者必须拿到接口定义
- 启动类:
@EnableDubbo到底要不要加 @DubboReference替换@Autowired引用远程服务application.yml只配注册中心,不配协议- 编写 Controller 调用并验证
- 建模块引依赖:
@DubboReference属性逐个拆version/check/retries/loadbalance/timeout与"什么时候要改"
- 启动顺序强依赖这个坑
check=true的典型报错、check=false的取舍
- 验证方式:Nacos 与 Dubbo Admin 里看"谁在调谁"
- 配置速览 / 注解速览 / API 速览
- 官方文档与总结
消费者在链路里的位置
提供者做的是"暴露 + 注册",消费者做的是"订阅 + 调用"。这两个动词的差别决定了配置上的不对称:提供者必须开一个 RPC 端口等别人来连,消费者不发服务,自然不需要协议配置。
一个 HTTP 请求进来之后,真正发生的事情是这样的:
#mermaid-svg-L8FcKIWo3dvbb1pz{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-L8FcKIWo3dvbb1pz .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-L8FcKIWo3dvbb1pz .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-L8FcKIWo3dvbb1pz .error-icon{fill:#552222;}#mermaid-svg-L8FcKIWo3dvbb1pz .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-L8FcKIWo3dvbb1pz .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-L8FcKIWo3dvbb1pz .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-L8FcKIWo3dvbb1pz .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-L8FcKIWo3dvbb1pz .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-L8FcKIWo3dvbb1pz .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-L8FcKIWo3dvbb1pz .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-L8FcKIWo3dvbb1pz .marker{fill:#333333;stroke:#333333;}#mermaid-svg-L8FcKIWo3dvbb1pz .marker.cross{stroke:#333333;}#mermaid-svg-L8FcKIWo3dvbb1pz svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-L8FcKIWo3dvbb1pz p{margin:0;}#mermaid-svg-L8FcKIWo3dvbb1pz .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-L8FcKIWo3dvbb1pz .cluster-label text{fill:#333;}#mermaid-svg-L8FcKIWo3dvbb1pz .cluster-label span{color:#333;}#mermaid-svg-L8FcKIWo3dvbb1pz .cluster-label span p{background-color:transparent;}#mermaid-svg-L8FcKIWo3dvbb1pz .label text,#mermaid-svg-L8FcKIWo3dvbb1pz span{fill:#333;color:#333;}#mermaid-svg-L8FcKIWo3dvbb1pz .node rect,#mermaid-svg-L8FcKIWo3dvbb1pz .node circle,#mermaid-svg-L8FcKIWo3dvbb1pz .node ellipse,#mermaid-svg-L8FcKIWo3dvbb1pz .node polygon,#mermaid-svg-L8FcKIWo3dvbb1pz .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-L8FcKIWo3dvbb1pz .rough-node .label text,#mermaid-svg-L8FcKIWo3dvbb1pz .node .label text,#mermaid-svg-L8FcKIWo3dvbb1pz .image-shape .label,#mermaid-svg-L8FcKIWo3dvbb1pz .icon-shape .label{text-anchor:middle;}#mermaid-svg-L8FcKIWo3dvbb1pz .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-L8FcKIWo3dvbb1pz .rough-node .label,#mermaid-svg-L8FcKIWo3dvbb1pz .node .label,#mermaid-svg-L8FcKIWo3dvbb1pz .image-shape .label,#mermaid-svg-L8FcKIWo3dvbb1pz .icon-shape .label{text-align:center;}#mermaid-svg-L8FcKIWo3dvbb1pz .node.clickable{cursor:pointer;}#mermaid-svg-L8FcKIWo3dvbb1pz .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-L8FcKIWo3dvbb1pz .arrowheadPath{fill:#333333;}#mermaid-svg-L8FcKIWo3dvbb1pz .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-L8FcKIWo3dvbb1pz .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-L8FcKIWo3dvbb1pz .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-L8FcKIWo3dvbb1pz .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-L8FcKIWo3dvbb1pz .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-L8FcKIWo3dvbb1pz .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-L8FcKIWo3dvbb1pz .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-L8FcKIWo3dvbb1pz .cluster text{fill:#333;}#mermaid-svg-L8FcKIWo3dvbb1pz .cluster span{color:#333;}#mermaid-svg-L8FcKIWo3dvbb1pz div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-L8FcKIWo3dvbb1pz .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-L8FcKIWo3dvbb1pz rect.text{fill:none;stroke-width:0;}#mermaid-svg-L8FcKIWo3dvbb1pz .icon-shape,#mermaid-svg-L8FcKIWo3dvbb1pz .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-L8FcKIWo3dvbb1pz .icon-shape p,#mermaid-svg-L8FcKIWo3dvbb1pz .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-L8FcKIWo3dvbb1pz .icon-shape .label rect,#mermaid-svg-L8FcKIWo3dvbb1pz .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-L8FcKIWo3dvbb1pz .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-L8FcKIWo3dvbb1pz .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-L8FcKIWo3dvbb1pz :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} GET /user/usernames/1
调用 userService.queryUsername(1)
1 订阅/查询地址(首次或变更时)
返回提供者地址列表
2 按负载均衡选一台,发 RPC 二进制报文
3 查库
结果
4 序列化返回
响应字符串
浏览器 / 前端
user-consumer
Tomcat 18081
Dubbo 代理对象
UserService 的接口代理
Nacos
127.0.0.1:8848
user-provider
Dubbo 20881
MySQL
tb_user
换个时序视角,重点看"地址查询"和"实际调用"是分开的:
Provider(20881) Nacos Consumer(18081) Provider(20881) Nacos Consumer(18081) #mermaid-svg-3eNzTIexSagLaRoy{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-3eNzTIexSagLaRoy .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3eNzTIexSagLaRoy .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3eNzTIexSagLaRoy .error-icon{fill:#552222;}#mermaid-svg-3eNzTIexSagLaRoy .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3eNzTIexSagLaRoy .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3eNzTIexSagLaRoy .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3eNzTIexSagLaRoy .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3eNzTIexSagLaRoy .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3eNzTIexSagLaRoy .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3eNzTIexSagLaRoy .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3eNzTIexSagLaRoy .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3eNzTIexSagLaRoy .marker.cross{stroke:#333333;}#mermaid-svg-3eNzTIexSagLaRoy svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3eNzTIexSagLaRoy p{margin:0;}#mermaid-svg-3eNzTIexSagLaRoy .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3eNzTIexSagLaRoy text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-3eNzTIexSagLaRoy .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-3eNzTIexSagLaRoy .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-3eNzTIexSagLaRoy .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-3eNzTIexSagLaRoy .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-3eNzTIexSagLaRoy #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-3eNzTIexSagLaRoy .sequenceNumber{fill:white;}#mermaid-svg-3eNzTIexSagLaRoy #sequencenumber{fill:#333;}#mermaid-svg-3eNzTIexSagLaRoy #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-3eNzTIexSagLaRoy .messageText{fill:#333;stroke:none;}#mermaid-svg-3eNzTIexSagLaRoy .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3eNzTIexSagLaRoy .labelText,#mermaid-svg-3eNzTIexSagLaRoy .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-3eNzTIexSagLaRoy .loopText,#mermaid-svg-3eNzTIexSagLaRoy .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-3eNzTIexSagLaRoy .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-3eNzTIexSagLaRoy .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-3eNzTIexSagLaRoy .noteText,#mermaid-svg-3eNzTIexSagLaRoy .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-3eNzTIexSagLaRoy .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3eNzTIexSagLaRoy .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3eNzTIexSagLaRoy .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3eNzTIexSagLaRoy .actorPopupMenu{position:absolute;}#mermaid-svg-3eNzTIexSagLaRoy .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-3eNzTIexSagLaRoy .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3eNzTIexSagLaRoy .actor-man circle,#mermaid-svg-3eNzTIexSagLaRoy line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-3eNzTIexSagLaRoy :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 启动时 @DubboReference 生成接口代理 之后走本地缓存,注册中心短暂挂掉也能调 订阅 cn.mf.user.service.UserService:2.0.0 推送提供者地址列表(缓存到本地) 发起 RPC 调用 queryUsername(1) 返回 "张三"
把两端的职责摆在一起看,对称关系更清楚:
| 维度 | 服务提供者 user-provider | 服务消费者 user-consumer |
|---|---|---|
| 核心注解 | @DubboService |
@DubboReference |
| 注解的位置 | 实现类上 | Controller 的字段上 |
| 对注册中心做什么 | 注册(登记自己的地址) | 订阅(拉取别人的地址) |
| 是否需要接口实现类 | 必须有 | 不能有(有实现类就变成本地调用了) |
dubbo.protocol |
必须配(开端口监听) | 不用配 |
dubbo.registry.address |
必须配 | 必须配 |
| Tomcat 端口(本案例) | 18080 | 18081 |
| Dubbo 端口 | 20881 | 无 |
| 是否碰数据库 | 碰(Mapper + 数据源) | 不碰 |
这张表里最值得盯的是第 4 行和第 5 行:消费者没有实现类、也不开 RPC 端口。这两点直接决定了下文的两个写法差异。
编写消费者要做哪几件事
把整个改造拆成六个动作,每一步只解决一个问题:
| 步骤 | 做什么 | 产物 | 关键点 |
|---|---|---|---|
| ① | 建 user-consumer 模块,引依赖 |
pom.xml |
引 web、dubbo 起步依赖、Nacos 注册中心实现 |
| ② | 依赖接口模块(或保证接口包名一致) | 接口可见性 | 消费者必须拿到接口定义,这是 RPC 与 HTTP 的核心差异 |
| ③ | 编写启动类 | UserConsumerApplication |
@EnableDubbo 与 dubbo.scan.base-packages 二选一,可都不写 |
| ④ | 用 @DubboReference 注入远程服务 |
UserController |
不能再用 @Autowired,容器里没有实现类 |
| ⑤ | 配置 application.yml |
注册中心地址 | 只配 registry,不配 protocol |
| ⑥ | 启动并访问验证 | 一次成功的远程调用 | Provider 必须先启动 |
接下来逐步展开,每一步都给出本案例工程里的真实代码。
建模块并引入依赖
新建模块 user-consumer,依赖比提供者少一截------消费者不查库,所以 mysql-connector-java、mybatis-spring-boot-starter 一律不需要。
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>dubbo-demo</artifactId>
<groupId>cn.mf</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>user-consumer</artifactId>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<!-- 提供 HTTP 接口,Controller 需要 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- dubbo 的起步依赖,版本必须和注册中心实现保持一致 -->
<dependency>
<groupId>org.apache.dubbo</groupId>
<artifactId>dubbo-spring-boot-starter</artifactId>
<version>2.7.8</version>
</dependency>
<!-- Nacos 作为注册中心的实现 -->
<dependency>
<groupId>org.apache.dubbo</groupId>
<artifactId>dubbo-registry-nacos</artifactId>
<version>2.7.8</version>
</dependency>
<!-- 接口契约模块:消费者靠它拿到 UserService 接口 -->
<dependency>
<groupId>cn.mf</groupId>
<artifactId>user-api</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>
</project>
dubbo-spring-boot-starter 和 dubbo-registry-nacos 的版本必须一致,这里都锁 2.7.8。写成 2.7.8 和 2.7.5 这种组合,编译能过,跑起来通常报 NoSuchMethodError。
依赖接口模块:这一步是 RPC 与 HTTP 调用的分水岭
这是初学者最容易绕不过来的地方。用 RestTemplate 或 Feign 调 HTTP 接口,消费者只需要一个 URL 加返回值的结构,压根不需要对方的 .java 文件。Dubbo 不行------@DubboReference 注入的是一个接口级别的动态代理,没有接口,连字段类型都写不出来。
源码里给出的理由是:Dubbo Admin 上对外暴露的服务名就是这个接口的全类名。消费者要"找到"服务,靠的就是这个全类名去注册中心匹配。所以消费者这一端必须有一份同名同包的接口。
接口真实内容(user-api 模块里,消费者通过依赖拿到):
java
package cn.mf.user.service;
import cn.mf.user.domain.User;
public interface UserService {
String queryUsername(Long id);
User queryById(Long id);
}
配套的实体也一样要能拿到,它会作为 RPC 返回值在网络上跑一圈:
java
package cn.mf.user.domain;
import lombok.Data;
import java.io.Serializable;
@Data
public class User implements Serializable {
private Long id;
private String username;
private String address;
}
两个容易踩的细节:
- 包名必须和提供者完全一致 ,不只是类名一致。因为注册中心里存的是
cn.mf.user.service.UserService这样的全类名,你把接口放到cn.mf.user.api下,哪怕方法签名一模一样,也匹配不上,报的是"找不到提供者"。 - 实体必须实现
Serializable。返回值要从提供者的 JVM 序列化后过来,不实现这个接口,参数一复杂就会抛序列化异常。
本案例用的是讲义 2.5 节"代码优化"之后的形态:把接口和实体抽到独立的 user-api 模块,提供者和消费者各引一份。入门阶段如果还没抽模块,也可以把接口从 provider 直接拷贝到 consumer 的同名包里,效果一样。抽取公共接口模块这件事本身,下一篇会单独展开。
抽取之后,工程结构是这样:
tree
dubbo-demo
├── pom.xml
├── user-api # 接口契约模块,Provider 与 Consumer 共享
│ └── src/main/java/cn/mf/user
│ ├── domain/User.java # 实体,实现 Serializable
│ └── service/UserService.java
├── user-provider
│ ├── pom.xml # 依赖 user-api
│ └── src/main/...
└── user-consumer
├── pom.xml # 依赖 user-api
└── src/main
├── java/cn/mf/user
│ ├── UserConsumerApplication.java # 启动类
│ └── controller/UserController.java # @DubboReference 在这里
└── resources/application.yml
顺带一句:父 pom 的 <modules> 里声明的 user-server 在这份代码里并不存在,磁盘上只有 user-api、user-provider、user-consumer 三个模块。直接 mvn package 会因为找不到 user-server 目录而报错,手工构建时把它从 <modules> 里删掉即可。
编写启动类
消费者的启动类可以写到最简,本案例工程里就是这样:
java
package cn.mf.user;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class UserConsumerApplication {
public static void main(String[] args) {
SpringApplication.run(UserConsumerApplication.class, args);
}
}
关于 @EnableDubbo,需要说清楚它的作用和边界:
- 它的职责是"开启 Dubbo 注解处理 + 指定要扫描的包",等价于在配置里写
dubbo.scan.base-packages。 - 使用
dubbo-spring-boot-starter时,Dubbo 的注解处理由起步依赖的自动装配完成,所以可以不写@EnableDubbo------本案例的启动类就是没写的。 - 提供者那端之所以能看到扫描配置,是因为它在
application.yml里写了dubbo.scan.base-packages: cn.mf.user.service,走的是配置这条路。 - 消费者端不需要暴露服务,也就没有
@DubboService需要扫描,所以既不加注解也不写扫描配置,依然能正常注入@DubboReference。
如果换到非 Spring Boot 环境,或者想让扫描范围更明确(比如把接口和实现放在不同包),那就显式加上:
java
package cn.mf.user;
import org.apache.dubbo.config.spring.context.annotation.EnableDubbo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@EnableDubbo(scanBasePackages = "cn.mf.user.service")
@SpringBootApplication
public class UserConsumerApplication {
public static void main(String[] args) {
SpringApplication.run(UserConsumerApplication.class, args);
}
}
用 @DubboReference 引用远程服务
改造前的 Controller 长得是我们都熟悉的样子,@Autowired 从 Spring 容器里拿一个 UserService:
java
package cn.mf.user.controller;
import cn.mf.user.service.UserService;
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("/user")
public class UserController {
// 改造前:指望容器里有一个 UserService 的实现类
@Autowired
private UserService userService;
@GetMapping("/usernames/{id}")
public String queryUsername(@PathVariable("id") Long id) {
return userService.queryUsername(id);
}
}
问题很直接:容器里没有 UserService 的实现类。user-consumer 模块从头到尾没有 UserServiceImpl,实现类在另一个进程里待着。这时候 @Autowired 只有两种结局------启动直接失败报 NoSuchBeanDefinitionException,或者你去手写一个假的实现类,那调用就彻底变成本地方法调用了。
换掉注解即可,这是整个改造的实质动作:
java
package cn.mf.user.controller;
import cn.mf.user.domain.User;
import cn.mf.user.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.apache.dubbo.config.annotation.DubboReference;
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;
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {
// 引用远程服务:version 指定调 2.0.0,retries=0 不重试,loadbalance 用随机
@DubboReference(version = "2.0.0", retries = 0, loadbalance = "Random")
private UserService userService;
/**
* 路径: /user/usernames/1
* @param id 用户id
* @return 用户名称
*/
@GetMapping("/usernames/{id}")
public String queryUsername(@PathVariable("id") Long id) {
return userService.queryUsername(id);
}
/**
* 根据 id 查询用户
*/
@GetMapping("/{id}")
public User queryById(@PathVariable("id") Long id) {
return userService.queryById(id);
}
}
两个注解的区别一句话说清:@Autowired 是"从本进程的容器里取对象",@DubboReference 是"生成一个代理对象,真实调用走网络发出去"。字段的类型、调用方式完全不变,这也是 Dubbo 对业务代码侵入很小的原因。
版本差异要留神:2.7 及以后的包名是 org.apache.dubbo.config.annotation,注解是 @DubboReference;老版本(2.6.x 及更早,Spring Cloud Alibaba 早期版本也常见)用的是 com.alibaba.dubbo.config.annotation.Reference,注解写作 @Reference。网上大量老教程写的是 @Reference,直接抄进 2.7.8 的工程里编译通不过。
@DubboReference 的常用属性
上面那行注解里塞了三个属性,它们各自解决一个具体问题,值得逐个过一遍。
version 决定"调哪个版本的服务"。提供者侧有两个实现,UserServiceImpl 是 1.0.0,UserServiceImpl2 是 2.0.0:
java
// 提供者侧,同一接口的两个版本实现
@DubboService(version = "1.0.0")
public class UserServiceImpl implements UserService { /* ... */ }
// 灰度用:新功能先让一部分流量走这个版本
@DubboService(version = "2.0.0")
public class UserServiceImpl2 implements UserService { /* ... */ }
存在的意义就是灰度发布:新功能上线时先让一部分用户走新版本,反馈没问题再全量切过去,出问题随时把流量切回老版本。两个提醒------第一,如果提供者注册了带版本的服务,而消费者不写 version ,Dubbo 会去找不带版本的实现,找不到就在启动检查阶段失败;第二,版本号是精确匹配的字符串,写 "2.0" 和 "2.0.0" 是两个不同的服务。
check 决定"启动时要不要检查提供者存在"。默认是 true,也就是消费者启动的那一刻就去注册中心确认一遍:我要的这个服务在不在?不在就直接抛异常、启动失败。这个设计在正式环境是必要的------能保证上线时整条调用链是通的,而不是把问题推迟到第一次请求。但它对开发阶段非常不友好,本地只起了消费者没起提供者,工程根本起不来。
retries 是失败重试次数,默认 2(也就是失败后最多再试两回)。这里有个必须记住的警告:重试不等于幂等 。查询类接口重试没风险,写操作重试就可能变成下两次单、扣两次款。所以本案例两个地方都把 retries 设成了 0------注解上写了一次,yml 里也写了一次。写接口一律设 0,需要重试就把幂等键做扎实再说。
loadbalance 是负载均衡策略,默认 Random 按权重随机。可选值有 Random、RoundRobin(按权重轮询)、LeastActive(最少活跃调用数)、ConsistentHash(一致性 Hash,相同参数的请求固定打到同一台)。要提醒的是,只有提供者是多实例的时候才看得出效果,本案例提供者只有一台,配什么都一样。
timeout 是调用超时时间,默认 1000 毫秒。这个值的含义是"消费者最多等多久",超了就按失败处理。它必须大于提供方的实际处理耗时 ,否则会出现大量本不该发生的超时;超时叠加默认的 retries=2,一次慢请求会变成三次,把提供者压得更慢,雪崩就是这么来的。查库慢、逻辑重的接口,把 timeout 调到 3000 甚至更大。
汇总成表:
| 属性 | 默认值 | 作用 | 什么时候要改 |
|---|---|---|---|
version |
空(匹配无版本服务) | 指定调用哪个版本的服务 | 提供方有多个版本、要做灰度时;不写而提供方只有带版本实现会启动失败 |
check |
true |
启动时检查提供者是否存在,不存在则启动失败 | 本地开发、提供者未上线、微服务互相依赖导致启动顺序死锁时设为 false |
retries |
2 |
失败重试次数(不含首次调用) | 写操作必须设 0;查询类接口可保留或调小 |
loadbalance |
random |
多提供者间的负载均衡策略 | 提供者有多个实例、且需要固定路由(如 ConsistentHash)时 |
timeout |
1000(毫秒) |
单次调用的超时时间 | 提供方耗时较长时必须调大,否则大量超时并触发重试放大 |
cluster |
failover |
集群容错策略,配合 retries 生效 |
一般不改;需要快速失败可换 failfast |
mock |
false |
服务降级,调用失败时返回兜底数据 | 需要接口级兜底(如详情页推荐位)时 |
配置 application.yml
消费者的配置比提供者短,而且少了一块------没有 dubbo.protocol。这不是漏写,是因为消费者不对外暴露服务,不需要在本机开 RPC 端口监听,只配注册中心地址就够。
yaml
server:
port: 18081
spring:
application:
name: user-consumer
logging:
level:
cn.mf: debug
pattern:
dateformat: HH:mm:ss:SSS
# 配置 dubbo 服务消费者
dubbo:
registry:
address: nacos://127.0.0.1:8848 # 注册中心地址:消费者从这里拉取提供者地址
consumer:
check: false # 关闭启动检查,允许先启动消费者后启动提供者
retries: 0 # 调用失败不重试,避免误重试带来的重复写
逐项对照:
spring.application.name:应用名,注册中心和控制台里都用它标识这台消费者。它和dubbo.application.name是两回事------本案例走 Spring Boot 的自动装配,spring.application.name会被拿来当 Dubbo 的 application 名,所以不需要单独再配一遍。dubbo.registry.address:消费者运行的核心配置。nacos://前缀决定注册中心类型,换zookeeper://127.0.0.1:2181就走 Zookeeper,前提是 pom 里换成对应的注册中心实现。dubbo.consumer.check/retries:写在consumer这一层,作用域是"本应用的所有引用"。这样就不必在每个@DubboReference上都重复写一遍。注解上的属性优先级高于这里的全局配置。
两个和讲义对不上的地方,按实际工程来:
| 项目 | 讲义写法 | 工程实际 | 说明 |
|---|---|---|---|
| 提供者 Tomcat 端口 | 18081 |
18080 |
讲义里提供者和消费者都写 18081,同一台机器会端口冲突,实操必须错开 |
| 消费者调用路径 | /user/username/{id} |
/user/usernames/{id} |
实际代码是复数形式 |
消费者 dubbo.consumer 段 |
未给出 | check: false、retries: 0 |
讲义把这两项放在 3.x 高级特性里讲,工程里已提前写入配置 |
启动与验证
启动顺序是有讲究的:先启动 Provider,再启动 Consumer 。原因是 check 的默认值是 true,消费者启动时要确认提供者在线。
如果顺序搞反了,或者提供者没起来,消费者会以这种方式失败:
text
java.lang.IllegalStateException: Failed to check the status of the service cn.mf.user.service.UserService.
No provider available for the service cn.mf.user.service.UserService:2.0.0 from the url
nacos://127.0.0.1:8848/org.apache.dubbo.registry.RegistryService?application=user-consumer
&dubbo=2.7.8&interface=cn.mf.user.service.UserService&version=2.0.0 ...
to the consumer 192.168.1.5 use dubbo version 2.7.8, please check status of providers(disabled, not registered or in blacklist).
No provider available 这句话几乎可以概括 Dubbo 入门阶段遇到的所有问题,下面单独拆一节。
反过来,提供者已经注册、消费者正常起来,日志里能看到订阅成功的记录:
text
[DUBBO] Subscribe: consumer://192.168.1.5/cn.mf.user.service.UserService:2.0.0 ...
Nacos registry, consumer://... subscribe finished
访问入口验证:
bash
# 查用户名,走的是 Dubbo 的 queryUsername
curl http://localhost:18081/user/usernames/1
# 张三
# 查完整用户对象,走的是 Dubbo 的 queryById,返回值被带上了 "v2.0" 后缀
curl http://localhost:18081/user/1
# {"id":1,"username":"张三v2.0","address":"上海市浦东新区"}
第二个接口的返回值是判断"到底调了哪个版本"的快捷手段。提供者的 queryById 里做了手脚:1.0.0 版本拼 v1.0,2.0.0 版本拼 v2.0。上面返回 张三v2.0,说明 @DubboReference(version = "2.0.0") 确实生效了。如果这里返回的是 张三v1.0,说明版本没匹配上。而 queryUsername 两个版本都没加后缀,所以它返回的永远是干净的 张三。
再看控制台能验证什么。Nacos 上「服务管理 → 服务列表」会出现这一类服务名:
text
providers:cn.mf.user.service.UserService:2.0.0 # 提供者实例
consumers:cn.mf.user.service.UserService:2.0.0 # 消费者实例
在 Dubbo Admin 里打开该服务的详情,能同时看到提供者列表和消费者列表。提供者那条点进去是 dubbo://192.168.1.5:20881/...,消费者那条显示的是 user-consumer 这个应用名。这正是注册中心除了"发地址"之外的第二个价值------能看到谁在调用谁。服务多起来之后,这条关联关系是排查"某个接口为什么没人调""改动会影响哪些上游"的第一手资料,比翻代码快得多。
最难缠的坑:check=true 与启动顺序强依赖
check=true 单独看是个好设计,放到微服务里就成了麻烦。
假设 consumer A 依赖 consumer B,B 又依赖 provider C。启动顺序就成了 A→B→C 或者 C→B→A 的链式依赖,稍微复杂一点就是环:A 等 B 提供的服务,B 又等 A 提供的服务,两个谁都不肯先启动,直接死锁在启动阶段。这就是常说的"服务多了启动不起来"。
单机开发时表现更直白:只起了消费者、提供者还没编译完,消费者报上面那段 No provider available 就退出了。
三种处理方式,按适用场景选:
| 处理方式 | 做法 | 适用场景 | 代价 |
|---|---|---|---|
| 关闭启动检查 | dubbo.consumer.check: false |
开发、测试环境,以及存在循环依赖的服务 | 问题从"启动时"推迟到"第一次调用时" |
| 单点关闭 | 只在个别 @DubboReference(check = false) 上关 |
只对某个非核心依赖放宽 | 需要判断哪些依赖可以放宽 |
| 严格按顺序部署 | 保持 check=true,调整发布顺序 |
正式环境 | 部署流程变复杂,扩容顺序也有约束 |
本案例两个地方都关了检查:yml 里 dubbo.consumer.check: false 管全局,@DubboReference 上没再单独写。关闭之后,消费者可以先起来、先对外提供 HTTP 服务,等提供者上线后自动收到地址推送并恢复调用------这段空窗期内的请求会失败,所以线上环境到底关不关,取决于你的业务能不能接受启动瞬间的失败请求。
No provider available 这类报错的排查顺序,按从近到远列一下:
| 现象 | 大概率原因 | 处理 |
|---|---|---|
消费者报 No provider available,Nacos 里也没有服务 |
提供者没启动,或 @DubboService 写成了 Spring 的 @Service |
先起提供者,确认启动日志有 Register dubbo service |
| 提供者已注册,消费者仍找不到 | 接口包名和提供者不一致,全类名对不上 | 两边都用同一个 user-api 模块,别手工拷贝改名 |
提供者只有 1.0.0,消费者要 2.0.0 |
版本号写错或不匹配 | 核对 @DubboService 与 @DubboReference 的 version 字符串 |
启动报 NoSuchBeanDefinitionException: UserService |
消费者侧写了实现类,或注解还留着 @Autowired |
删掉实现类,注解换成 @DubboReference |
启动报 NoSuchMethodError |
dubbo-spring-boot-starter 与 dubbo-registry-nacos 版本不一致 |
统一为 2.7.8 |
消费者报 Connection refused 到内网某 IP |
提供者注册的是别的网卡地址 | 在提供者侧配 dubbo.protocol.host 指定注册地址 |
配置速览
消费者用到的配置就这几项:
| 配置项 | 示例值 | 作用 |
|---|---|---|
server.port |
18081 |
Tomcat 端口,对外提供 HTTP 接口 |
spring.application.name |
user-consumer |
应用名,同时作为 Dubbo application 名 |
dubbo.registry.address |
nacos://127.0.0.1:8848 |
注册中心地址,消费者据此拉取提供者 |
dubbo.consumer.check |
false |
关闭启动检查,解除启动顺序强依赖 |
dubbo.consumer.retries |
0 |
全局关闭重试 |
dubbo.protocol.* |
不配 | 消费者不暴露服务,无需协议与端口 |
API 速览
消费者对外暴露的 HTTP 接口:
| 方法 | 路径 | 参数 | 返回 | 底层 Dubbo 调用 |
|---|---|---|---|---|
| GET | /user/usernames/{id} |
用户 id | String 用户名 |
UserService.queryUsername(Long) |
| GET | /user/{id} |
用户 id | User 对象 |
UserService.queryById(Long) |
RPC 契约(由 user-api 定义,两端共享):
| 接口 | 方法 | 签名 | 提供者版本差异 |
|---|---|---|---|
cn.mf.user.service.UserService |
queryUsername |
String queryUsername(Long id) |
两个版本实现一致,无差异 |
cn.mf.user.service.UserService |
queryById |
User queryById(Long id) |
1.0.0 拼 v1.0,2.0.0 拼 v2.0 |
关键注解速览:
| 注解 | 位置 | 作用 |
|---|---|---|
@DubboReference |
Consumer 的字段 | 引用远程服务,支持 version、check、retries、loadbalance、timeout |
@Reference |
Consumer 的字段 | 2.6.x 及更早版本的写法,包名 com.alibaba.dubbo.config.annotation |
@EnableDubbo |
启动类 | 开启注解处理并指定扫描包,与 dubbo.scan.base-packages 二选一 |
@DubboService |
Provider 的实现类 | 暴露服务,消费者侧对应的是 @DubboReference |
官方文档
总结
消费者这一端写下来,真正有技术含量的只有一行:
java
@DubboReference(version = "2.0.0", retries = 0, loadbalance = "Random")
private UserService userService;
其余都是围着它转的。version 解决多版本灰度,retries 决定失败后要不要重发(写操作要设 0),loadbalance 影响多实例时的流量分配,check 控制启动时要不要立刻验证提供者存在------而 check 正是入门阶段最容易把人卡住的那个参数。配置上那句 check: false,本质上是用"启动时发现不了问题"换"启动顺序不再互相锁死"。
和提供者那篇对照着记更省事:提供者是"暴露 + 注册",配置里必须写 dubbo.protocol 和 dubbo.scan.base-packages;消费者是"订阅 + 调用",配置里只留 dubbo.registry。两端连接的桥梁是同一个东西------注册中心上的接口全类名,以及两边共享的那份接口契约。少了 user-api 这块拼图,@DubboReference 连编译都过不去。
本案例为了讲清调用,接口是直接放在 user-api 里的。下一步是把"为什么必须抽这个模块、抽的时候哪些东西该放进去、Serializable 到底解决了什么问题"讲透,也就是抽取公共接口模块与序列化这两件事。