分类:计算机 / 架构 / backend项目架构设计
- backend项目架构设计 - 执行时间分析
做一个复杂的数据计算需求,执行过程被拆分为几十个Service(服务)和Calculator(计算器)类,且在其中又调用了十几个基础数据的Service类。计算逻辑开发好后,结构清楚,但执行时间很长,需要做性能优化。那么,应该优化哪些地方,即哪些地方是性能瓶颈?这需要对执行时间做统计和分析。 计时 我们利用Spring的AOP功能实现对方法做执行时间计时。 例如,对于Service,编写如下切面类: @Aspect @Component @Slf4j public class ServiceTimingAspect { /** * 切点 * <p>切点为{@code com.company.project}包下任意层级的{@code service}包中的所有类的所有方法。</p> */ @Pointcut("execution(* com.company.project..service..*(..))") private void pointcut() {} /** * 环绕通知,执行并计时 * @param joinPoint 连接点 * @return 切点的返回值 * @throws Throwable 切点的抛出对象 */ @Around("pointcut()") public Object around(ProceedingJoinPoint joinPoint) throws Throwable { // 执行并计时 var startTime = System.currentTimeMillis(); Object result = joinPoint.proceed(); var endTime = System.currentTimeMillis(); var duration = endTime - startTime; // 方法签名 var methodSignature = joinPoint.toString(); methodSignature = methodSignature.substring("execution(".length(), methodSignature.length() - 1); logger.debug("方法签名{}耗时: {}ms", methodSignature, duration); // 实参列表 var args = Arrays.toString(joinPoint.getArgs()); args = args.substring(1, args.length() - 1); args = args.replace("\n", "\\n").replace("\t", "\\t"); // 方法调用 var methodCall = methodSignature.substring(0, methodSignature.indexOf("(") + 1) + args + ")"; logger.debug("方法调用{}耗时: {}ms", methodCall, duration); return result; } } 日志 通过配置logback-spring.xml将切面类的日志同时输出到控制台和专门的计时日志文件: <!-- 输出到计时日志文件的Appender --> <appender name="TIMING_LOG_FILE" class="ch.qos.logback.core.FileAppender"> <file>log/timing.log</file> <encoder> <pattern>${FILE_LOG_PATTERN}</pattern> </encoder> </appender> <!-- 切面类的Logger --> <logger name="切面类所在的包名" level="DEBUG" additivity="false"> <appender-ref ref="CONSOLE"/> <appender-ref ref="TIMING_LOG_FILE"/> </logger> 统计 编写TimingLogAnalyzer类,通过main()方法执行,读取计时日志文件,统计每个方法签名、方法调用的执行次数、总耗时、最小耗时、最大耗时、平均耗时,然后将统计结果输出到Excel文件中。 分析 在Excel文件中通过排序、筛选、统计图等手段,分析出性能瓶颈的所在,进而有针对性地去做性能优化。 功能增强 对Mapper、Calculator、Controller或者其他类做计时切面类。尤其是Mapper,可以用于分析缓慢的数据库查询。 通过配置和@ConditionalOnProperty控制切面类的装配,线上关闭计时,本地也只在需要分析时才开启计时。
- backend项目架构设计 - 请求级缓存
在开发一个复杂的业务计算需求时,遇到如下问题:业务计算过程很复杂,需要拆分为很多步骤,每个步骤又需要拆分为多个不同职责的类,计算时,这些类之间需要共享很多数据。如果通过方法参数传递,则参数会很多,非常繁琐。如果通过中间数据结构传递,则每个类所需的共享数据不同,中间数据结构不好统一。 解决思路:不传递共享数据,而是每个类自己去获取,需要什么就获取什么,参数只保留最必要的,这样每个类的独立性大大增强,更简洁、清晰。显然,这里有个问题,大量重复获取同一个数据,会严重降低效率。我们可以用缓存解决这个问题,对于同一个数据,只在第一次获取时查库,后续直接用缓存。 在我们的业务场景中,数据库的数据可能被多个来源修改,那么,缓存淘汰策略就是问题。如果修改全部在单一项目进行,那么,可以采用修改时主动淘汰的策略,这样效率最高,显然我们的业务场景不满足。另一种常用的策略是基于超时,但超时时间设多长不好定,短了影响性能,长了影响一致性,每种数据的修改频率也不同,单独设置很繁琐。 考虑到,一次请求的时间非常短(通常几十~几百毫秒),在此过程中,相同的参数几乎100%会返回相同的数据。因此,我们可以实现请求级缓存机制。在请求级缓存机制中,一个缓存的生命周期为,从第一次查询开始,到请求执行结束。 基于这种机制去实现复杂的业务计算需求,可以同时保证代码简洁性(减少共享数据传递)、高效率(只查一次,效率与传递共享数据相同)和数据一致性(极短时间内认为数据不变化)。 参考代码: // 缓存管理器 public class RequestLevelCacheManager implements CacheManager { private final ConcurrentMapCacheManager cacheManager = new ConcurrentMapCacheManager(); /** * 获取指定请求线程中指定名称的缓存 * @param thread 请求线程 * @param name 缓存名称 * @return 缓存 */ public Cache getCache(Thread thread, String name) { return cacheManager.getCache(thread.threadId() + ":" + name); } /** * 获取当前请求线程中指定名称的缓存 * @param name 缓存名称 * @return 缓存 */ @Override public Cache getCache(String name) { return getCache(Thread.currentThread(), name); } /** * 获取缓存管理器中的所有缓存名称 * @return 缓存名称集合 */ @Override public Collection<String> getCacheNames() { return cacheManager.getCacheNames(); } /** * 清除指定请求线程的缓存 * @param thread 请求线程 */ public void clearCache(Thread thread) { // 缓存管理器中的所有缓存名称 cacheManager.getCacheNames().stream() // 筛选出当前请求线程的所有缓存名称 .filter(name -> name.startsWith(thread.threadId() + ":")) // 获取缓存名称对应的缓存 .map(cacheManager::getCache) // 筛选出非null的缓存 .filter(Objects::nonNull) // 对缓存执行清除 .forEach(Cache::clear); } /** 清除当前请求线程的缓存 */ public void clearCache() { clearCache(Thread.currentThread()); } } // 缓存管理器配置 @Configuration public class CacheConfig { // 将RequestLevelCacheManager设为默认的缓存管理器 @Bean @Primary public RequestLevelCacheManager requestLevelCacheManager() { return new RequestLevelCacheManager(); } } // 像其他类型的缓存一样使用 @Cacheable("cacheName") // 请求拦截器,需要注册到`/**`路径 @Component public class RequestLevelCacheInterceptor implements HandlerInterceptor { @Autowired private RequestLevelCacheManager requestLevelCacheManager; /** 在请求执行结束时,清除请求级缓存 */ @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { requestLevelCacheManager.clearCache(); } }
- backend项目架构设计 - 通用的按关键词搜索的Mapper
搜索是最常用的功能之一,具体到搜索某一种数据,通常有两种形式: 单个搜索框:用户输入关键词(keyword)文本,在数据的多个属性中用关键词筛选。输入简单,但结果可能不够精确。 多个筛选条件:为数据的多个属性分别设置筛选条件,在数据中分别用对应的筛选条件筛选。输入更复杂,但结果更精确。 我们在这里只讨论第一种搜索形式,并将其实现为通用的底层功能,以便在需要时对任意数据快速实现搜索。 前端页面:页面上设置一个单行文本框,用户可输入关键词列表,多个关键词用空格隔开。前端保证关键词列表的每个元素为非空字符串。 后端接口:接收3个参数: keywords:List<String>,关键词列表,可为空列表。如果一条数据包含所有关键词,则该数据在搜索结果中,即多个关键词之间为AND关系。对于一个关键词,数据包含该关键词的定义是,数据的任一指定属性满足指定的与该关键词的关系(例如相等、包含)。 pageNum:int,当前页码。 pageSize:int,每页条数。 后端在Mapper层实现通用的按关键词搜索(SearchByKeywordsMapper接口),并留出扩展性。实体Mapper接口调用SearchByKeywordsMapper接口的方法,并传入以下信息: keywords、pageNum、pageSize。 select语句,即如何查询指定的数据。 属性条件,即哪些属性(可多个),与关键词要满足什么关系(例如相等、包含)。 order by子句,即如何排序。 为了简化实现,我们使用PageHelper分页插件实现自动分页,并支持使用Map+简短标记(如=、like)来简化属性条件的表达。 参考代码: /** * 通用的按关键词搜索的Mapper(面向MySQL数据库) * @param <T> 实体类 */ public interface SearchByKeywordsMapper<T> { /** * 搜索并分页 * @param keywords 搜索关键词列表 * @param pageNum 当前页码,从1开始。 * @param pageSize 每页条数 * @return 分页对象 * @implNote 请在实体Mapper中使用{@code default}方法实现此方法,并在{@code default}方法中调用 * {@link #_search(String, List, Map, String, int, int)}方法实现搜索和分页功能。 */ Page<T> search(List<String> keywords, int pageNum, int pageSize); /** * 搜索并分页 * @param select {@code select}SQL语句,可以包含连表,但不能包含{@code where}子句和{@code order by}子句。 * @param keywords 搜索关键词列表 * @param keywordConditions 单个关键词的搜索条件映射表。 * <p>其中,键为列名,例如{@code "e.name"}。</p> * <p>值为条件,支持以下4种形式:<p> * <ol> * <li>{@code "="}:表示等于当前关键词,即<code>"column = #{keyword}"</code>。</li> * <li>{@code "like"}:表示字符串包含当前关键词,即<code>"column like concat('%', #{keyword}, '%')"</code>。</li> * <li>{@code "find_in_set"}:表示使用{@code find_in_set()}函数判断包含,即<code>"find_in_set(#{keyword}, column)"</code>。</li> * <li>自定义条件:不含列名,关键词的占位符为<code>#{keyword}</code>,即<code>"column 自定义条件"</code>。</li> * </ol> * @param orderBy {@code order by}子句,不含{@code order by}关键字。{@code null}或{@code ""}表示不排序。 * @param pageNum 当前页码,从1开始。 * @param pageSize 每页条数 * @return 分页对象 */ default Page<T> _search( String select, List<String> keywords, Map<String, String> keywordConditions, @Nullable String orderBy, int pageNum, int pageSize ) { var keywordConditionsString = keywordConditions.entrySet().stream() .map(entry -> { var column = entry.getKey(); var condition = entry.getValue(); if (condition.equals("=")) { return column + " = #{keyword}"; } else if (condition.equalsIgnoreCase("like")) { return column + " like concat('%', #{keyword}, '%')"; } else if (condition.equalsIgnoreCase("find_in_set")) { return "find_in_set(#{keyword}, " + column + ")"; } else { return column + " " + condition; } }) .collect(Collectors.joining(" or ")); return _search(select, keywords, keywordConditionsString, orderBy, pageNum, pageSize); } /** * 使用PageHelper分页插件执行搜索并分页 * @param select {@code select}SQL语句,可以包含连表,但不能包含{@code where}子句和{@code order by}子句。 * @param keywords 搜索关键词列表 * @param keywordConditions 单个关键词的搜索条件,其中关键词的占位符为<code>#{keyword}</code>。 * @param orderBy {@code order by}子句,不含{@code order by}关键字。{@code null}或{@code ""}表示不排序。 * @param pageNum 当前页码,从1开始。 * @param pageSize 每页条数 * @return 分页对象 */ @Select(""" <script> <![CDATA[ ${select} ]]> <where> <foreach item="keyword" collection="keywords" separator="and"> ( <![CDATA[ ${keywordConditions} ]]> ) </foreach> </where> <if test="orderBy != null and orderBy != ''"> order by <![CDATA[ ${orderBy} ]]> </if> </script> """) Page<T> _search( String select, List<String> keywords, String keywordConditions, @Nullable String orderBy, int pageNum, int pageSize ); } // 实体Mapper public interface EntityMapper extends SearchByKeywordsMapper<Entity> { /** * 搜索实体 * @param keywords 搜索关键词列表 * @param pageNum 当前页码 * @param pageSize 每页条数 * @return 分页对象 */ default Page<Entity> search(List<String> keywords, int pageNum, int pageSize) { return _search( "select ...", keywords, Map.of( "column1", "=", "column2", "like" ), "column1, column2 desc", pageNum, pageSize ); } }
- backend项目架构设计 - 包结构
通常在Spring Boot项目中,包结构如下: com.company.project entity mapper service controller 实践发现,随着项目越来越复杂,类越来越多,这种组织方式存在明显问题:同一个数据或业务相关的多个类分散在相隔甚远的不同包中,而编写、阅读代码却又是一起的,所以,在IDE中需要反复上下滚动,来找到需要的类,麻烦、效率低、不清晰。 在backend项目中,我们以数据或业务为中心组织包结构,包结构如下: com.company.project domain:领域包 entity/business:实体/业务包 data:数据包 mapper:Mapper包 service:服务包 controller:控制器包 scheduler:调度器包 领域包:表示一个业务领域,例如某个部门专门负责的业务。领域下可以再划分子领域。 实体/业务包:表示一个实体(例如项目、合同),或者一项专门的业务(例如对某数据的分析)。这是代码组织的基本粒度单位,包内的所有代码都是围绕某个实体/业务进行编写的,相关的服务、控制器等类全部集中在一起,方便编写和阅读。实体/业务包中提供数据结构(data包)和操作(service包),这是面向对象思想在包层次上的体现。 数据包:定义数据结构。之所以叫“数据包”而不是“实体包”,是因为数据不仅仅是实体类,还有枚举、表示某种共性的接口、常量等。 Mapper包:存放Mapper接口,实现数据库层面的操作。我们约定,凡是数据库层面的操作,只要能在Mapper接口中实现,就一定在Mapper接口中实现,因为这可以使Mapper层和服务层的职责更清晰,简化服务层。可以通过default方法、private方法、Mapper互相调用等手段来实现这一点。 服务包:存放服务类。服务层是实现业务逻辑的核心,是对外提供操作的核心,其他实体/业务只能通过服务层的方法来使用本实体/业务的操作。通过精心设计服务层方法的粒度,可以实现良好的代码复用。 控制器包:存放控制器类,对前端提供API。 调度器包:存放调度器类,实现定时任务。我们约定,定时任务必须通过调用控制器层来执行,而不能直接调用服务层,这是因为我们经常有手动触发定时任务的需求,手动触发会通过控制器层的API来执行,所以为了避免不一致,故做此约定。换句话说,调度器只是提供了一个定时触发的机制而已,无论是定时触发还是手动触发,执行都是从控制器层开始的。
- backend项目架构设计 - 全局Bean名称生成器
问题 一个软件项目中经常存在名称相同但意义不同的类,例如实施领域和研发领域都有“项目”这个实体,也就都有ProjectMapper、ProjectService、ProjectController等类。 Spring Boot默认将类名转为小写驼峰形式作为Bean名称,这就导致出现相同的Bean名称,Spring Boot报错。 解决方案 编写GlobalBeanNameGenerator类,对于单例Bean(绝大多数情况),将Bean名称自动设置为类的完全限定名,确保唯一。然后在@SpringBootApplication、@MapperScan中使用GlobalBeanNameGenerator。 public class GlobalBeanNameGenerator extends AnnotationBeanNameGenerator { @Override public String generateBeanName(BeanDefinition definition, BeanDefinitionRegistry registry) { // 如果是单例,且能获取到bean的类名 if (definition.isSingleton() && definition.getBeanClassName() != null) { // 返回类的完全限定名作为bean的名称,此时不同包的同名类会生成不同的bean名称 return definition.getBeanClassName(); } // 其他情况,保持原有的生成名称的逻辑 return super.generateBeanName(definition, registry); } } @SpringBootApplication(nameGenerator = GlobalBeanNameGenerator.class) @MapperScan(nameGenerator = GlobalBeanNameGenerator.class)
- backend项目架构设计 - 起步
我们之前有一个Java后端项目,但一直是野蛮生长的状态,简单粗暴地堆叠功能,没有经过良好的设计,没有制定统一的开发规范,每个人的代码都是不同的样子,每个人不会也不敢复用别人的代码,这些问题导致后续的开发、维护越来越艰难、低效。 我现在是公司内部信息化的技术负责人,从长远考虑,我认为有必要通过架构设计来解决这些问题,提升软件质量和团队效率。 经过分析评估,基本技术选型如下: 新起项目。 原因:仔细评估后发现,新的架构设计无法在老项目上实施,必然会遭遇无数兼容性问题,我们开发资源不够,不足以充分解决这些问题,那么必然会导致新旧代码都受影响。新起项目可以甩开历史包袱,旧代码按需逐步迁移即可。 单体项目。 原因:考虑现有的业务量、用户规模和开发资源,单体项目是足够的。微服务等分布式架构,目前不适合,强行上反而会使问题复杂化。 编程语言:Java 21。 原因:现有系统使用Java开发,现有人员擅长Java,因此语言选择Java。Java 21是当前最新LTS版本,有很多易用的新特性,值得拥抱新技术。 开发框架:Spring Boot 3,MyBatis。 原因:业界最常用,熟悉。选用框架的最新版本。 我将新项目的名称定为backend,意在作为以后的统一后端项目。 本项目将首要解决良好设计、开发规范、代码复用等过去的痼疾,提升软件的正确性、可维护性。配合团队管理(统一思想培训、代码评审等),本项目从诞生起就要严格控制代码的准入门槛,并以每一个实际需求为案例,不断迭代、优化架构设计。