Java后端架构开荒实战(二)——单机到集群

413 阅读13分钟

一、前言

上一篇文章做了一些准备工作,这边文章正式开始写代码。

在做好单实例架构之后,升级到集群是一件很容易的事情,所以把单机和集群放在这一篇一起说。

二、单体项目架构

在开始前先说一下本文一些名词的定义吧。

  • 组织(org):这个就是公司的意思,一个公司组织下面可能会有多个项目。
  • 项目(project):项目在内部是要自洽的,项目和项目的调用之间就属于第三方调用了。比如本文提到的电商后端就是一个项目,组织公共类库就属于另外一个项目,每个项目有自己的生命周期。
  • 应用(application):应用一般是一个领域服务的形式,在单体应用中可能是一个业务模块,在微服务架构中可能是一个微服务。

2.1 组织公共类库

这种二方库一般是公司组织级别的,就是封装了所有项目都可能用到的公共方法、配置和工具类等等,注意区别与项目里面的公共类库,这些类库的设计要注意通用性。

一些项目级别的专有配置和工具就不要放到这里来啦。

可以按照springboot源码那样按maven模块组织,也可以简单一点只分包吧。

贴一下web方面经常需要的配置:

统一返回结果BaseResult,一个通用的用接口层的范型返回对象是非常重要的。

public class BaseResult<T> {
    /**
     * 返回状态
     */
    private boolean success;
    /**
     * 返回状态码
     */
    private String code;
    /**
     * 返回信息
     */
    private String message;
    /**
     * 返回数据
     */
    private T data;
    ...

跨域配置,注意这里@ConditionalOnWebApplication web应用才生效。

/**
 * <p>
 * 跨域配置
 * </p>
 *
 * @author robbendev
 */
@ConditionalOnWebApplication
@Configuration
public class GlobalCorsConfig {


    @Bean
    public CorsFilter corsFilter() {
        //1.添加CORS配置信息
        CorsConfiguration config = new CorsConfiguration();
        //放行哪些原始域
        config.addAllowedOrigin("*");

        //是否发送Cookie信息
        config.setAllowCredentials(true);
        //放行哪些原始域(请求方式)
        config.addAllowedMethod("*");
        //放行哪些原始域(头部信息)
        config.addAllowedHeader("*");

        config.setMaxAge(3600L);
        //暴露哪些头部信息(因为跨域访问默认不能获取全部头部信息)
        config.addExposedHeader("Content-Type");
        config.addExposedHeader("X-Requested-With");
        config.addExposedHeader("accept");
        config.addExposedHeader("Origin");
        config.addExposedHeader("Access-Control-Request-Method");
        config.addExposedHeader("Access-Control-Request-Headers");

        //2.添加映射路径
        UrlBasedCorsConfigurationSource configSource = new UrlBasedCorsConfigurationSource();
        configSource.registerCorsConfiguration("/**", config);

        //3.返回新的CorsFilter.
        return new CorsFilter(configSource);
    }
}

通用业务异常,web应用的一般在业务层抛出手动抛出,由全局异常捕获转然后转化成通用返回值返回。

/**
 * 通用业务异常
 *
 * @author robbendev
 */
@EqualsAndHashCode(callSuper = true)
@Data
public class BizException extends RuntimeException implements Serializable {

    /**
     * 序列化
     */
    private static final long serialVersionUID = -4636716497382947499L;

    /**
     * 错误码
     */
    private String code;

    /**
     * 错误信息
     */
    private String message;

    /**
     * 错误详情
     */
    private Object data;
}


备份流 (RequestBakRequestWrapper就不贴了),拦截器那里会用到。

/**
 * 对request请求进行包装备份请求参数
 *
 * @author robbendev
 */
@ConditionalOnWebApplication
@Component
@ServletComponentScan
@WebFilter(filterName = "requestBakFilter")
public class RequestBakFilter implements Filter {

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {

    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest servletRequest = (HttpServletRequest) request;
        RequestBakRequestWrapper requestWrapper = new RequestBakRequestWrapper(servletRequest);
        chain.doFilter(requestWrapper, response);
    }

    @Override
    public void destroy() {

    }
}

其他的配置各个公司的最佳实践不一而同。

2.2 项目公共类库

这种公共类库是项目级别的,每个不同的项目会有项目内部的自定义公用类库需求。

如果你需要web开发就需要springboot-web诸如此类,这些就定义在这里。

项目依赖

shop-common/pom/xml

<parent>
    <groupId>com.robbendev</groupId>
    <artifactId>robbendev-shop-backend</artifactId>
    <version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>

<artifactId>shop-common</artifactId>
<packaging>jar</packaging>


<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
</dependencies>
...


用户登陆

用户登陆算是比较独立的模块,单拎一小节。

  • spring security + jwt的方案。
  • 服务端session这种。

大家可以自行搜索一下oauth2.0和一些单点登录的方案。

shop项目的话用户登陆token签发是通过服务端session来做的。对应的服务定义在shop-common里面。贴一个token的本地缓存简单实现

@Service
public class TokenServiceImpl implements TokenService {

    private Map<String, Token> session = new ConcurrentHashMap<>();

    @Override
    public void save(Token token) {
        session.put(token.getToken(), token);
    }

    @Override
    public void remove(String token) {
        session.remove(token);
    }
}

有兴趣的同学可以试试如何实现过期缓存。

文件服务

不贴代码了,也是属于shop-common模块的,各个云服务商都提供样板代码。 注意做成接口实现分离形式,在项目里浅封装一下。

其他

还有一些应用级别的配置类、拦截器,日志处理等等。代码先不贴了,这些实践现在都很成熟。

2.3 应用模块组织

如何组织我们项目的业务模块能够有一个比较好的扩展性?

  1. 业务模块全部放在一个maven模块里面,通过分包的方式组织模块。 这种方式通过分包的方式组织模块,但是由于没有架构层面的强约束,很容易各个模块的方法混在一起,在后期不容易拆分。

  2. 通过maven模块化组织,让每个模块引入其他业务模块的接口,每个业务模块实现自己的业务方法。

明显可以看到第二种方式在大型项目后台中有一个比较好的拓展性:

  • 实现了模块之间的解耦合。
  • 如果是单体应用部署只用打包在一起部署,如果是微服务的话引入服务层框架,对每个模块单独部署。升级方便。
  • 避免在项目初期引入过多复杂的组件,同时又有快速扩展能力。按需升级。

贴代码robbendev-shop-backend整理架构 :

├── boot  //聚合了所有模块的单应用启动模块
│   ├── pom.xml
│   └── src
│       ├── main
│       └── test
├── build  //这里指定了打包顺序
│   ├── pom.xml
├── pom.xml
├── shop-common  //项目的公用类库
│   ├── pom.xml
│   └── src
└── shop-modules  //项目的模块拆分
    ├── pom.xml
    ├── shop-market  //营销模块 
    │   ├── market-interfaces //营销服务接口二方库
    │   ├── market-service   //营销服务
    │   ├── pom.xml
    ├── shop-orders  //订单模块
    │   ├── orders-interfaces //订单服务接口二方库
    │   ├── orders-service  //订单服务
    │   ├── pom.xml
    ├── shop-product //产品服务
    │   ├── pom.xml  
    │   ├── product-interfaces //产品服务接口二方库
    │   ├── product-service  //产品服务
    └── shop-user  //用户服务
        ├── pom.xml
        ├── user-interfaces //用户服务接口二方库
        └── user-service   //用户服务

可以看到不同模块是按照模块组织,每个业务模块通过ineterfaces模块和其他模块通信。

2.4 应用架构

应用架构的方法论

下面看一下单个应用模块如何组织,单个应用构建的的方法论现在已经比较成熟,这里说两种

  1. 经典的三层架构- controller、service、dao、entity 这种很容易让service层膨胀的很大,一个类几千行,每个方法可能会变成事务脚本。

好处就是比较符合直觉思维,写起来也快,代码阅读起来也比较顺利。 缺点可能service层过于臃肿,代码的业务含义不强。

  1. ddd建模 - interfaces、application、infrastruture、domain 这个可以参考一下相关书籍,这里不赘述。我自己还是比较偏向这一种的,现在也慢慢开始流行起来了。一些核心的概念包括聚合、仓储、领域服务、领域事件、应用服务等。

领域对象建模主要是帮助如何建设一个自洽的应用,是属于应用层而不是架构层的方法论。但是由于领域对象建模的思想和微服务思想有大部分相似的地方,所以在做微服务的拆分的时候可以用领域对象方法来做指导,其实微服务拆分本来就是业务模块、限界上下文的划分。

完全的领域建模落地实施起来会比较困难,尤其是在实体的状态管理,领域事件溯源等。所以在实际开发中不用完全照搬领域对象建模的概念,接下来我贴一下我自己的领域对象建模实践。

首先刚才说到的接口实现分离,把二方库依赖版本添加到之前我们提到的统一二方库依赖pom.xml中

贴一下market-service的pom:

//...
<dependency>
    <groupId>com.robbendev</groupId>
    <artifactId>robbdendev-common</artifactId>
    <version>${robbendev-common.version}</version>
</dependency>

<dependency>
    <groupId>com.robbendev</groupId>
    <artifactId>shop-common</artifactId>
    <version>${robbendev-shop-backend.version}</version>
</dependency>

<dependency>
    <groupId>com.robbendev</groupId>
    <artifactId>market-interfaces</artifactId>
    <version>${robbendev-shop-backend.version}</version>
</dependency>

<dependency>
    <groupId>com.robbendev</groupId>
    <artifactId>orders-interfaces</artifactId>
    <version>${robbendev-shop-backend.version}</version>
</dependency>

<dependency>
    <groupId>com.robbendev</groupId>
    <artifactId>product-interfaces</artifactId>
    <version>${robbendev-shop-backend.version}</version>
</dependency>

...

这样我们就可以通过接口访问其他模块的方法。

贴一下单个模块的分包,这里单个业务其实可以继续分模块解耦合,但是考虑项目初期的业务复杂程度不会很大,所以还是只分包做分层处理,模块开发的时候团队之间约定好一些基本规范。 order模块按照领域对象建模的分包:

├── orders-interfaces
│   ├── pom.xml
│   └── src
│       ├── main
│       │   ├── java
│       │   │   └── com.robbebdev.shop.order
│       │   │                   ├── dto //模块接口参数
│       │   │                   │   ├── request //入参定义
│       │   │                   │   └── response //出参定义
│       │   │                   └── service  //模块服务接口
├── orders-service
│   ├── pom.xml
│   └── src
│       ├── main
│       │   ├── java
│       │   │   └── com.robbendev.shop.order
│       │   │                   ├── application //应用服务层
│       │   │                   ├── domain //领域层
│       │   │                   ├── infrastucture //基础设施层
│       │   │                   └── interfaces //用户接口层
├── pom.xml

可以看到有两个maven模块 一个是interfaces模块,里面有模块接口定义和参数定义 一个是service模块,里面会在用户接口层实现interfaces里面的服务接口方法,其他层就和一个ddd的项目差不多。

业务代码

贴一个demo接口具体实现吧,以订单模块为例子,现在写一个更新订单接口。

├── orders-interfaces
│   └── src
│       ├── main
│       │   ├── java
│       │   │   └── com
│       │   │       └── robbendev.shop.order.dto
│       │   │                   │   ├── request
│       │   │                   │   │   └── FindOrderReq.java
│       │   │                   │   └── response
│       │   │                   │       └── FindOrderResp.java
│       │   │                   └── service
│       │   │                       └── IOrderApi.java
├── orders-service
│   └── src
│       ├── main
│       │   ├── java
│       │   │   └── com
│       │   │       └── robbendev
│       │   │           └── shop
│       │   │               └── order
│       │   │                   ├── application
│       │   │                   │   ├── IOrderService.java //应用服务接口 
│       │   │                   │   └── IOrderServiceImpl.java //应用服务实现类 其实这里可以不用接口,但是兼容一些人的开发习惯吧。
│       │   │                   ├── domain
│       │   │                   │   ├── Order.java  //实体
│       │   │                   │   └── OrderRepository.java //仓储接口
│       │   │                   ├── infrastucture
│       │   │                   │   ├── dataobject
│       │   │                   │   │   └── OrderDO.java //数据对象
│       │   │                   │   ├── mapper
│       │   │                   │   │   └── OrderMapper.java //数据接口
│       │   │                   │   └── repository
│       │   │                   │       └── OrderRepositoryImpl.java //仓储的db实现
│       │   │                   └── interfaces
│       │   │                       └── OrderController.java //暴露的外部api,需要实现interfaces包中的 IOrderApi


模块间通信api

/**
 * <p>
 * 模块通信的api,具体的实现在用户接口层。
 * </p>
 *
 * @author robbendev
 * @since 2021/4/1 5:07 下午
 */
public interface IOrderApi {

    BaseResult<FindOrderResp> findOrder(FindOrderReq req);
}

应用服务

/**
 * <p>
 * 应用服务,这里是浅浅的一层,可以作为领域层的门面,实体到出参的转换在这里做。
 * </p>
 *
 * @author robbendev
 * @since 2021/4/1 5:35 下午
 */
@Service
public class IOrderServiceImpl implements IOrderService {

    @Resource
    OrderRepository orderRepository;


    @Override
    public FindOrderResp findOrder(FindOrderReq req) {
        Order order = orderRepository.findById(req.getId());
        FindOrderResp findOrderResp = new FindOrderResp();
        findOrderResp.setAmount(order.getAmount());
        findOrderResp.setProductName(order.getProductName());
        findOrderResp.setId(order.getId());

        return findOrderResp;
    }
}

实体

/**
 * 实体,聚合,聚合根!概念参考ddd。像id这些可以用primitive domain实现,像这样。
 * <code>private OrderId id;</code>
 *
 * @author robbendev
 * @since 2021/4/1 5:14 下午
 */
@Data
public class Order {

    private Long id;
    private BigDecimal amount;
    private String productName;
}

仓储接口

/**
 * <p>
 * 仓储接口,概念参考ddd,可以有多个实现,db实现呀,es实现等。
 * </p>
 *
 * @author robbendev
 * @since 2021/4/1 5:25 下午
 */
public interface OrderRepository {

    Order findById(Long id);
}

数据对象

/**
 * <p>
 * 数据对象,和数据库表字段一一对应。
 * </p>
 *
 * @author robbendev
 * @since 2021/4/1 5:16 下午
 */
@Data
public class OrderDO {

    private Long id;
    private BigDecimal amount;
    private String productName;
}

数据库访问接口

/**
 * <p>
 * 数据库访问接口
 * </p>
 *
 * @author robbendev
 * @since 2021/4/1 5:27 下午
 */
@Mapper
public interface OrderMapper {

    @Select("select * from order where id =#{id}")
    OrderDO getById(Long id);
}

仓储的实现

/**
 * <p>
 * 仓储的db实现。
 * </p>
 *
 * @author robbendev
 * @since 2021/4/1 5:25 下午
 */
@Component
public class OrderRepositoryDBImpl implements OrderRepository {

    @Resource
    OrderMapper orderMapper;


    @Override
    public Order findById(Long id) {

        OrderDO orderDO = orderMapper.getById(id);

        //对象转换替换方案 mapsStruct 或者beanUtils。
        //有对实体作状态跟踪的方案,但是比较复杂,这里没有选用。
        //所以在ddd选型的时候不用全上,适合就好。
        Order order = new Order();
        order.setId(orderDO.getId());
        order.setAmount(orderDO.getAmount());
        order.setProductName(orderDO.getProductName());

        return order;
    }
}

用户接口

/**
 * <p>
 * 用户接口(user interface,概念参考ddd)api
 * </p>
 *
 * @author robbendev
 * @since 2021/4/1 5:13 下午
 */
@RestController
@RequestMapping("/order")
public class OrderController implements IOrderApi {

    @Resource
    IOrderService orderService;

    @Override
    @PostMapping("/findOrder")
    public BaseResult<FindOrderResp> findOrder(@RequestBody FindOrderReq req) {
        FindOrderResp resp = orderService.findOrder(req);
        return BaseResult.success(resp);
    }

}

数据库ddl和配置文件就不写了,就一个springboot默认数据库配置。

2.5 单体应用启动

在集成之前先看下build模块打包项目pom配置,因为要注意一下打包顺序。

<parent>
    <artifactId>robbendev-shop-backend</artifactId>
    <groupId>com.robbendev</groupId>
    <version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>

<artifactId>build</artifactId>

<packaging>pom</packaging>

<modules>
    <module>../shop-common</module>
    <module>../shop-modules/shop-market/market-interfaces</module>
    <module>../shop-modules/shop-orders/orders-interfaces</module>
    <module>../shop-modules/shop-product/product-interfaces</module>
    <module>../shop-modules/shop-user/user-interfaces</module>
    <module>../shop-modules/shop-market/market-service</module>
    <module>../shop-modules/shop-orders/orders-service</module>
    <module>../shop-modules/shop-product/product-service</module>
    <module>../shop-modules/shop-user/user-service</module>
    <module>../boot</module>
</modules>

可以看到先打包项目公共类库(根据之前的概念,组织公共类库的发布是属于另外的项目,应该有独立的生命周期。),再打包模块接口,最后打包模块应用。这样就不会出现说”哎呀,你搞了什么,我怎么这个文件又找不到。“

再看boot模块的pom文件和代码

<parent>
    <artifactId>robbendev-shop-backend</artifactId>
    <groupId>com.robbendev</groupId>
    <version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>

<packaging>jar</packaging>
<artifactId>boot</artifactId>


<dependencies>

    <dependency>
        <groupId>com.robbendev</groupId>
        <artifactId>market-interfaces</artifactId>
    </dependency>

    <dependency>
        <groupId>com.robbendev</groupId>
        <artifactId>market-service</artifactId>
    </dependency>

    <dependency>
        <groupId>com.robbendev</groupId>
        <artifactId>orders-interfaces</artifactId>
    </dependency>

    <dependency>
        <groupId>com.robbendev</groupId>
        <artifactId>orders-service</artifactId>
    </dependency>
    
    //...产品用户


</dependencies>

然后在boot模块里面,几行代码就可以运行一个springboot web程序

/**
 * <p>
 *
 * </p>
 *
 * @author robbendev
 * @since 2021/3/31 2:43 下午
 */
@SpringBootApplication
public class AppBoot {

    public static void main(String[] args) {
        SpringApplication.run(AppBoot.class, args);
    }
}

运行成功截图

2021-04-01 16:40:33.987  INFO 9926 --- [           main] com.robbendev.shop.AppBoot               : Starting AppBoot on huluobindeMacBook-Pro.local with PID 9926 (/Users/huluobin/IdeaProjects/robbendev-shop-backend/boot/target/classes started by huluobin in /Users/huluobin/IdeaProjects/robbendev-common)
2021-04-01 16:40:33.991  INFO 9926 --- [           main] com.robbendev.shop.AppBoot               : No active profile set, falling back to default profiles: default
2021-04-01 16:40:34.856  INFO 9926 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port(s): 8080 (http)
2021-04-01 16:40:34.868  INFO 9926 --- [           main] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
2021-04-01 16:40:34.869  INFO 9926 --- [           main] org.apache.catalina.core.StandardEngine  : Starting Servlet engine: [Apache Tomcat/9.0.37]
2021-04-01 16:40:34.969  INFO 9926 --- [           main] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
2021-04-01 16:40:34.970  INFO 9926 --- [           main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 887 ms
2021-04-01 16:40:35.150  INFO 9926 --- [           main] o.s.s.concurrent.ThreadPoolTaskExecutor  : Initializing ExecutorService 'applicationTaskExecutor'
2021-04-01 16:40:35.301  INFO 9926 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8080 (http) with context path ''
2021-04-01 16:40:35.309  INFO 9926 --- [           main] com.robbendev.shop.AppBoot               : Started AppBoot in 1.944 seconds (JVM running for 3.03)

然后post一下我们刚才的接口,一切ok。

image.png

好啦,到这里我们整个项目的框架就搭建好了,现在可以按照模块去进行业务开发了。

三、集群

分布式Session

之前我们token是使用的本地缓存,那么在集群情况下就可能会出现不同请求落在不同实例上,导致缓存失效。解决方案:

  • 每个实例都存一份。这样有点浪费。
  • 请求的时候按照一定的路由规则保证每次落在相同的机器上。有点麻烦
  • 把session单独出来。这样需要保证全局缓存的稳定。

这里选第三种方案了,也比较主流。看一下redis的实现

@Service
public class TokenServiceRedisImpl implements TokenService {
    @Resource
    StringRedisTemplate stringRedisTemplate;

    @Override
    public void save(Token token) {
        stringRedisTemplate.opsForValue().set(token.getToken(), JsonUtilByFsJson.beanToJson(token)
                , 1, TimeUnit.DAYS);
    }

    @Override
    public void remove(String token) {
        stringRedisTemplate.delete(token);
    }
}

然后在自己的登陆服务里面切换一下就行。

负载均衡

借助Kubernetes的特性,我们可以很容易的实现水平扩容和负载均衡。

image.png 把这玩意直接改成你希望扩展的数量就行,然后kubernetes service会自动负载。

或者改yml

spec:
  progressDeadlineSeconds: 600
  replicas: 1  //这里改副本数量
  revisionHistoryLimit: 10

小结

本篇主要覆盖了一个java后端从0到1再到集群的一个过程。主要是一些工程上的实践和方法论,同时也是我自己实践过程的一些心路历程。

在服务层做了集群以后,后面我会继续讲一下数据层一如的一些实践,比如数据源分库,中间件分库分表等等,最后再讲微服务。风格的话还是和这篇文章类似。

觉得有收获的同学们帮忙点个赞。
拍砖或者联系我robbendev@gmail.com