最新动态
- 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,意在作为以后的统一后端项目。 本项目将首要解决良好设计、开发规范、代码复用等过去的痼疾,提升软件的正确性、可维护性。配合团队管理(统一思想培训、代码评审等),本项目从诞生起就要严格控制代码的准入门槛,并以每一个实际需求为案例,不断迭代、优化架构设计。
- OA系统加解密代码块
公司网络结构调整,我们需要批量迁移OA系统的代码块中的JS文件地址。代码块在数据库中是以密文形式存储的,22年我们曾尝试过手动编辑和前端自动化脚本的办法,但总归是效率低下,治标不治本。现在我们有了逆向工程手段,可以从根本上解决问题。 分析OA系统的反编译代码,得知其使用的加解密库为org.bouncycastle:bcprov-jdk15on:1.52,所有类型的代码块(流程布局、建模布局、建模查询)的加解密过程完全相同。提取加解密过程,封装为工具类: package com.company.project.util; import org.bouncycastle.crypto.engines.AESFastEngine; import org.bouncycastle.crypto.modes.CBCBlockCipher; import org.bouncycastle.crypto.paddings.PaddedBufferedBlockCipher; import org.bouncycastle.crypto.params.KeyParameter; import org.bouncycastle.crypto.params.ParametersWithIV; import org.bouncycastle.util.encoders.Hex; /** 代码块工具类 */ public class CodeBlockUtils { private static final byte[] BYTES1 = "WEAVER E-DESIGN.".getBytes(); private static final byte[] BYTES2 = "weaver e-design.".getBytes(); /** * 将明文编码成密文 * @param source 明文 * @return 密文 */ public static String encode(String source) { try { if (source.isEmpty()) return ""; PaddedBufferedBlockCipher cipher = new PaddedBufferedBlockCipher(new CBCBlockCipher(new AESFastEngine())); cipher.init(true, new ParametersWithIV(new KeyParameter(BYTES1), BYTES2)); byte[] outputBytes = new byte[cipher.getOutputSize(source.getBytes("GBK").length)]; int length1 = cipher.processBytes(source.getBytes("GBK"), 0, source.getBytes("GBK").length, outputBytes, 0); int length2 = cipher.doFinal(outputBytes, length1); byte[] resultBytes = new byte[length1 + length2]; System.arraycopy(outputBytes, 0, resultBytes, 0, resultBytes.length); return new String(Hex.encode(resultBytes)); } catch (Exception e) { throw new RuntimeException(e); } } /** * 将密文解码为明文 * @param encoded 密文 * @return 明文 */ public static String decode(String encoded) { try { if (encoded.isEmpty()) return ""; PaddedBufferedBlockCipher cipher = new PaddedBufferedBlockCipher(new CBCBlockCipher(new AESFastEngine())); cipher.init(true, new ParametersWithIV(new KeyParameter(BYTES1), BYTES2)); byte[] sourceBytes = Hex.decode(encoded); cipher.init(false, new ParametersWithIV(new KeyParameter(BYTES1), BYTES2)); byte[] outputBytes = new byte[cipher.getOutputSize(sourceBytes.length)]; int length1 = cipher.processBytes(sourceBytes, 0, sourceBytes.length, outputBytes, 0); int length2 = cipher.doFinal(outputBytes, length1); byte[] resultBytes = new byte[length1 + length2]; System.arraycopy(outputBytes, 0, resultBytes, 0, resultBytes.length); return new String(resultBytes, "GBK"); } catch (Exception e) { throw new RuntimeException(e); } } } 使用OA系统中存储的所有代码块对工具类做全量测试,确认加解密结果一致。 那么,我们就可以在此基础上,进一步实现代码块内容的查找与批量替换。
- oa-backend-framework项目架构设计 - Web API
实现本地运行 OA系统遵循Java EE的JAX-RS规范来实现Web API,我们当然也必须遵循之。 JAX-RS本身只是一种规范,运行它还需要实现了这种规范的框架(通常为Jersey),以及Web容器(Jersey在Web容器上注册Servlet来接收请求)。OA系统使用的JAX-RS框架是Jersey,使用的Web容器是Resin。 要在本地运行Web API,只需要让我们的框架在本地具备(<scope>provided</scope>)JAX-RS规范(javax.ws.rs:javax.ws.rs-api)和Jersey框架(com.sun.jersey:jersey-bundle),并运行在本地的Web容器(Tomcat)中即可。 为了让Jersey能扫描到我们的控制器类,需要在web.xml中(本地和线上)修改<servlet-class>com.sun.jersey.spi.container.servlet.ServletContainer</servlet-class>的<param-value>值,将我们应用的包名附加到最后。 控制器类 JAX-RS使用@Path注解标记一个方法是一个资源(即一个API),使用@GET/@POST注解标记资源对应的HTTP方法,使用@QueryParam、@PathParam等注解获取请求中的参数,使用@Produces注解设置响应的内容类型(Content-Type)。@Path写在类上可表达公共路径前缀,@Produces写在类上可统一设置响应的内容类型。 可见,JAX-RS的基本使用跟Spring Boot的控制器是类似的,因此我们仍然称有@Path注解的类为控制器类,但它们的运行机制不太相同。在Spring Boot中,一个控制器类是一个Bean,全局只有一个实例。但在JAX-RS中,对于一个@Path类,每当一个HTTP请求映射到它时,它都会创建一个新的实例来执行。因此,我们的控制器类不能作为Bean管理。 为了简化控制器类的代码,我们编写所有控制器的抽象基类BaseController: // 所有API均返回JSON响应 @Produces(MediaType.APPLICATION_JSON) public abstract class BaseController { public BaseController() { // 注入依赖 IoCContainer.injectDependencies(this); } } 业务的控制器类继承自BaseController: public EntityController extends BaseController { @Resource private EntityService entityService; @GET @Path("/getEntity") public Entity get() { ... } } 自动数据库事务管理 我们希望执行一个API请求时,如果正常返回则自动提交数据库事务,如果发生异常则自动回滚数据库事务。 因为JAX-RS最终也是基于Servlet实现的,因此我们可以使用Servlet中的过滤器(Filter)机制,实现请求前、后的拦截,并在其中实现统一异常处理和自动数据库事务管理。 // 只拦截我们应用的API @WebFilter("/my/*") public class MyFilter implements Filter { @Override public void init(FilterConfig filterConfig) {} @Override public void destroy() {} @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { try { // 当前请求线程获取数据库连接 ConnectionHolder.getConnection(); // 对于HTTP响应,做统一异常处理 if (response instanceof HttpServletResponse) { HttpServletResponse httpResponse = (HttpServletResponse) response; // HTTP响应包装器,重写方法,避免响应在其他Servlet或Filter中被提交导致无法修改响应体内容 HttpServletResponseWrapper httpResponseWrapper = new HttpServletResponseWrapper(httpResponse) { @Override public void sendError(int sc, String msg) {} @Override public void sendError(int sc) {} @Override public void sendRedirect(String location) {} }; try { // 执行 chain.doFilter(request, httpResponseWrapper); // 执行成功,提交数据库事务 ConnectionHolder.commit(); } // 捕获任何抛出,进行统一异常处理 catch (Throwable e) { // 回滚数据库事务 ConnectionHolder.rollback(); // 响应结果map Map<String, String> resultMap = new LinkedHashMap<>(); resultMap.put("error", e.getClass().getSimpleName()); resultMap.put("message", e.getMessage()); // 写入HTTP响应 httpResponse.setStatus(500); httpResponse.setContentType(MediaType.APPLICATION_JSON); httpResponse.getWriter().write(JSONUtil.toJsonStr(resultMap)); } } // 对于非HTTP响应,不做处理 else { chain.doFilter(request, response); } } finally { // 当前请求线程关闭数据库连接 ConnectionHolder.closeConnection(); } } }
- oa-backend-framework项目架构设计 - IoC容器
IoC容器+依赖注入大大降低了对象间依赖管理的复杂性,给Java开发带来了极大的便利。我们的应用同样遵循这个设计思想。 考察OA系统中可能可用的IoC容器,发现都不适合我们: Spring IoC容器:Spring的版本为1.2.6,有IoC容器功能,但由于版本过早,不支持注解。 OA系统自己的IoC容器com.weaverboot.frame.ioc.container.WeaIocContainer:与OA系统各机制强相关,无法确定是否能保证我们应用的隔离性,有互相影响的风险。 其实我们需要的特性很简单: 只需要支持单例。 只需要按类型注入。 基于注解的Bean类扫描和依赖注入。 因此,我们自己实现IoC容器。 Component注解:具有此注解的具体类(非抽象类)将会作为Bean管理。 IoCContainer类: 使用Map<Class<?>, Object> beanMap保存每个类对应的Bean实例。 使用Hutool(在OA系统的三方库中)的ClassScanner扫描应用的包,对每个带有@Component注解的具体类,调用registerBean()注册Bean实例。 registerBean(Class<?> clazz)方法:先检查clazz在beanMap中是否存在,存在则不重复注册。不存在,则调用clazz的无参构造器创建Bean实例,并保存到beanMap中,然后调用injectDependencies()注入依赖。注意:这里要先保存到beanMap,再注入依赖,否则在循环依赖、自身依赖的场景下会无限递归。 injectDependencies(Object object)方法:遍历object所在类及所有祖先类的实例属性,如果属性有@Resource注解,且属性值为null,则按属性的类型获取Bean实例,并设置为属性值。如果该类型对应的Bean实例不存在,则先调用registerBean()注册之。 应用启动时,会调用IoCContainer扫描、注册Bean实例,并注入依赖,完成IoC容器的初始化。
- oa-backend-framework项目架构设计 - 数据库层
OA系统使用weaver.conn.RecordSet类和weaver.conn.RecordSetTrans类来执行数据库操作,其中RecordSet不支持事务,RecordSetTrans支持事务。这两个类的代码量非常大,在没有文档和注释的情况下,仅通过逆向分析,我们根本无法保证能正确地使用其特性。因此,为了更加可控,我们自己实现数据库层。 数据源管理器 DataSourceManager类负责初始化数据源,并从数据源获取数据库连接。启动应用时,会调用DataSourceManager执行初始化。 初始化数据源:我们使用Druid(在OA系统的三方库中)作为数据库连接池。读取OA系统的数据源配置文件weaver.properties,用其中的配置创建DruidDataSource实例,并保存在DataSourceManager的静态属性中。 获取数据库连接:从DruidDataSource实例上获取数据库连接。 线程的数据库连接 ConnectionHolder类是一个ThreadLocal容器,用于保存当前线程的数据库连接,确保一条线程的所有操作都在同一个连接中,即在同一个事务中。 ConnectionHolder提供获取连接、关闭连接、提交事务、回滚事务这4个方法。 数据库服务 应用没有专门的DAO层框架,直接用JDBC模式操作数据库又很繁琐,因此,我们以服务的形式提供通用的数据库操作功能。 DatabaseService类是通用的数据库服务类,封装了通用的查库和写库操作的方法。此服务可以通过IoC容器注入到其他类中,应用中所有的数据库操作最终都是通过调用DatabaseService来完成。 DatabaseService使用Apache Commons DBUtils(在OA系统的三方库中)的QueryRunner来执行SQL。执行时,总是从ConnectionHolder获取数据库连接。 DatabaseService类提供以下查库方法: 方法 描述 查询结果为0行 查询结果为1行 查询结果为多行 queryRowOrNull 获取唯一行 返回null 返回唯一行 抛出异常 queryRow 获取唯一行 抛出异常 返回唯一行 抛出异常 queryFirstRowOrNull 获取第一行 返回null 返回第一行 抛出异常 queryFirstRow 获取第一行 抛出异常 返回第一行 抛出异常 queryRows 获取行列表 返回行列表(空) 返回行列表(1行) 返回行列表(多行) DatabaseService类提供以下写库方法: insert:插入 update:更新 delete:删除 所有查库和写库方法都支持参数String sql, Object... params,防止注入攻击。 通用的行数据结构 为了通用地表示DatabaseService查库方法查询到的任意结构的行数据,我们定义了Row类。 Row类是一个Map,类似于JavaScript的对象、Python的字典,可以表示任意的数据库行、实体、对象等数据结构。用Map表示对象,可以无需对属性做预定义,可以按需自由地增删属性,这带来了一些灵活性(例如:可以构造更新部分字段的更新数据对象,可以将一种对象修改为另一种对象,可以在字段增减时热更新程序),也降低了一些确定性(例如:IDE因为不知道有哪些属性而无法做代码提示和检查错误)。 Row类继承自LinkedHashMap<String, String>。考虑实际使用场景,我们对其做了一些约束。 键,即对象的属性名,是大小写无关的。通过重写Map的get()、put()等方法,统一将键转换为小写形式,来实现大小写无关性。 值,即对象的属性值,存储为字符串。通过重写Map的put()等方法,实现无论给的值是何种类型,都统一转换为字符串存储,其中null会存储为空字符串。通过增加getInt()、getBigDecimal()等方法,提供获取合适数据类型的功能。这种设计,屏蔽了数据类型的不确定性(例如:小数是Double还是BigDecimal),也使其不支持嵌套的对象。 对于Map具有的、但对对象来说意义不大的方法,如compute(),我们重写之并直接抛异常,以阻止其被使用。 应用中的各种业务数据类都继承自Row,或者直接使用Row。DatabaseService的查库方法支持泛型,可以返回指定的业务数据类(Row或Row的子类)。
- oa-backend-framework项目架构设计 - 启动应用
OA系统的启动原理:OA系统在Web容器上注册了一个Servletweaver.general.InitServer。启动Web容器时,会执行InitServer的init()方法,OA系统就在该方法中启动,执行各种初始化操作。 同理,我们也可以在Web容器中注册一个ServletMyApplicationStartupServlet,实现启动: // 使用@WebServlet注解注册Servlet,可以免于修改web.xml。 // loadOnStartup:取最大整数,确保应用在OA系统启动之后才启动。 // urlPatterns:此Servlet不处理HTTP请求,但注解要求必填,所以这里给一个不会与OA系统冲突的路径即可。 @WebServlet(loadOnStartup = Integer.MAX_VALUE, urlPatterns = "/my") public class MyApplicationStartupServlet extends HttpServlet { @Override public void init() { // 启动应用程序,执行初始化 } } 应用启动时执行的初始化过程: 打印应用logo,类似于Spring Boot,这可以让我们在日志中更显著地看到应用启动。 初始化数据源。 初始化IoC容器。 对OA系统进行配置,例如动态注册免用户身份检查的API路径前缀(unchecksessionurl)。
- oa-backend-framework项目架构设计 - 总体架构
总体思路上,我们要做一个相对独立的后端应用程序。 这个应用具有独立的分层设计,主要分层如下: 业务请求入口: 控制器层:接收来自Web API的业务请求。控制器类会注册到Web容器中。虽然API主要都写在我们的Spring Boot项目中,而不是写在这里(因为很麻烦),但仍然有部分涉及OA系统的API必须写在这里。 流程动作层:接收来自OA系统的执行流程动作的业务请求。流程动作类会注册到OA系统中。 服务层:实现业务服务,是业务逻辑的核心。 数据库层:实现对数据库的读写,支持事务管理。 应用与OA系统运行在同一个JVM中,可以在代码层面与OA系统进行交互。 应用将实现一个开发框架,这个开发框架将做到: 本地运行,方便调试:所有与OA系统非强相关的业务代码,都支持在本地运行,可以在Web容器上运行API,可以通过JUnit运行流程动作。本地运行时,可以方便地通过IDE断点做调试。 打包部署:整个项目可以打成一个jar包,然后整体上传、部署到OA系统中,方便更新和版本控制。 良好封装:框架对与OA系统的交互做良好封装,业务代码完全透明,或只需使用封装后的简单概念和接口,无需自己处理OA系统的复杂细节。 事务控制:一个业务请求(API或流程动作)的所有代码都运行在一个数据库事务中,支持自动提交和回滚。 开发规范:制定合理的开发规范,包括包结构、实体划分、服务粒度等,使业务代码更清晰有序、更可复用、更可维护。 由于本质上仍然是嵌入到OA系统中运行,应用面临如下限制: 部署后仍然要重启OA:必须重启JVM,才能使新代码生效。 三方库有限:应用只能使用OA系统自带的三方库,且必须同版本。为了避免影响OA系统,不允许向OA系统中加入其他三方库或已有三方库的其他版本。