一、前言
上一篇文章做了一些准备工作,这边文章正式开始写代码。
在做好单实例架构之后,升级到集群是一件很容易的事情,所以把单机和集群放在这一篇一起说。
二、单体项目架构
在开始前先说一下本文一些名词的定义吧。
- 组织(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 应用模块组织
如何组织我们项目的业务模块能够有一个比较好的扩展性?
-
业务模块全部放在一个maven模块里面,通过分包的方式组织模块。 这种方式通过分包的方式组织模块,但是由于没有架构层面的强约束,很容易各个模块的方法混在一起,在后期不容易拆分。
-
通过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 应用架构
应用架构的方法论
下面看一下单个应用模块如何组织,单个应用构建的的方法论现在已经比较成熟,这里说两种
- 经典的三层架构- controller、service、dao、entity 这种很容易让service层膨胀的很大,一个类几千行,每个方法可能会变成事务脚本。
好处就是比较符合直觉思维,写起来也快,代码阅读起来也比较顺利。 缺点可能service层过于臃肿,代码的业务含义不强。
- 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。
好啦,到这里我们整个项目的框架就搭建好了,现在可以按照模块去进行业务开发了。
三、集群
分布式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的特性,我们可以很容易的实现水平扩容和负载均衡。
把这玩意直接改成你希望扩展的数量就行,然后kubernetes service会自动负载。
或者改yml
spec:
progressDeadlineSeconds: 600
replicas: 1 //这里改副本数量
revisionHistoryLimit: 10
小结
本篇主要覆盖了一个java后端从0到1再到集群的一个过程。主要是一些工程上的实践和方法论,同时也是我自己实践过程的一些心路历程。
在服务层做了集群以后,后面我会继续讲一下数据层一如的一些实践,比如数据源分库,中间件分库分表等等,最后再讲微服务。风格的话还是和这篇文章类似。
觉得有收获的同学们帮忙点个赞。
拍砖或者联系我robbendev@gmail.com