Skip to content
DAILY QUOTE

“ ”

0.导入黑马商城项目

0.1.安装MySQL

将课程给到的配置文件和初始化脚本复制到虚拟机或者服务器目录下:

创建通用网络:

bash
docker network create hm-net

使用docker命令安装MySQL:

bash
docker run -d \
  --name mysql \
  -p 127.0.0.1:3307:3306 \
  -e TZ=Asia/Shanghai \
  -e MYSQL_ROOT_PASSWORD=123 \
  -v /home/ubuntu/hmall/mysql/data:/var/lib/mysql \
  -v /home/ubuntu/hmall/mysql/conf:/etc/mysql/conf.d \
  -v /home/ubuntu/hmall/mysql/init:/docker-entrypoint-initdb.d \
  --network hm-net\
  mysql:8.0

查看容器是否启动:

bash
docker ps -a

可以看到已经正常运行:

使用MySQL客户端工具连接MySQL,可以看到黑马商场所需要的表:

0.2.后端

将课程资料中的hmall目录复制到工作目录,在Idea打开。 项目结构如下:

按下ALT + 8键打开services窗口,新增一个启动项:

在弹出窗口添加Spring Boot,对HMallApplication进行配置: 将SpringBoot启动环境设置为local:

启动后,访问http://localhost:8080/hi查看效果:

0.3.前端

将课程中的hmall-nginx目录放在一个全英文目录下,利用cmd启动:

bash
#启动nginx
start nginx.exe
#停止
nginx.exe -s stop
#重新加载配置
nginx.exe -s reload
#重启
nginx.exe -s restart

nginx.exe不要双击启动,而是打开cmd窗口,通过命令行启动。停止的时候也一样要是用命令停止。如果启动失败不要重复启动,而是查看logs目录中的error.log日志,查看是否是端口冲突。如果是端口冲突则自行修改端口解决。

启动成功后,访问http://localhost:18080,即可查看门户页面:

1.认识微服务

1.1.单体架构

单体架构就是把项目的所有功能模块都放在一个工程里开发,部署时也把整个项目统一打包、统一发布。

比如一个商城系统中,用户、商品、购物车、订单、支付等功能都在同一个服务里。

单体架构的优点是简单,适合项目早期:

text
开发简单部署简单学习成本低适合小项目或早期项目快速上线

但是随着项目变大,单体架构的问题会越来越明显:

问题说明
团队协作成本高多个人同时改同一个工程,代码冲突多,模块边界不清晰
发布效率低任意模块改动都要重新打包发布整个系统
可用性差某个热点接口占满资源后,其他正常接口也会被拖慢
扩展性差即使水平扩容,也可能被某个热点功能继续占用资源
接下来通过项目做一个系统可用性的演示:
首先i需改hm-service模块下的com.hmall.controller.HelloController中的hello方法,模拟方法耗时:

启动项目,目前两个接口是无需登录即可访问的:

1.2.微服务

微服务架构就是把原来单体项目里的不同业务模块拆出来,变成多个可以独立开发、独立部署、独立运行的服务。

黑马商城可以拆成:

text
用户服务
商品服务
购物车服务
订单交易服务
支付服务

微服务有以下特点:

特点说明
单一职责一个服务只负责一类业务,比如商品服务只处理商品相关功能
团队自治每个服务可以由独立小团队开发、测试、发布和维护
服务自治每个服务独立部署,最好有自己的数据库,减少互相影响
例如,黑马商城项目,我们就可以把商品、用户、购物车、交易等模块拆分,交给不同的团队去开发,并独立部署:

微服务解决了单体架构的几个核心问题:

单体架构问题微服务解决方式
协作成本高按服务拆分后,每个团队只维护自己的服务
发布效率低某个服务改动,只需要发布这个服务
可用性差服务之间资源隔离,一个服务异常不一定拖垮所有服务
扩展困难哪个服务压力大,就单独扩容哪个服务

但微服务也会带来新问题:

text
服务之间怎么调用?
请求到底先访问哪个服务?
服务变多以后怎么管理地址?
某个服务宕机后调用方怎么感知?
多个服务一起完成一个业务时怎么保证一致性?

所以后面才会引出Nacos、OpenFeign、Gateway、Sentinel、Seata等组件。

1.3.SpringCloud

Spring Cloud可以理解为Java微服务开发的一套组件集合。它不是单个技术,而是一整套微服务解决方案。

它主要用来解决:

text
服务注册与发现
服务远程调用
负载均衡
统一网关
配置管理
服务熔断降级
分布式事务
链路追踪

拆分服务:把一个程序变成多个独立程序 Spring Cloud:帮助这些独立程序互相发现、调用和管理 Spring Cloud Alibaba:用Nacos、Sentinel等组件实现这些能力

而且SpringCloud依托于SpringBoot的自动装配能力,大大降低了其项目搭建、组件使用的成本。对于没有自研微服务组件能力的中小型企业,使用SpringCloud全家桶来实现微服务开发是最合适的选择。 Spring Cloud

Spring Cloud和Spring Boot版本必须匹配,企业项目里不能随便乱搭。

父工程hmall已经配置了SpringCloud以及SpringCloudAlibaba的依赖:

对应的版本:

这样在后续使用SpringCloud或者SpringCloudAlibaba组件就不需要单独制定版本了。

2.微服务拆分

2.1.熟悉黑马山城

首先熟悉黑马商城基本结构:

修改application-local.yaml中的连接参数:

yaml
hm:
  db:
    host: 127.0.0.1 # 修改为服务器地址
    port: 13307
    pw: 123 # 修改为docker中的MySQL密码

同时配置启动项激活的是local环境:

2.1.1.登录

首先是登陆业务流程:

登录入口在com.hmall.controller.UserController中的login方法:

登录模块后续适合拆到:

text
用户服务user-service

2.1.2.搜索商品

在首页搜索框输入关键字,点击搜索即可进入搜索列表页面:

该页面会调用接口:/search/list,对应的服务端入口在com.hmall.controller.SearchController中的search方法:

目前原项目中是通过数据库做简单分页查询。

后续商品相关能力适合拆到:

text
商品服务item-service

2.1.3.购物车

在搜索到的商品列表中,点击按钮加入购物车,即可将商品加入购物车:

加入成功后即可进入购物车列表页,查看自己购物车商品列表:

同时这里还可以对购物车实现修改、删除等操作。 相关功能全部在com.hmall.controller.CartController中:

查询购物车时,不只是查购物车表,还需要查询商品的最新价格、库存、状态。

这就引出后面的问题:

text
购物车服务需要商品信息,但商品数据在商品服务里,怎么办?

答案就是:跨服务远程调用。

2.1.4.下单

在购物车页面点击结算按钮,会进入订单结算页面:

下单时服务端主要做三件事:

text
创建订单
扣减库存
清理购物车

业务入口在com.hmall.controller.OrderController中的createOrder方法:

2.1.5.支付

下单完成后会跳转到支付页面,目前只支持余额支付

余额支付时,服务端主要做:

text
校验用户密码
扣减用户余额
修改支付流水状态
修改订单状态

请求入口在com.hmall.controller.PayController中:

后续适合拆到:

text
支付服务pay-service

2.2.服务拆分原则

2.2.1. 什么时候拆微服务?

不是所有项目一开始都要上微服务。

小型项目、创业项目、验证型项目,通常先用单体架构。因为这个阶段最重要的是快速开发、快速上线、验证业务是否能跑通。

等业务规模变大、团队人数增多、模块之间冲突明显、某些服务压力变大之后,再逐步拆成微服务。

大型项目如果一开始目标明确、资源充足,可以从立项阶段就设计成微服务架构。

可以总结成一句话:

text
小项目先单体,业务变复杂后再拆;大项目资源充足,可以一开始就微服务。

2.2.2. 怎么拆微服务?

核心原则是:

text
高内聚,低耦合
  • 高内聚 一个服务内部的功能应该高度相关,围绕同一类业务展开。

比如商品服务内部可以包含:

text
商品查询
商品上下架
库存扣减
商品详情

这些都属于商品相关业务,放在一起比较合理。

  • 低耦合 服务之间尽量不要直接依赖彼此的数据库。

比如订单服务需要商品价格时,不能直接查商品服务的数据库,而应该调用商品服务暴露出来的接口。

错误方式:

text
订单服务 → 直接查商品库

正确方式:

text
订单服务 → 调用商品服务接口 → 商品服务查自己的数据库

这样即使商品服务内部表结构改了,只要对外接口不变,订单服务就不需要改。

2.2.3. 拆分方式

服务拆分主要有两种方式:

拆分方式说明例子
纵向拆分按业务功能拆用户服务、商品服务、订单服务、支付服务
横向拆分抽取公共能力消息服务、风控服务、文件服务、通知服务

黑马商城主要采用的是纵向拆分。

最后可以拆成:

text
user-service:用户服务
item-service:商品服务
cart-service:购物车服务
trade-service:交易服务
pay-service:支付服务

2.3.拆分购物车,商品服务

接下来,我们先把商品管理功能、购物车功能抽取为两个独立服务。

一般微服务项目有两种不同的工程结构:

  • 完全解耦:每一个微服务都创建为一个独立的工程,甚至可以使用不同的开发语言来开发,项目完全解耦。

    • 优点:服务之间耦合度低
    • 缺点:每个项目都有自己的独立仓库,管理起来比较麻烦
  • Maven聚合:整个项目为一个Project,然后每个微服务是其中的一个Module

    • 优点:项目代码集中,管理和运维方便
    • 缺点:服务之间耦合,编译时间较长

在hmall父工程之中,我已经提前定义了SpringBoot、SpringCloud的依赖版本,所以为了方便期间,我们直接在这个项目中创建微服务module.

2.3.1.商品服务

在hmall中创建moudle:

引入依赖:

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>hmall</artifactId>
        <groupId>com.heima</groupId>
        <version>1.0.0</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>item-service</artifactId>

    <properties>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
    </properties>
    <dependencies>
        <!--common-->
        <dependency>
            <groupId>com.heima</groupId>
            <artifactId>hm-common</artifactId>
            <version>1.0.0</version>
        </dependency>
        <!--web-->
        <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>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
        </dependency>
        <!--单元测试-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
        </dependency>
    </dependencies>
    <build>
        <finalName>${project.artifactId}</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

编写启动类:

代码如下:

java
package com.hmall.item;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@MapperScan("com.hmall.item.mapper")
@SpringBootApplication
public class ItemApplication {
    public static void main(String[] args) {
        SpringApplication.run(ItemApplication.class, args);
    }
}

配置文件可以从hm-service拷贝:

其中application.yaml内容如下:

yaml
server:
  port: 8081
spring:
  application:
    name: item-service
  profiles:
    active: dev
  datasource:
    url: jdbc:mysql://${hm.db.host}:${hm.db.port}/hm-item?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai
    driver-class-name: com.mysql.cj.jdbc.Driver
    username: root
    password: ${hm.db.pw}
mybatis-plus:
  configuration:
    default-enum-type-handler: com.baomidou.mybatisplus.core.handlers.MybatisEnumTypeHandler
  global-config:
    db-config:
      update-strategy: not_null
      id-type: auto
logging:
  level:
    com.hmall: debug
  pattern:
    dateformat: HH:mm:ss:SSS
  file:
    path: "logs/${spring.application.name}"
knife4j:
  enable: true
  openapi:
    title: 商品服务接口文档
    description: "信息"
    email: zhanghuyi@itcast.cn
    concat: 虎哥
    url: https://www.itcast.cn
    version: v1.0.0
    group:
      default:
        group-name: default
        api-rule: package
        api-rule-resources:
          - com.hmall.item.controller

剩下的application-dev.yamlapplication-local.yaml直接从hm-service拷贝即可。

然后拷贝hm-service中与商品管理有关的代码到item-service,如图: 这里有一个地方的代码需要改动,就是ItemServiceImpl中的deductStock方法:

最后导入数据库表,执行提供的SQL文件:

最终,会在数据库创建一个名为hm-item的database,将来的每一个微服务都会有自己的一个database:

在企业开发的生产环境中,每一个微服务都应该有自己的独立数据库服务,而不仅仅是database,课堂我们用database来代替。

接下来,就可以启动测试了,在启动前我们要配置一下启动项,让默认激活的配置为local而不是dev

接着,启动item-service,访问swagger接口文档:http://localhost:8081/doc.html

然后测试其中的根据id批量查询商品这个接口:

测试参数:100002672302,100002624500,100002533430,结果如下: 说明商品微服务抽取成功了。

2.3.2.购物车服务

与商品服务类似,在hmall下创建一个新的module,起名为cart-service:

依赖:

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>hmall</artifactId>
        <groupId>com.heima</groupId>
        <version>1.0.0</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>cart-service</artifactId>

    <properties>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
    </properties>

    <dependencies>
        <!--common-->
        <dependency>
            <groupId>com.heima</groupId>
            <artifactId>hm-common</artifactId>
            <version>1.0.0</version>
        </dependency>
        <!--web-->
        <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>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
        </dependency>
        <!--单元测试-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
        </dependency>
    </dependencies>
    <build>
        <finalName>${project.artifactId}</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

启动类:

java
package com.hmall.cart;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@MapperScan("com.hmall.cart.mapper")
@SpringBootApplication
public class CartApplication {
    public static void main(String[] args) {
        SpringApplication.run(CartApplication.class, args);
    }
}

配置文件同样拷贝item-service,不过其中application.yaml需要修改:

yaml
server:
  port: 8082
spring:
  application:
    name: cart-service
  profiles:
    active: dev
  datasource:
    url: jdbc:mysql://${hm.db.host}:${hm.db.port}/hm-cart?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai
    driver-class-name: com.mysql.cj.jdbc.Driver
    username: root
    password: ${hm.db.pw}
mybatis-plus:
  configuration:
    default-enum-type-handler: com.baomidou.mybatisplus.core.handlers.MybatisEnumTypeHandler
  global-config:
    db-config:
      update-strategy: not_null
      id-type: auto
logging:
  level:
    com.hmall: debug
  pattern:
    dateformat: HH:mm:ss:SSS
  file:
    path: "logs/${spring.application.name}"
knife4j:
  enable: true
  openapi:
    title: 购物车服务接口文档
    description: "信息"
    email: zhanghuyi@itcast.cn
    concat: 虎哥
    url: https://www.itcast.cn
    version: v1.0.0
    group:
      default:
        group-name: default
        api-rule: package
        api-rule-resources:
          - com.hmall.cart.controller

最后,把hm-service中的与购物车有关功能拷贝过来,最终的项目结构如下:

特别注意的是com.hmall.cart.service.impl.CartServiceImpl,其中有两个地方需要处理:

  • 需要获取登录用户信息,但登录校验功能目前没有复制过来,先写死固定用户id
  • 查询购物车时需要查询商品信息,而商品信息不在当前服务,需要先将这部分代码注释

我们对这部分代码做如下修改:

java
package com.hmall.cart.service.impl;

import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmall.cart.domain.dto.CartFormDTO;
import com.hmall.cart.domain.po.Cart;
import com.hmall.cart.domain.vo.CartVO;
import com.hmall.cart.mapper.CartMapper;
import com.hmall.cart.service.ICartService;
import com.hmall.common.exception.BizIllegalException;
import com.hmall.common.utils.BeanUtils;
import com.hmall.common.utils.CollUtils;
import com.hmall.common.utils.UserContext;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.Collection;
import java.util.List;

/**
 * <p>
 * 订单详情表 服务实现类
 * </p>
 *
 * @author 虎哥
 * @since 2023-05-05
 */
@Service
@RequiredArgsConstructor
public class CartServiceImpl extends ServiceImpl<CartMapper, Cart> implements ICartService {

    // private final IItemService itemService;

    @Override
    public void addItem2Cart(CartFormDTO cartFormDTO) {
        // 1.获取登录用户
        Long userId = UserContext.getUser();

        // 2.判断是否已经存在
        if (checkItemExists(cartFormDTO.getItemId(), userId)) {
            // 2.1.存在,则更新数量
            baseMapper.updateNum(cartFormDTO.getItemId(), userId);
            return;
        }
        // 2.2.不存在,判断是否超过购物车数量
        checkCartsFull(userId);

        // 3.新增购物车条目
        // 3.1.转换PO
        Cart cart = BeanUtils.copyBean(cartFormDTO, Cart.class);
        // 3.2.保存当前用户
        cart.setUserId(userId);
        // 3.3.保存到数据库
        save(cart);
    }

    @Override
    public List<CartVO> queryMyCarts() {
        // 1.查询我的购物车列表
        List<Cart> carts = lambdaQuery().eq(Cart::getUserId, 1L /*TODO UserContext.getUser()*/).list();
        if (CollUtils.isEmpty(carts)) {
            return CollUtils.emptyList();
        }
        // 2.转换VO
        List<CartVO> vos = BeanUtils.copyList(carts, CartVO.class);
        // 3.处理VO中的商品信息
        handleCartItems(vos);
        // 4.返回
        return vos;
    }

    private void handleCartItems(List<CartVO> vos) {
        // 1.获取商品id TODO处理商品信息
        /*Set<Long> itemIds = vos.stream().map(CartVO::getItemId).collect(Collectors.toSet());
        // 2.查询商品
        List<ItemDTO> items = itemService.queryItemByIds(itemIds);
        if (CollUtils.isEmpty(items)) {
            throw new BadRequestException("购物车中商品不存在!");
        }
        // 3.转为id到item的map
        Map<Long, ItemDTO> itemMap = items.stream().collect(Collectors.toMap(ItemDTO::getId, Function.identity()));
        // 4.写入vo
        for (CartVO v : vos) {
            ItemDTO item = itemMap.get(v.getItemId());
            if (item == null) {
                continue;
            }
            v.setNewPrice(item.getPrice());
            v.setStatus(item.getStatus());
            v.setStock(item.getStock());
        }*/
    }

    @Override
    public void removeByItemIds(Collection<Long> itemIds) {
        // 1.构建删除条件,userId和itemId
        QueryWrapper<Cart> queryWrapper = new QueryWrapper<Cart>();
        queryWrapper.lambda()
                .eq(Cart::getUserId, UserContext.getUser())
                .in(Cart::getItemId, itemIds);
        // 2.删除
        remove(queryWrapper);
    }

    private void checkCartsFull(Long userId) {
        int count = lambdaQuery().eq(Cart::getUserId, userId).count();
        if (count >= 10) {
            throw new BizIllegalException(StrUtil.format("用户购物车课程不能超过{}", 10));
        }
    }

    private boolean checkItemExists(Long itemId, Long userId) {
        int count = lambdaQuery()
                .eq(Cart::getUserId, userId)
                .eq(Cart::getItemId, itemId)
                .count();
        return count > 0;
    }
}

最后,还是要导入数据库表,在本地数据库直接执行课前资料对应的SQL文件:

接下来配置启动项active profilelocal,然后启动CartApplication,访问swagger文档页面:

我们测试其中的查询我的购物车列表接口:

无需填写参数,直接访问:

将vo属性中stock与status默认值去掉可以看到其中与商品有关的几个字段值都为空,这是因为注释掉了查询商品信息的代码。

2.4.服务调用

拆分前:

text
购物车功能和商品功能都在hm-service中CartService可以直接调用ItemService

拆分后:

text
购物车功能在cart-service商品功能在item-service

这时cart-service不能直接调用本地的ItemService,只能通过网络请求访问item-service提供的接口。

调用关系变成:

text
cart-service → HTTP请求 → item-service

因此,查询购物车列表流程变成了下图:

代码中需要变化的就是这一步:

2.4.1.RestTemplate

Spring给我们提供了一个RestTemplate的API,可以方便的实现Http请求的发送。

其中提供了大量的方法,方便我们发送Http请求,例如:

可以看到常见的Get、Post、Put、Delete请求都支持,如果请求参数比较复杂,还可以使用exchange方法来构造请求。

我们在cart-service服务中定义一个配置类:

先将RestTemplate注册为一个Bean:

java
package com.hmall.cart.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;

@Configuration
public class RemoteCallConfig {

    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}

2.4.2.远程调用

接下来修改cart-service中的com.hmall.cart.service.impl.``CartServiceImplhandleCartItems方法,发送http请求到item-service

java
private void handleCartItems(List<CartVO> vos) {
    // TODO 1.获取商品id
    Set<Long> itemIds = vos.stream().map(CartVO::getItemId).collect(Collectors.toSet());
    // 2.查询商品
    // List<ItemDTO> items = itemService.queryItemByIds(itemIds);
    // 2.1.利用RestTemplate发起http请求,得到http的响应
    ResponseEntity<List<ItemDTO>> response = restTemplate.exchange(
            "http://localhost:8081/items?ids={ids}",
            HttpMethod.GET,
            null,
            new ParameterizedTypeReference<List<ItemDTO>>() {
            },
            Map.of("ids", CollUtil.join(itemIds, ","))
    );
    // 2.2.解析响应
    if(!response.getStatusCode().is2xxSuccessful()){
        // 查询失败,直接结束
        return;
    }
    List<ItemDTO> items = response.getBody();
    if (CollUtils.isEmpty(items)) {
        return;
    }
    // 3.转为id到item的map
    Map<Long, ItemDTO> itemMap = items.stream().collect(Collectors.toMap(ItemDTO::getId, Function.identity()));
    // 4.写入vo
    for (CartVO v : vos) {
        ItemDTO item = itemMap.get(v.getItemId());
        if (item == null) {
            continue;
        }
        v.setNewPrice(item.getPrice());
        v.setStatus(item.getStatus());
        v.setStock(item.getStock());
    }
}

可以看到,利用RestTemplate发送http请求与前端ajax发送请求非常相似,都包含四部分信息:

  • ① 请求方式
  • ② 请求路径
  • ③ 请求参数
  • ④ 返回值类型

启动item-service,然后重启cart-service,再次测试查询我的购物车列表接口:

RestTemplate虽然能实现远程调用,但有明显缺点:

text
服务地址写死了,例如localhost:8081
如果商品服务部署多个实例,不知道调用哪个
如果某个实例宕机,调用方无法自动感知
代码写起来比较繁琐和本地方法调用差异很大

所以后面要引入:

text
Nacos:解决服务地址管理问题
OpenFeign:解决远程调用代码繁琐问题

3.服务注册和发现

  • 为什么需要注册中心? 如果item-service只有一个实例,cart-service可以写死地址:
text
http://localhost:8081

但真实项目中,一个服务可能部署多个实例:

text
item-service-1:192.168.1.10:8081
item-service-2:192.168.1.11:8081
item-service-3:192.168.1.12:8081

这时会出现几个问题:

text
cart-service怎么知道item-service有哪些实例?
该调用哪个实例?
某个实例宕机了怎么办?
临时新增实例后,调用方怎么知道?

注册中心就是为了解决这些问题。

3.1.注册中心原理

在微服务远程调用的过程中,包括两个角色:

  • 服务提供者:提供接口供其它微服务访问,比如item-service
  • 服务消费者:调用其它微服务提供的接口,比如cart-service

在大型微服务项目中,服务提供者的数量会非常多,为了管理这些服务就引入了注册中心的概念。注册中心、服务提供者、服务消费者三者间关系如下:

流程:

text
1. 服务启动后,把自己的服务名、IP、端口注册到Nacos
2. 消费者从Nacos拉取目标服务的实例列表
3. 消费者通过负载均衡选择一个实例
4. 消费者向这个实例发起远程调用
5. 服务实例定期向Nacos发送心跳
6. 如果实例宕机,Nacos会剔除该实例
7. 如果新增实例,Nacos会更新服务列表并通知消费者

3.2.Nacos注册中心

目前开源的注册中心框架有很多,国内比较常见的有:

  • Eureka:Netflix公司出品,目前被集成在SpringCloud当中,一般用于Java应用
  • Nacos:Alibaba公司出品,目前被集成在SpringCloudAlibaba中,一般用于Java应用
  • Consul:HashiCorp公司出品,目前集成在SpringCloud中,不限制微服务语言

Nacos是国内产品,中文文档丰富,具备配置管理功能,故使用Nacos来学习。 官方网站:Nacos官网| Nacos配置中心 | Nacos下载| Nacos官方社区 | Nacos官网

基于Docker来部署Nacos的注册中心,首先准备MySQL数据库表,用来存储Nacos数据。由于是Docker部署,所以将资料中的SQL文件导入MySQL容器中,表结构如下:

修改课程资料下的nacos文件夹,将其中nacos/custom.env文件中的MYSQL_SERVICE_HOST修改为容器名称,并将nacos运行在mysql容器网络: 将nacos目录上传到服务器,进入相应目录,执行docker命令:

bash
docker run -d \
--name nacos \
--env-file ./nacos/custom.env \
--network hm-net \
-p 127.0.0.1:8848:8848 \
-p 127.0.0.1:9848:9848 \
-p 127.0.0.1:9849:9849 \
--restart=always \
nacos/nacos-server:v2.1.0-slim

8848:Nacos 主端口,控制台、HTTP API、客户端主连接地址
9848:Nacos 2.x 客户端 gRPC 通信端口
9849:Nacos 服务端之间 gRPC 通信端口,主要用于集群同步

本地开ssh隧道:

bash
ssh -N -L 8848:127.0.0.1:8848 -L 9848:127.0.0.1:9848 <用户>@<服务器公网IP>

本地浏览器访问:

text
http://127.0.0.1:8848/nacos/

账号密码都是nacos。

3.3.服务注册

接下来把item-service注册到Nacos,步骤如下:

  • 引入依赖
  • 配置Nacos地址
  • 重启

3.3.1.添加依赖

item-servicepom.xml中添加依赖:

xml
<!--nacos服务注册发现-->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

3.3.2.配置Nacos

item-serviceapplication.yml中添加nacos地址配置:

yaml
spring:
  application:
    name: item-service # 服务名称
  cloud:
    nacos:
      server-addr: 127.0.0.1:8848 # nacos地址

3.3.3.启动服务实例

为测试一个服务多个实例的情况,再配置一个item-service的部署实例:

重启item-service的两个实例:

访问nacos控制台,发现注册成功:

3.4.服务发现

服务的消费者要去nacos订阅服务,这个过程就是服务发现,步骤如下:

  • 引入依赖
  • 配置Nacos地址
  • 发现并调用服务

3.4.1.引入依赖

服务发现除了要引入nacos依赖以外,由于还需要负载均衡,因此要引入SpringCloud提供的LoadBalancer依赖。

我们在cart-service中的pom.xml中添加下面的依赖:

xml
<!--nacos服务注册发现-->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

可以发现,这里Nacos的依赖于服务注册时一致,这个依赖中同时包含了服务注册和发现的功能。因为任何一个微服务都可以调用别人,也可以被别人调用,即可以是调用者,也可以是提供者。

因此,等一会儿cart-service启动,同样会注册到Nacos

3.4.2.配置Nacos地址

cart-serviceapplication.yml中添加nacos地址配置:

yaml
spring:
  cloud:
    nacos:
      server-addr: 127.0.0.1:8848

3.4.3.发现并调用服务

接下来,服务调用者cart-service就可以去订阅item-service服务了。不过item-service有多个实例,而真正发起调用时只需要知道一个实例的地址。

因此,服务调用者必须利用负载均衡的算法,从多个实例中挑选一个去访问。常见的负载均衡算法有:

  • 随机
  • 轮询
  • IP的hash
  • 最近最少访问
  • ...

这里我们可以选择最简单的随机负载均衡。

另外,服务发现需要用到一个工具,DiscoveryClient,SpringCloud已经帮我们自动装配,我们可以直接注入使用:

接下来,我们就可以对原来的远程调用做修改了,之前调用时我们需要写死服务提供者的IP和端口:

但现在不需要了,我们通过DiscoveryClient发现服务实例列表,然后通过负载均衡算法,选择一个实例去调用:

测试:

4.OpenFeign

上一章利用Nacos实现了服务的治理,利用RestTemplate实现了服务的远程调用,但调用代码太复杂了。

这种调用方式与本地方法调用差异太大,因此需要改变远程调用的开发模式,让远程调用像本地方法调用一样简单,这就需要用到OpenFeign组件。

远程调用的关键点就在于四个:

  • 请求方式
  • 请求路径
  • 请求参数
  • 返回值类型

OpenFeign就利用SpringMVC的相关注解来声明上述4个参数,然后基于动态代理帮我们生成远程调用的代码,而无需我们手动再编写,非常方便。

4.1.快速入门

4.1.1.引入依赖

cart-service服务pom文件中引入OpenFeign依赖和loadBalancer依赖:

xml
  <!--openFeign-->
  <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-starter-openfeign</artifactId>
  </dependency>
  <!--负载均衡器-->
  <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-starter-loadbalancer</artifactId>
  </dependency>

4.1.2.启用OpenFeign

接下来在cart-serviceCartApplication启动类添加注解,启动OpenFeign功能:

4.1.3.编写OpenFeign客户端

cart-service中,定义一个新的接口,编写Feign客户端: 其中代码如下:

java
package com.hmall.cart.client;

import com.hmall.cart.domain.dto.ItemDTO;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

import java.util.List;

@FeignClient("item-service")
public interface ItemClient {

    @GetMapping("/items")
    List<ItemDTO> queryItemByIds(@RequestParam("ids") Collection<Long> ids);
}

这里只需要声明接口,无需实现方法。接口中的几个关键信息:

  • @FeignClient("item-service") :声明服务名称
  • @GetMapping :声明请求方式
  • @GetMapping("/items") :声明请求路径
  • @RequestParam("ids") Collection<Long> ids :声明请求参数
  • List<ItemDTO> :返回值类型

有了以上信息,OpenFeign就可以利用动态代理帮我们实现这个方法,向http://item-service/items发送一个GET请求,携带ids为请求参数,并自动将返回值处理为List<ItemDTO>

只需要直接调用这个方法,即可实现远程调用了。

4.1.4.使用FeiginClient

最后,我们在cart-servicecom.hmall.cart.service.impl.CartServiceImpl中改造代码,直接调用ItemClient的方法:

feign完成了服务拉取、负载均衡、发送http请求的所有工作。

4.2.连接池

Feign底层发起http请求,依赖于其它的框架。其底层支持的http客户端实现包括:

  • HttpURLConnection:默认实现,不支持连接池
  • Apache HttpClient :支持连接池
  • OKHttp:支持连接池

4.2.1.引入依赖

cart-servicepom.xml中引入依赖:

xml
<!--OkHttp依赖-->
<dependency>
  <groupId>io.github.openfeign</groupId>
  <artifactId>feign-okhttp</artifactId>
</dependency>

4.2.2.开启连接池

cart-serviceapplication.yml配置文件中开启Feign的连接池功能:

yaml
feign:
  okhttp:
    enabled: true # 开启OKHttp功能

重启服务,连接池就生效了。

4.2.3.验证

idea双击shift,在org.springframework.cloud.openfeign.loadbalancer.FeignBlockingLoadBalancerClient中的execute方法中打断点:

Debug方式启动cart-service,请求一次查询我的购物车方法,进入断点: 实现已经改为OkHttpClient

4.3.最佳实践

现在cart-service要调用item-service,所以写了ItemClient。

以后trade-service下单时也需要调用item-service查询商品信息,如果每个服务都写一份ItemClient,就会重复。

4.3.1.思路

避免重复编码的方法将就是抽取,这里有两种抽取思路:

  • 思路1:抽取到微服务之外的公共module
  • 思路2:每个微服务自己抽取一个module

如图:

方案1抽取更加简单,工程结构也比较清晰,但缺点是整个项目耦合度偏高。 方案2抽取相对麻烦,工程结构相对更复杂,但服务之间耦合度降低。

由于item-service已经创建好,无法继续拆分,因此这里我们采用方案1.

4.3.2.抽取Feigin客户端

hmall下定义一个新的module,命名为hm-api。

依赖如下:

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>hmall</artifactId>
        <groupId>com.heima</groupId>
        <version>1.0.0</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>hm-api</artifactId>

    <properties>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
    </properties>

    <dependencies>
        <!--open feign-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-openfeign</artifactId>
        </dependency>
        <!-- load balancer-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-loadbalancer</artifactId>
        </dependency>
        <!--Swagger注解依赖-->
        <dependency>
            <groupId>io.swagger</groupId>
            <artifactId>swagger-annotations</artifactId>
            <version>1.6.6</version>
            <scope>compile</scope>
        </dependency>
    </dependencies>
</project>

然后把ItemDTO和ItemClient拷贝过来,最终结构如下:

现在,任何微服务要调用item-service中的接口,只需要引入hm-api模块依赖即可,无需自己编写Feign客户端了。

4.3.3.扫描包

接下来,我们在cart-servicepom.xml中引入hm-api模块:

xml
  <!--feign模块-->
  <dependency>
      <groupId>com.heima</groupId>
      <artifactId>hm-api</artifactId>
      <version>1.0.0</version>
  </dependency>

删除cart-service中原来的ItemDTO和ItemClient,重启项目,发现报错了:

这里因为ItemClient现在定义到了com.hmall.api.client包下,而cart-service的启动类定义在com.hmall.cart包下,扫描不到ItemClient,所以报错了。

解决办法很简单,在cart-service的启动类上添加声明即可,两种方式:

  • 方式1:声明扫描包:
  • 方式2:声明要用的FeiginClient

4.4.日志配置

OpenFeign默认不输出详细请求日志。

日志级别有四种:

级别说明
NONE不记录日志,默认值
BASIC记录请求方法、URL、响应状态码、耗时
HEADERS在BASIC基础上记录请求头、响应头
FULL记录完整请求和响应,包括请求体、响应体

4.4.1.定义日志级别

在hm-api模块下新建一个配置类,定义Feign的日志级别: 代码如下:

java
package com.hmall.api.config;

import org.springframework.context.annotation.Bean;

public class DefaultFeignConfig {
    @Bean
    public feign.Logger.Level feignLoggerLevel() {
        return feign.Logger.Level.FULL;
    }
}

4.4.2.配置

接下来让日志生效,有两种方式:

  • 局部生效:在某个FeignClient中配置,只对当前FeignClient生效
java
@FeignClient(value = "item-service", configuration = DefaultFeignConfig.class)
  • 全局生效:在@EnableFeignClients中配置,针对所有FeignClient生效。
java
@EnableFeignClients(defaultConfiguration = DefaultFeignConfig.class)

日志格式:

text
com.hmall.api.client.ItemClient          : [ItemClient#queryItemByIds] ---> GET http://item-service/items?ids=100000006163 HTTP/1.1
14:30:09:387 DEBUG 31536 --- [nio-8082-exec-2] com.hmall.api.client.ItemClient          : [ItemClient#queryItemByIds] ---> END HTTP (0-byte body)
14:30:10:593 DEBUG 31536 --- [nio-8082-exec-2] com.hmall.api.client.ItemClient          : [ItemClient#queryItemByIds] <--- HTTP/1.1 200  (1206ms)
14:30:10:595 DEBUG 31536 --- [nio-8082-exec-2] com.hmall.api.client.ItemClient          : [ItemClient#queryItemByIds] connection: keep-alive
14:30:10:595 DEBUG 31536 --- [nio-8082-exec-2] com.hmall.api.client.ItemClient          : [ItemClient#queryItemByIds] content-type: application/json
14:30:10:595 DEBUG 31536 --- [nio-8082-exec-2] com.hmall.api.client.ItemClient          : [ItemClient#queryItemByIds] date: Wed, 10 Jun 2026 06:30:10 GMT
14:30:10:595 DEBUG 31536 --- [nio-8082-exec-2] com.hmall.api.client.ItemClient          : [ItemClient#queryItemByIds] keep-alive: timeout=60
14:30:10:595 DEBUG 31536 --- [nio-8082-exec-2] com.hmall.api.client.ItemClient          : [ItemClient#queryItemByIds] transfer-encoding: chunked
14:30:10:595 DEBUG 31536 --- [nio-8082-exec-2] com.hmall.api.client.ItemClient          : [ItemClient#queryItemByIds]
14:30:10:596 DEBUG 31536 --- [nio-8082-exec-2] com.hmall.api.client.ItemClient          : [ItemClient#queryItemByIds] [{"id":"100000006163","name":"巴布豆(BOBDOG)柔薄悦动婴儿拉拉裤XXL码80片(15kg以上)","price":67100,"stock":10000,"image":"https://m.360buyimg.com/mobilecms/s720x720_jfs/t23998/350/2363990466/222391/a6e9581d/5b7cba5bN0c18fb4f.jpg!q70.jpg.webp","category":"拉拉裤","brand":"巴布豆","spec":"{}","sold":11,"commentCount":33343434,"isAD":false,"status":2}]
14:30:10:596 DEBUG 31536 --- [nio-8082-exec-2] com.hmall.api.client.ItemClient          : [ItemClient#queryItemByIds] <--- END HTTP (371-byte body)