概述
把一个单体工程里的 UserServiceImpl 改造成能对外提供远程调用的服务,核心动作只有一个:把 Spring 的 @Service 换成 Dubbo 的 @DubboService。本文把这一步前后的完整工程(依赖、接口、实现、启动类、配置)和注册到 Nacos 的验证过程拆开讲,并附上一份踩坑排查表。
纲要
- Dubbo 是什么:RPC 框架,Provider 注册服务、Consumer 订阅服务、Registry 做地址簿、Monitor 收统计
- 四个角色的职责与调用链路(注册 → 订阅 → 调用 → 监控)
- 为什么用 Nacos 当注册中心
- 入门案例工程结构
- 父工程
dubbo-demo(Spring Boot 2.3.9 + Hoxton.SR10) user-api:接口UserService+ 实体Useruser-provider:Mapper +@DubboService实现类 +application.yml
- 父工程
- 服务提供者五步落地
- 引入
dubbo-spring-boot-starter+dubbo-registry-nacos(2.7.8) - 定义服务接口(先写在 provider 里,后面抽成公共模块)
@DubboService标注实现类- 编写启动类
application.yml配协议、端口、注册中心地址、注解扫描包
- 引入
- 启动验证:Nacos 控制台看服务列表,Dubbo Admin 看提供者详情
- 实战坑:注解混用、扫描包写错、Nacos 未启动、端口冲突、实体未实现
Serializable
Dubbo 在分布式架构里干什么
单体应用里,Controller 调 Service 就是一次普通的 JVM 内方法调用。拆成分布式之后,用户服务部署在上海、订单服务部署在北京,订单服务想拿用户名,只能走网络。Dubbo 解决这个问题的方式是:让调用方拿到一个"代理对象",对这个代理调方法时,Dubbo 在底层把方法名、参数序列化成二进制报文发出去,服务端反序列化、执行、再把结果按原路送回来。调用方写的代码看起来和调本地方法没有任何区别。
这就是 RPC(Remote Procedure Call,远程过程调用)。Dubbo 是阿里巴巴开源的一套 RPC 框架,2011 年开源,早期是 SOA 服务化治理的核心组件,现在更多是作为 Spring Cloud 体系里的二进制通信方案,用它的性能优势替换掉 HTTP + JSON 那一套。
四个角色与调用链路
Dubbo 官方架构里有四个节点:
| 节点 | 角色说明 |
|---|---|
| Provider | 暴露服务的服务提供方,启动时把自己能提供的服务注册到注册中心 |
| Consumer | 调用远程服务的服务消费方,启动时向注册中心订阅自己需要的服务 |
| Registry | 服务注册与发现的注册中心,本文用 Nacos |
| Monitor | 统计调用次数和调用耗时的监控中心,本文用 Dubbo Admin |
它们之间的先后关系:
#mermaid-svg-fk358lMmGFYBPl6r{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-fk358lMmGFYBPl6r .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-fk358lMmGFYBPl6r .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-fk358lMmGFYBPl6r .error-icon{fill:#552222;}#mermaid-svg-fk358lMmGFYBPl6r .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-fk358lMmGFYBPl6r .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-fk358lMmGFYBPl6r .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-fk358lMmGFYBPl6r .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-fk358lMmGFYBPl6r .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-fk358lMmGFYBPl6r .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-fk358lMmGFYBPl6r .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-fk358lMmGFYBPl6r .marker{fill:#333333;stroke:#333333;}#mermaid-svg-fk358lMmGFYBPl6r .marker.cross{stroke:#333333;}#mermaid-svg-fk358lMmGFYBPl6r svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-fk358lMmGFYBPl6r p{margin:0;}#mermaid-svg-fk358lMmGFYBPl6r .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-fk358lMmGFYBPl6r .cluster-label text{fill:#333;}#mermaid-svg-fk358lMmGFYBPl6r .cluster-label span{color:#333;}#mermaid-svg-fk358lMmGFYBPl6r .cluster-label span p{background-color:transparent;}#mermaid-svg-fk358lMmGFYBPl6r .label text,#mermaid-svg-fk358lMmGFYBPl6r span{fill:#333;color:#333;}#mermaid-svg-fk358lMmGFYBPl6r .node rect,#mermaid-svg-fk358lMmGFYBPl6r .node circle,#mermaid-svg-fk358lMmGFYBPl6r .node ellipse,#mermaid-svg-fk358lMmGFYBPl6r .node polygon,#mermaid-svg-fk358lMmGFYBPl6r .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-fk358lMmGFYBPl6r .rough-node .label text,#mermaid-svg-fk358lMmGFYBPl6r .node .label text,#mermaid-svg-fk358lMmGFYBPl6r .image-shape .label,#mermaid-svg-fk358lMmGFYBPl6r .icon-shape .label{text-anchor:middle;}#mermaid-svg-fk358lMmGFYBPl6r .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-fk358lMmGFYBPl6r .rough-node .label,#mermaid-svg-fk358lMmGFYBPl6r .node .label,#mermaid-svg-fk358lMmGFYBPl6r .image-shape .label,#mermaid-svg-fk358lMmGFYBPl6r .icon-shape .label{text-align:center;}#mermaid-svg-fk358lMmGFYBPl6r .node.clickable{cursor:pointer;}#mermaid-svg-fk358lMmGFYBPl6r .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-fk358lMmGFYBPl6r .arrowheadPath{fill:#333333;}#mermaid-svg-fk358lMmGFYBPl6r .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-fk358lMmGFYBPl6r .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-fk358lMmGFYBPl6r .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fk358lMmGFYBPl6r .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-fk358lMmGFYBPl6r .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fk358lMmGFYBPl6r .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-fk358lMmGFYBPl6r .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-fk358lMmGFYBPl6r .cluster text{fill:#333;}#mermaid-svg-fk358lMmGFYBPl6r .cluster span{color:#333;}#mermaid-svg-fk358lMmGFYBPl6r 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-fk358lMmGFYBPl6r .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-fk358lMmGFYBPl6r rect.text{fill:none;stroke-width:0;}#mermaid-svg-fk358lMmGFYBPl6r .icon-shape,#mermaid-svg-fk358lMmGFYBPl6r .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fk358lMmGFYBPl6r .icon-shape p,#mermaid-svg-fk358lMmGFYBPl6r .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-fk358lMmGFYBPl6r .icon-shape .label rect,#mermaid-svg-fk358lMmGFYBPl6r .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fk358lMmGFYBPl6r .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-fk358lMmGFYBPl6r .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-fk358lMmGFYBPl6r :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 1 注册服务地址
2 订阅服务地址
推送地址列表
3 发起 RPC 调用
4 上报调用次数/耗时
4 上报调用次数/耗时
Provider
user-provider:20881
Consumer
user-consumer
Registry
Nacos 127.0.0.1:8848
Monitor
Dubbo Admin
换成时序视角更清楚:
Monitor Consumer Nacos(Registry) Provider Monitor Consumer Nacos(Registry) Provider #mermaid-svg-6LZZVLL3MTdLTQgc{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-6LZZVLL3MTdLTQgc .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-6LZZVLL3MTdLTQgc .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-6LZZVLL3MTdLTQgc .error-icon{fill:#552222;}#mermaid-svg-6LZZVLL3MTdLTQgc .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-6LZZVLL3MTdLTQgc .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-6LZZVLL3MTdLTQgc .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-6LZZVLL3MTdLTQgc .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-6LZZVLL3MTdLTQgc .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-6LZZVLL3MTdLTQgc .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-6LZZVLL3MTdLTQgc .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-6LZZVLL3MTdLTQgc .marker{fill:#333333;stroke:#333333;}#mermaid-svg-6LZZVLL3MTdLTQgc .marker.cross{stroke:#333333;}#mermaid-svg-6LZZVLL3MTdLTQgc svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-6LZZVLL3MTdLTQgc p{margin:0;}#mermaid-svg-6LZZVLL3MTdLTQgc .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-6LZZVLL3MTdLTQgc text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-6LZZVLL3MTdLTQgc .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-6LZZVLL3MTdLTQgc .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-6LZZVLL3MTdLTQgc .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-6LZZVLL3MTdLTQgc .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-6LZZVLL3MTdLTQgc #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-6LZZVLL3MTdLTQgc .sequenceNumber{fill:white;}#mermaid-svg-6LZZVLL3MTdLTQgc #sequencenumber{fill:#333;}#mermaid-svg-6LZZVLL3MTdLTQgc #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-6LZZVLL3MTdLTQgc .messageText{fill:#333;stroke:none;}#mermaid-svg-6LZZVLL3MTdLTQgc .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-6LZZVLL3MTdLTQgc .labelText,#mermaid-svg-6LZZVLL3MTdLTQgc .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-6LZZVLL3MTdLTQgc .loopText,#mermaid-svg-6LZZVLL3MTdLTQgc .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-6LZZVLL3MTdLTQgc .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-6LZZVLL3MTdLTQgc .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-6LZZVLL3MTdLTQgc .noteText,#mermaid-svg-6LZZVLL3MTdLTQgc .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-6LZZVLL3MTdLTQgc .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-6LZZVLL3MTdLTQgc .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-6LZZVLL3MTdLTQgc .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-6LZZVLL3MTdLTQgc .actorPopupMenu{position:absolute;}#mermaid-svg-6LZZVLL3MTdLTQgc .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-6LZZVLL3MTdLTQgc .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-6LZZVLL3MTdLTQgc .actor-man circle,#mermaid-svg-6LZZVLL3MTdLTQgc line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-6LZZVLL3MTdLTQgc :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 保存 接口名 ->> IP:20881 缓存到本地, 长连接接收变更推送 1 注册 cn.mf.user.service.UserService 2 订阅 UserService 返回提供者地址列表 3 按负载均衡选一台发起 RPC 调用 返回查询结果 4 每分钟上报调用次数与耗时 4 每分钟上报调用次数与耗时
几个设计上的点值得单独说:
- 注册中心只管地址,不转发请求。Consumer 拿到 Provider 的 IP 和端口后,后续的 RPC 流量是点对点直连的,注册中心挂了不影响已有调用(只影响新服务发现)。这是 Dubbo 和早期 ESB、网关类方案最大的区别。
- 地址列表缓存在 Consumer 本地,注册中心通过长连接推送变更。所以注册中心短暂不可用,Consumer 依然能按缓存的地址调用。
- Dubbo 协议端口和 Tomcat 端口是两回事 。Tomcat 的
server.port服务 HTTP 请求,Dubbo 的dubbo.protocol.port服务 RPC 请求,默认从 20880 开始,同一台机器上启动第二个提供者会自动加一。源码里写的 20881,就是避开默认值、也避开多实例冲突的写法。
案例工程结构
父工程 dubbo-demo 只做依赖管理和模块聚合,本身不打 jar:
tree
dubbo-demo
├── pom.xml # 父工程:Spring Boot 2.3.9.RELEASE,packaging=pom
├── user-api # 公共接口模块
│ ├── pom.xml
│ └── src/main/java/cn/mf/user
│ ├── domain/User.java # 实体,实现 Serializable
│ └── service/UserService.java # 服务接口
├── user-provider # 服务提供者
│ ├── pom.xml
│ └── src/main
│ ├── java/cn/mf/user
│ │ ├── UserProviderApplication.java # 启动类
│ │ ├── mapper/UserMapper.java # MyBatis Mapper
│ │ └── service/impl
│ │ ├── UserServiceImpl.java # @DubboService(version=1.0.0)
│ │ └── UserServiceImpl2.java # @DubboService(version=2.0.0)
│ └── resources/application.yml
└── user-consumer # 服务消费者(下一篇)
└── src/main/java/cn/mf/user
├── UserConsumerApplication.java
└── controller/UserController.java
为什么要把接口单独抽成 user-api?因为 Consumer 只依赖接口就能发起调用,不需要 Provider 的实现类和 Mapper。抽出来之后两边都引这一个模块,接口改了两边一起感知,也避免把 Mapper、数据库驱动这类东西泄漏给消费方。入门阶段先在 provider 里写接口,跑通之后再抽,本文按这个顺序讲。
环境准备
Nacos 用单机模式启动即可,别用默认的 cluster 模式,会起不来:
bash
# 解压到不含中文和空格的目录后进入 bin
cd nacos/bin
# Linux / macOS
sh startup.sh -m standalone
# Windows
startup.cmd -m standalone
启动完成后访问 http://127.0.0.1:8848/nacos,默认账号密码都是 nacos。Provider 注册上来之后,在「服务管理 → 服务列表」里能看到。
MySQL 建一张表供 Provider 查询:
sql
CREATE DATABASE `dubbo-demo` DEFAULT CHARSET utf8mb4;
USE `dubbo-demo`;
CREATE TABLE `tb_user` (
`id` BIGINT(20) NOT NULL AUTO_INCREMENT,
`username` VARCHAR(50) DEFAULT NULL,
`address` VARCHAR(100) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO `tb_user` VALUES (1, '张三', '上海市浦东新区');
INSERT INTO `tb_user` VALUES (2, '李四', '北京市朝阳区');
想看更直观的服务详情,可以再起一个 Dubbo Admin(它自己也要连同一个注册中心):
bash
git clone https://github.com/apache/dubbo-admin.git
cd dubbo-admin
# 在 dubbo-admin-server/src/main/resources/application.properties 里指定注册中心地址
mvn clean package -Dmaven.test.skip=true
mvn --projects dubbo-admin-server spring-boot:run
# 访问 http://localhost:8080
引入 Dubbo 与 Nacos 依赖
这一步做两件事:把 Dubbo 的 Spring Boot 起步依赖引进来,让它接管服务的暴露;再引入 Nacos 注册中心的实现,没有它 Dubbo 不知道往哪儿注册。
父工程 dubbo-demo/pom.xml:
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">
<modelVersion>4.0.0</modelVersion>
<groupId>cn.mf</groupId>
<artifactId>dubbo-demo</artifactId>
<packaging>pom</packaging>
<version>1.0-SNAPSHOT</version>
<!-- 聚合模块:packaging=pom 却不声明 modules,mvn package 只会构建父工程本身 -->
<modules>
<module>user-api</module>
<module>user-server</module>
<module>user-provider</module>
<module>user-consumer</module>
</modules>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.9.RELEASE</version>
<relativePath/>
</parent>
<properties>
<java.version>1.8</java.version>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring-cloud.version>Hoxton.SR10</spring-cloud.version>
<mysql.version>5.1.47</mysql.version>
<mybatis.version>2.1.1</mybatis.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>${mysql.version}</version>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>${mybatis.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>
</project>
Provider 模块 user-provider/pom.xml:
xml
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- mybatis -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</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>
<!-- 接口模块,抽出来之后通过它共享接口与实体 -->
<dependency>
<groupId>cn.mf</groupId>
<artifactId>user-api</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>
版本注意:dubbo-spring-boot-starter 和 dubbo-registry-nacos 必须同版本,这里统一 2.7.8。两边版本不一致是常见的 NoSuchMethodError 来源。另外 2.7.x 的注解包名是 org.apache.dubbo.config.annotation,老版本(2.6.x 及更早)是 com.alibaba.dubbo.config.annotation,网上的老教程经常混着写,复制的时候看清楚。
定义服务接口
这一步确定"对外承诺什么能力"。先写在 provider 里,包名 cn.mf.user.service:
java
package cn.mf.user.service;
import cn.mf.user.domain.User;
public interface UserService {
String queryUsername(Long id);
User queryById(Long id);
}
接口本身没有任何 Dubbo 相关的东西,就是个普通 Java 接口。这点很关键:Dubbo 的侵入性只在实现类一侧,接口保持干净,才能被 Consumer 直接依赖。
实体类顺手写好。User 会作为 RPC 的返回值在网络上传输,必须实现 Serializable:
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;
}
用 @DubboService 暴露实现
这一步是整个改造的实质:把 Spring 的 @Service 换成 @DubboService。
先看改造前的写法,这是单体工程里最常见的一行:
java
package cn.mf.user.service.impl;
import cn.mf.user.mapper.UserMapper;
import cn.mf.user.service.UserService;
import org.springframework.stereotype.Service;
// 改造前:只把对象放进 Spring 容器,外部进程访问不到
@Service
public class UserServiceImpl implements UserService {
private final UserMapper userMapper;
// 构造器注入省略
@Override
public String queryUsername(Long id) {
return userMapper.findById(id).getUsername();
}
}
@Service 做的事只有一个:创建一个 bean 放进 Spring 容器。这个 bean 活在 provider 自己的 JVM 里,北京的消费者不可能跨网络摸到它。换成 @DubboService 之后,Dubbo 除了创建 bean,还会额外做两件事------按协议在本机开一个端口监听请求,以及把"接口名 → 本机 IP:端口"这条记录写进 Nacos。
改造后的真实代码(user-provider 模块):
java
package cn.mf.user.service.impl;
import cn.mf.user.domain.User;
import cn.mf.user.mapper.UserMapper;
import cn.mf.user.service.UserService;
import org.apache.dubbo.config.annotation.DubboService;
import org.springframework.beans.factory.annotation.Autowired;
// 暴露 dubbo 服务:注册到注册中心,并在 20881 端口监听 RPC 请求
@DubboService(version = "1.0.0")
public class UserServiceImpl implements UserService {
@Autowired
private UserMapper userMapper;
@Override
public String queryUsername(Long id) {
return userMapper.findById(id).getUsername();
}
@Override
public User queryById(Long id) {
User user = userMapper.findById(id);
user.setUsername(user.getUsername() + "v1.0");
return user;
}
}
version = "1.0.0" 是 Dubbo 的多版本支持,用于灰度发布。同一个接口可以有多个实现各自带版本号,Consumer 用 @DubboReference(version = "2.0.0") 指定调哪个。入门阶段可以先不加,Dubbo 会用一个默认版本。源码里配套的第二个实现长这样,方便看出灰度是怎么配的:
java
package cn.mf.user.service.impl;
import cn.mf.user.domain.User;
import cn.mf.user.mapper.UserMapper;
import cn.mf.user.service.UserService;
import org.apache.dubbo.config.annotation.DubboService;
import org.springframework.beans.factory.annotation.Autowired;
// 同一接口的 2.0.0 版本实现,配合灰度发布使用
@DubboService(version = "2.0.0")
public class UserServiceImpl2 implements UserService {
@Autowired
private UserMapper userMapper;
@Override
public String queryUsername(Long id) {
return userMapper.findById(id).getUsername();
}
@Override
public User queryById(Long id) {
User user = userMapper.findById(id);
user.setUsername(user.getUsername() + "v2.0");
return user;
}
}
数据访问的 Mapper 保持原样,用注解方式写 SQL 就够了:
java
package cn.mf.user.mapper;
import cn.mf.user.domain.User;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface UserMapper {
@Select("select * from tb_user where id = #{id}")
User findById(@Param("id") Long id);
}
编写启动类
这一步没有 Dubbo 特有的东西,就是一个标准 Spring Boot 启动类:
java
package cn.mf.user;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class UserProviderApplication {
public static void main(String[] args) {
SpringApplication.run(UserProviderApplication.class, args);
}
}
两个常见疑问:
- 要不要加
@EnableDubbo? 可以不加。本案例用dubbo.scan.base-packages配置扫描路径,效果等价于@EnableDubbo(scanBasePackages = "...")。二选一即可,都写也不会报错,但没必要。 - 要不要加
@MapperScan? 本案例不需要,因为UserMapper上已经标了@Mapper,MyBatis 的自动配置能扫到。如果你用 XML 写 SQL 或者 Mapper 接口没标注解,才需要@MapperScan("cn.mf.user.mapper")。
配置 dubbo
这一步是让 Dubbo 知道"用什么协议、在哪个端口监听、往哪儿注册、扫哪个包"。
user-provider/src/main/resources/application.yml:
yaml
server:
port: 18080 # Tomcat 端口,服务 HTTP 请求
spring:
datasource:
url: jdbc:mysql://localhost:3306/dubbo-demo?useSSL=false
username: root
password: root
driver-class-name: com.mysql.jdbc.Driver
application:
name: user-provider # 应用名,注册中心的标识之一
logging:
level:
cn.mf: debug
pattern:
dateformat: HH:mm:ss:SSS
# 配置 dubbo 提供者
dubbo:
protocol:
name: dubbo # 协议名:dubbo 协议,长连接 + 二进制序列化,并发承载力高
port: 20881 # RPC 监听端口,默认从 20880 起,同机多实例自动 +1
registry:
address: nacos://127.0.0.1:8848 # 注册中心地址,nacos:// 前缀指定注册中心类型
scan:
base-packages: cn.mf.user.service # 扫描 @DubboService 注解,含子包
三个 dubbo 配置项逐个说明:
dubbo.protocol:协议和端口。name: dubbo是 Dubbo 自研的 TCP 长连接协议,单个连接就能承载较高并发,这是它比 HTTP 短连接快的主要原因。除 dubbo 外还支持 rmi、hessian、http、grpc 等,常规场景用 dubbo 就行。端口默认 20880,实测同一台机器起第二个 provider 会自动顺延到 20881,所以手动写 20881 是为了和默认值错开,避免本地调试时和别的服务抢端口。dubbo.registry.address:nacos://前缀决定用哪个注册中心实现,换成zookeeper://127.0.0.1:2181就走 Zookeeper,前提是 pom 里换掉dubbo-registry-nacos。dubbo.scan.base-packages:告诉 Dubbo 去哪些包下找@DubboService。这个包路径是 provider 自己代码里的路径 ,配置成cn.mf.user.service会连service.impl子包一起扫到。写错的话服务不会注册,而且启动时没有任何报错------这是最隐蔽的一个坑。
端口号对不上不是你的错:源码里 provider 的 Tomcat 端口是 18080,讲义上写的是 18081(18081 在源码里给了 consumer)。两个端口只要互相不冲突就行,别把它们和 20881 混为一谈。
启动与验证
跑 UserProviderApplication 的 main 方法,日志里出现这几行才算真的注册成功:
text
[DUBBO] Export dubbo service cn.mf.user.service.UserService to local registry, dubbo version: 2.7.8
[DUBBO] Register dubbo service cn.mf.user.service.UserService url dubbo://192.168.1.5:20881/cn.mf.user.service.UserService...
Nacos registry, cn.mf.user.service.UserService register finished
验证分两步:
Nacos 控制台 :打开 http://127.0.0.1:8848/nacos → 服务管理 → 服务列表,能看到名为 providers:cn.mf.user.service.UserService:1.0.0 的服务。点详情看实例,里面是本机内网 IP 和 20881 端口。注意这里显示的是内网 IP,不是 127.0.0.1------Dubbo 会主动探测网卡拿一个可被其他机器访问的地址,这个地址错了,跨机器就一定调不通。
Dubbo Admin :打开 http://localhost:8080 → 服务查询,能看到服务名、提供者数量、消费者数量。目前消费者是 0,下一篇写完 user-consumer 并启动后,这里就会变成 1。点「详情」能看到提供者的完整 URL 和端口。
控制台里能看到这条记录,就说明:服务已经暴露、已经注册、端口已经监听。剩下就是消费者的事了。
常见坑与排查
| 现象 | 原因 | 处理 |
|---|---|---|
Nacos 里查不到服务,启动日志无 [DUBBO] Register |
@DubboService 写成了 Spring 的 @Service,或两者都没写 |
实现类上只保留 org.apache.dubbo.config.annotation.DubboService |
| 服务没注册,但启动完全不报错 | dubbo.scan.base-packages 包路径写错,扫不到实现类 |
核对实现类所在包,配置到其父包即可(含子包) |
启动报 Failed to ... nacos / 连接超时 |
Nacos 没启动,或地址写错 | 先 curl 127.0.0.1:8848/nacos 确认;确认 -m standalone 单机模式 |
| 启动报端口占用 | dubbo.protocol.port 与本机其他 Dubbo 实例冲突 |
换一个端口,或删掉让 Dubbo 从 20880 自动分配 |
| 调用时报序列化异常 | 传输的实体没实现 Serializable |
给 DTO / 实体加上 implements Serializable,最好是抽到 api 模块统一管理 |
NoSuchMethodError / ClassNotFoundException |
dubbo-spring-boot-starter 与 dubbo-registry-nacos 版本不一致 |
两个依赖版本统一,本文统一 2.7.8 |
| 消费者拿到的 IP 是虚拟机网卡地址 | 多网卡环境下 Dubbo 选错了网卡 | 配置 dubbo.protocol.host 指定注册地址 |
关于序列化,这里多说一句。queryById 返回的是 User 对象,它要从 provider 的 JVM 跑到 consumer 的 JVM,中间必须被序列化成字节流。Dubbo 默认用的是自己的 Hessian2 二进制序列化,不是 JSON。实体不实现 Serializable 的话,DEBUG 阶段可能还能跑通,一旦参数或返回值复杂起来就会抛异常------别赌,直接实现。序列化协议怎么换、为什么要实现 Serializable 而不只是加个接口、以及 serialVersionUID 该不该写,下一篇单独展开。
配置速览
| 配置项 | 示例值 | 作用 |
|---|---|---|
dubbo.protocol.name |
dubbo |
RPC 协议,默认 dubbo,TCP 长连接 |
dubbo.protocol.port |
20881 |
RPC 监听端口,默认 20880,冲突时自动 +1 |
dubbo.registry.address |
nacos://127.0.0.1:8848 |
注册中心地址,前缀决定实现类型 |
dubbo.scan.base-packages |
cn.mf.user.service |
扫描 @DubboService 的根包,含子包 |
spring.application.name |
user-provider |
应用名,注册中心与监控里的标识 |
server.port |
18080 |
Tomcat 端口,与 Dubbo 端口互不相干 |
注解速览:
| 注解 | 位置 | 作用 |
|---|---|---|
@DubboService |
Provider 实现类 | 暴露服务,支持 version、timeout、weight 等属性 |
@DubboReference |
Consumer 字段 | 引用远程服务,支持 version、loadbalance、check、retries |
@EnableDubbo |
启动类 | 开启 Dubbo 并指定扫描包,与 dubbo.scan.base-packages 二选一 |
官方文档
总结
改造一个服务提供者,代码层面的改动量小得有点反直觉:一个注解、一个配置块。真正需要理解的是这两个改动背后发生的事------@DubboService 让 Dubbo 在本机开了 20881 端口并把地址写进 Nacos,配置块决定了协议、端口、注册中心地址和扫描范围。
几个容易记混的点再钉一遍:Dubbo 端口(20881)和 Tomcat 端口(18080)是两套东西;注册中心只发地址不转发流量,所以它是弱依赖;扫描包写错不会报错只会静默失败,这个是排查时最容易漏的。
服务提供者的另一端是消费者:@DubboReference 拿到代理、超时重试、启动检查、负载均衡,以及本文埋下的序列化问题,后面接着写。