Java 项目实战: 交友项目 - 入门案例:编写 Dubbo 服务消费者

概述

提供者那头已经把 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 到底解决了什么问题"讲透,也就是抽取公共接口模块与序列化这两件事。

相关推荐
IT研究室1 小时前
最新计算机毕业设计选题推荐-基于spring boot的教育学习平台-网站-文档指导-Java-springboot
java·spring boot·课程设计
亚林瓜子1 小时前
Spring Boot 集成 ip2region 离线 IP 库实战指南
java·spring boot·后端·ip·ip2region
盛世宏博智慧档案2 小时前
恒湿净化一体机联动开发:档案馆温湿度阈值判断与启停控制实践
java·后端·struts·恒湿机·加湿除湿消毒净化一体机
vipxieliang2 小时前
ValidX 应用于 HR系统员工信息验证:入职、离职、社保数据
java·spring boot·后端
huaweichenai2 小时前
spring boot实现base64上传
java·spring boot·后端
萧瑟余晖2 小时前
Spring Web综合实战与性能调优详解
java·spring
点纭2 小时前
spring ai1.0实战:ChatClient(2)
java·后端·spring
小马过河R2 小时前
EdgeOne深度解析:从原理、适用场景到COS接入的技术逻辑
java·开发语言·前端·spring·cos
知守观2 小时前
多模块工程没 install,跑起来的代码是上周的:Maven reactor 依赖解析踩坑
java·maven·intellij idea