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

概述

把一个单体工程里的 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 + 实体 User
    • user-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 拿到代理、超时重试、启动检查、负载均衡,以及本文埋下的序列化问题,后面接着写。

相关推荐
秋饼2 小时前
Spring AI 2.0 结构化输出企业级实战:从 BeanOutputConverter 源码到生产级解析容错
java·ai·技术分享·后端开发
摇滚侠2 小时前
《计算机是如何工作的:人人都能懂的计算机软硬件工作原理》第 9 章 机器码与汇编语言 阅读笔记 9
java·开发语言·笔记
Cc.Y2 小时前
Java零基础入门:深入Java核心:文件IO与序列化全解
java·开发语言·python
拿本唠嗑AI研究2 小时前
本体工程实战指南:从建模到可维护的完整落地路径
java·人工智能·python·jena·ontology
lambo mercy2 小时前
Day3:结构化输出
java·数据库·oracle
程序员清风2 小时前
最短路径算法:BFS、Dijkstra 与 Floyd
java·算法·宽度优先
智能直播4 小时前
RK3528 V4L2 框架 UVC 接口自动获取 H.264 / MJPEG / YUYV 格式:从内核采集到硬解分发
java·网络·实时音视频·视频编解码·h.264
171320330计算机毕设编程4 小时前
计算机毕业设计 基于YOLO改进算法的车辆特征识别系统设计与实现--lw
java·大数据·python·sql·算法
badhope4 小时前
Spring AI Alibaba停更:Java程序员的信任,还能被辜负几次?
java·spring