做过制造业、物流、OA这类企业内部系统的朋友应该都体会过一种“复制粘贴式开发”的痛今天加一张客户表明天加一张订单表后天加一张审批单每一张表背后都得配实体类、Mapper、Service、Controller、列表页、表单页、详情页。模块一多光Controller就堆出几百行几乎一模一样的代码后面任何公共逻辑调整都要翻遍所有模块改一遍稍微漏一个文件线上就给你颜色看。我这次要分享的就是直击这个痛点的一个实战方案在SSM框架下用Java反射做一个“动态实体动态页面跳转”的通用处理层。请求进来时根据参数里的类型编码动态获取实体类、动态调用对应的Service、动态返回对应的Layui页面Oracle数据库里的几十张业务表共用同一套控制器和基础逻辑新增一个业务模块只需要配一条映射记录和写一个具体页面就够了。整个方案在我经手的某物资管理类模拟项目中已经稳定跑过两轮迭代本文会把完整的设计思路、关键代码、配置表和踩过的坑都讲透适合正在做系统重构、模块扩建或者被重复CRUD逼得想骂人的Java Web开发同学参考。1. 需求场景分析为什么需要动态反射1.1 传统CRUD三层架构的代码爆炸先还原一个特别典型的场景。项目里上线时只有三个模块客户管理、订单管理、产品管理。每个模块的常规CRUD从Controller到Service再到Mapper大概是这样一个套路Controller写分页查询、详情查询、保存、更新、删除5个方法每个方法里处理参数接收、调Service、封装返回值。Service写接口和实现类每个方法里处理业务校验、调用Mapper、处理异常。Mapper写接口和XML每个实体配一套resultMap和基础SQL。页面端写列表页、编辑页、详情页每个页面刷一遍表格和表单组件的初始化代码。三张表做完差不多还能忍受。等做到第八张、第十二张表时你会发现整个工程的Controller、ServiceImpl至少有七成代码是从上一个模块复制过来的改的只是对象类型、表名、字段名的替换。当时最让我崩溃的是一次公共字段调整给所有实体加一个createUserId涉及了全部模块的实体、Mapper SQL、页面隐藏字段和三套历史数据脚本整整两天都在机械地做“查找替换”改完还得逐个模块回归就怕哪个文件漏了导致字段映射不上。这个问题本质上是“编译期绑定”导致的每个模块在代码里被写死在类引用和调用关系里新增一个模块就必须新增一套代码系统扩展一次代码量就翻一倍平均单模块的开发工时根本降不下来。1.2 反射机制到底解决了什么问题Java反射能让你在程序运行期间通过全限定类名拿到Class对象再通过Class对象创建实例、获取方法、调用方法、操作属性。用一句话概括就是把“什么时候用哪个类、调哪个方法”从编译期推迟到了运行期变成一种“按配置驱动”的机制。映射回上面的业务痛点前端请求里带上一个typeCode后端根据这个typeCode去配置表查出对应的实体类全路径比如com.example.entity.Customer然后用Class.forName拿到类并实例化再查出来对应的Spring Bean名称比如customerService从容器里拿出来调用统一的服务接口最后根据typeCode拼出页面路径在Controller里返回一个动态的视图名。这样新增一个模块时代码层面只需要新增实体类和页面文件Controller、Service的公共骨架完全可以复用业务代码增量大幅收缩。当然反射也不是银弹。我个人的判断标准有三个业务实体数量是否足够多至少5个以上且CRUD结构高度相似。页面结构是否具备规律性表单字段虽然不同但交互形态一致。团队是否愿意按照统一的命名规范开发主键统一、字段名统一、页面文件名统一。如果只有两张表老老实实写传统CRUD比上反射快得多没必要为了“炫技”增加系统复杂度。1.3 为什么选SSMLayuiOracle的组合再解释一下这套技术组合的选型逻辑。SSMSpringSpringMVCMyBatis虽然不如Spring Boot新但在企业内部存量系统里占据的比例非常大这套框架的好处是分层清晰事务、依赖注入、SQL映射这些核心机制都能看得见摸得着适合做通用化封装。Layui作为服务端渲染风格的前端框架模块化内置了表格、弹层、表单组件配合JSP或模板引擎做多页面跳转非常顺手不需要额外引入一整套前端工程化工具。Oracle数据库则常见于对稳定性、管理能力要求更高的中大型业务系统它和MyBatis的整合主要注意驱动版本、大小写映射、分页SQL这几个点后面我会展开讲。2. 整体架构设计与映射关系梳理2.1 三种映射关系是动态化的基础整套方案的落地靠的不是散落的反射代码而是把“变化点”抽出来变成一张可配置的映射表。我在设计时梳理了三种映射关系它们分别回答了三个问题实体类映射typeCode对应哪个实体类的全限定名服务Bean映射这个实体类由哪个Spring容器中的Service来处理页面视图映射这个实体类的列表、表单、详情页面分别在哪里把这三类映射在数据库里维护成一张动态映射配置表后控制器的分发逻辑就变得非常统一接受请求查映射走反射调服务返回视图。整个处理流程和具体业务实体完全解耦这是动态化的核心基础。2.2 实体与Oracle表的命名映射规范动态化方案有一个隐藏前提表结构、实体类、页面之间必须有高度一致的命名规律否则配置表会无比巨大失去通用性。我当时定下的规范是这样的。Oracle数据库里所有业务表统一命名比如T_CUSTOMER、T_ORDER、T_PRODUCT主键统一叫ID字段统一大写加下划线风格例如CUSTOMER_NAME、CREATE_TIME、UPDATE_TIME。Java实体统一用驼峰命名即customerName、createTime。MyBatis全局开启mapUnderscoreToCamelCasetrue这样查询结果返回时Oracle返回的大写下划线列名能自动映射到实体的驼峰字段省掉大量手动resultMap。这里有一个非常重要的细节Oracle在JDBC驱动返回的字段名默认是全大写的比如CUSTOMER_NAME。如果MyBatis没有开启驼峰映射或者XML里没写别名查询出来的结果实体属性就全是null。我见过不止一个人在这种地方浪费半天时间以为是反射的问题实际只是映射配置没开。Oracle的日期类型也要提前约好。我统一使用DATE或TIMESTAMPJava侧用java.util.Date接收前端Layui的日期组件固定输出yyyy-MM-dd HH:mm:ss格式后端在参数绑定时统一做字符串转Date避免日期解析在各个模块里写法不一致。另外建议每张业务表预留两个公共字段CREATE_TIME创建时间和UPDATE_TIME更新时间。通用Service在insert和update时自动填充这两个字段用反射机制处理这样新表建好之后这套公共字段的维护就不用每个模块单独写一遍了。2.3 动态映射配置表的设计映射关系存储在Oracle里表结构大致是这样的CREATE TABLE SYS_DYN_MAPPING ( TYPE_CODE VARCHAR2(32) NOT NULL, CLASS_NAME VARCHAR2(200) NOT NULL, SERVICE_BEAN VARCHAR2(100) NOT NULL, TABLE_NAME VARCHAR2(64) NOT NULL, PRIMARY_KEY VARCHAR2(32) DEFAULT ID, PAGE_LIST VARCHAR2(200), PAGE_EDIT VARCHAR2(200), PAGE_DETAIL VARCHAR2(200), STATUS CHAR(1) DEFAULT 1, REMARK VARCHAR2(500) ); ALTER TABLE SYS_DYN_MAPPING ADD CONSTRAINT PK_SYS_DYN_MAPPING PRIMARY KEY (TYPE_CODE);一条典型的配置数据是这样的TYPE_CODECLASS_NAMESERVICE_BEANTABLE_NAMEPRIMARY_KEYcustomercom.example.entity.CustomercustomerServiceT_CUSTOMERIDordercom.example.entity.OrderorderServiceT_ORDERIDproductcom.example.entity.ProductproductServiceT_PRODUCTID页面路径字段也可以动态拼不一定每条记录都显式配置。比如统一约定页面路径等于dyn/ typeCode小写 _list、_edit、_detail那么LIST、EDIT、DETAIL三个字段甚至可以省略完全靠规则生成。但如果某些模块页面不按规则走就可以在配置表里单独指定灵活性更好。我实际采用的是“规则优先、配置覆盖”的策略后面讲页面跳转时会详细说。3. 后端核心实现反射获取实体与通用处理器3.1 通用Controller的分发逻辑后端入口我设计成一个统一的DynamicController所有业务模块的CRUD请求都走同一个地址/dynamic/list、/dynamic/detail、/dynamic/edit、/dynamic/save、/dynamic/delete每个方法里第一步都是拿typeCode去查映射配置再根据配置走后续逻辑。核心代码骨架是这样Controller RequestMapping(/dynamic) public class DynamicController { Autowired private ApplicationContext applicationContext; Autowired private SysDynMappingService mappingService; /** 通用列表跳转 */ RequestMapping(/list) public String list(RequestParam(typeCode) String typeCode, Model model) { SysDynMapping mapping getMapping(typeCode); model.addAttribute(mapping, mapping); return resolvePage(mapping, list); } /** 通用编辑页跳转 */ RequestMapping(/edit) public String edit(RequestParam(typeCode) String typeCode, RequestParam(value id, required false) String id, Model model) { SysDynMapping mapping getMapping(typeCode); if (StringUtils.hasText(id)) { Object entity findById(mapping, id); model.addAttribute(entity, entity); } else { model.addAttribute(entity, createEntity(mapping)); } return resolvePage(mapping, edit); } /** 通用数据保存 */ RequestMapping(/save) ResponseBody public Object save(RequestParam(typeCode) String typeCode, HttpServletRequest request) { SysDynMapping mapping getMapping(typeCode); Object entity createEntity(mapping); bindParams(entity, request); DynamicService service getService(mapping); service.save(entity); return R.ok(); } }注意这里我并没有把列表、详情的数据查询都塞进一个Controller方法而是拆成页面跳转接口和数据接口两类。页面跳转返回视图数据由Layui的table组件另外发请求获取这样的好处是前端逻辑更清晰页面刷新和数据加载分离也方便以后做权限控制。3.2 反射创建实体与参数绑定细节反射创建实体的代码量不大但细节比较讲究private Object createEntity(String className) { try { Class? clazz Class.forName(className); return clazz.getDeclaredConstructor().newInstance(); } catch (Exception e) { throw new BusinessException(实体类加载失败: className, e); } }实体创建出来后需要把请求参数绑定到对象的属性上。这里我建议不要直接用BeanUtils.populate那类工具因为工具类的属性匹配在遇上类型转换时经常静默失败比如Oracle返回的字符串日期没法自动转成Date。我自己写了一个轻量绑定方法逻辑是先拿到实体所有setter方法再和请求参数key做匹配最后用Method.invoke调用private void bindParams(Object entity, HttpServletRequest request) { Method[] methods entity.getClass().getMethods(); MapString, Method setterMap new HashMap(); for (Method method : methods) { if (method.getName().startsWith(set) method.getParameterCount() 1) { String fieldName lowerFirst(method.getName().substring(3)); setterMap.put(fieldName, method); } } MapString, String[] paramMap request.getParameterMap(); for (Map.EntryString, String[] entry : paramMap.entrySet()) { Method setter setterMap.get(entry.getKey()); if (setter null) { continue; } String value entry.getValue()[0]; setter.invoke(entity, convertValue(value, setter.getParameterTypes()[0])); } }这里有一个关键点Web表单提交的字段名和实体属性名必须一致页面上input namecustomerName实体里就是private String customerName。前端传id、typeCode这类本身就是请求公共参数的值后端过滤掉避免把无关参数绑进实体。convertValue方法负责类型转换核心支持String、Integer、Long、BigDecimal、Date、Boolean几种常见类型。Date的转换尤其注意格式我规定页面传参统一yyyy-MM-dd HH:mm:ss但也要兼容yyyy-MM-dd这种日期格式解析时先按长格式试失败再按短格式再失败就报错private Object convertValue(String value, Class? targetType) { if (targetType String.class) { return value; } if (targetType Integer.class || targetType int.class) { return Integer.valueOf(value); } if (targetType Long.class || targetType long.class) { return Long.valueOf(value); } if (targetType BigDecimal.class) { return new BigDecimal(value); } if (targetType Date.class) { return parseDate(value); } if (targetType Boolean.class || targetType boolean.class) { return Boolean.valueOf(value); } return value; }经验提醒反射调用setter时如果参数类型是基本类型和包装类型混用getParameterTypes()给出的类型可能不同比如setId(long id)和setId(Long id)转换逻辑就有细微差别。我后来统一约定实体里全部使用包装类型避免额外处理。3.3 Service层接口设计与事务控制关于Service层这里有一层很重要的设计考量。最极端的反射方案是连Service的方法也通过反射去invoke但我不推荐这么做因为Service方法的入参和返回值差异大强上反射会让异常堆栈和事务行为变得不可控出了问题非常难排查。我采用的是“半反射”方案定义一个统一接口DynamicService把常见操作抽象成泛型方法具体业务Service实现这个接口public interface DynamicServiceT { /** 分页查询返回给Layui table渲染用的数据 */ PageResultT queryPage(String tableName, PageParam param); /** 根据主键查询详情 */ T findById(String tableName, String primaryKey, Object id); /** 新增 */ int insert(T entity); /** 更新 */ int update(T entity); /** 保存存在即更新不存在即新增 */ int save(T entity); /** 删除 */ int deleteById(String tableName, String primaryKey, Object id); }每个业务Service在实现时注入自己的Mapper把tableName和实体类型做好对应。Controller里用applicationContext.getBean(mapping.getServiceBean())拿到Service后强转成DynamicService接口调用统一方法private DynamicService getService(SysDynMapping mapping) { Object bean applicationContext.getBean(mapping.getServiceBean()); if (!(bean instanceof DynamicService)) { throw new BusinessException(Bean不是DynamicService实现: mapping.getServiceBean()); } return (DynamicService) bean; }这样做的优点是反射只保留在“创建实例”“参数绑定”“Bean获取”这几个边界清晰的位置Service里的业务逻辑依然是类型安全、可单测、可追踪的。插入和更新操作的事务控制直接依赖Spring的声明式事务在具体Service实现类上使用Transactional注解就不会因为反射丢失事务上下文。我在实际项目里还踩过一个事务相关的坑通用Service的save方法里如果内部调用了另一个模块的Service方法比如保存完用户后记录一条操作日志一定要保证两个操作在同一个事务里。否则日志写入失败时主数据已经落库排查数据问题非常痛苦。3.4 通用Mapper与Oracle分页实现Mapper层同样采用泛型和动态SQL的思路。定义一个通用的基础Mapper接口使用MyBatis的动态SQL处理动态表名public interface DynamicBaseMapper { ListMapString, Object queryPage(Param(tableName) String tableName, Param(offset) int offset, Param(limit) int limit, Param(keyword) String keyword); int count(Param(tableName) String tableName, Param(keyword) String keyword); MapString, Object findById(Param(tableName) String tableName, Param(primaryKey) String primaryKey, Param(id) Object id); int insert(Param(tableName) String tableName, Param(entity) MapString, Object entity); int update(Param(tableName) String tableName, Param(primaryKey) String primaryKey, Param(entity) MapString, Object entity); int deleteById(Param(tableName) String tableName, Param(primaryKey) String primaryKey, Param(id) Object id); }对应的XML里重点注意动态表名取值用${}但必须做白名单校验。${tableName}存在SQL注入风险不能直接把前端传的参数拼进去应该从映射配置表里取值后再校验只允许字母、数字、下划线格式不对直接拒绝执行。这个校验逻辑我放在Service里private void checkTableName(String tableName) { if (!tableName.matches(^[A-Z][A-Z0-9_]{0,63}$)) { throw new BusinessException(非法的表名: tableName); } }Oracle分页方面我在项目里用了PageHelper这个分页插件直接配合MyBatis最新版本使用底层会自动把普通查询改造成带ROWNUM的Oracle分页SQL。如果不想引入插件也可以手写三层嵌套的RowNum分页但维护成本更高不建议在通用方案里手写。实际测试中PageHelper在Oracle下要注意一个细节分页拦截器只对紧随其后的第一条查询生效所以PageHelper.startPage(pageNum, pageSize)后面必须紧跟mapper查询中间不要插任何其他查询或赋值操作否则分页会失效甚至会作用到错误的SQL上。4. 前端Layui动态跳转与页面配合4.1 列表页表格与操作按钮的配置后端动态化做完以后前端也需要配合一套统一的约定。列表页我统一用Layui的table模块渲染页面里通过typeCode拼出数据接口地址。var typeCode ${typeCode}; table.render({ elem: #dynTable, url: /dynamic/pageData?typeCode typeCode, page: true, limit: 20, cols: [[ {field: id, title: ID, width: 80}, {field: createdTime, title: 创建时间, width: 160}, {field: updatedTime, title: 更新时间, width: 160}, {title: 操作, toolbar: #dynTableBar, width: 200, fixed: right} ]] });操作列通过Layui的自定义模板绑定lay-event事件在table.on(tool)里处理“编辑”“详情”“删除”三个操作。因为每个模块的typeCode已经在渲染页面时写入全局变量操作按钮发起跳转时直接拼上typeCode和主键id即可table.on(tool(dynTable), function (obj) { var data obj.data; if (obj.event edit) { layer.open({ type: 2, title: 编辑数据, content: /dynamic/edit?typeCode typeCode id data.id, area: [900px, 600px], end: function () { table.reload(dynTable); } }); } });这里有一个很实用的细节Layui的表格列是静态写死的因为不同模块的字段列表差别较大纯靠一套通用列配置很难满足所有场景。我在实际项目里采取的是折中方案——每个模块的列表页还是单独一个JSP页面但页面里只放表格容器和工具栏初始化表格的列配置存放在一个JS配置对象里通过typeCode索引获取。这样既保留了表格的动态性又不会让列配置的复杂度冲垮通用逻辑。4.2 页面路径的动态规则与视图解析后端在Controller里返回视图时遵循“规则优先、配置覆盖”的策略。默认规则是private String resolvePage(SysDynMapping mapping, String operation) { String page null; if (list.equals(operation)) { page mapping.getPageList(); } else if (edit.equals(operation)) { page mapping.getPageEdit(); } else if (detail.equals(operation)) { page mapping.getPageDetail(); } if (StringUtils.hasText(page)) { return page; } // 默认规则dyn/typeCode_operation return dyn/ mapping.getTypeCode().toLowerCase() _ operation; }所以当配置表里PAGE_LIST字段为空时Controller返回dyn/customer_listSpringMVC的视图解析器会去/WEB-INF/views/dyn/customer_list.jsp找页面。如果某个模块的页面不符合这个规律比如产品列表的页面比较特殊放在了公共目录下就只需要在配置表里把PAGE_LIST写成对应路径代码不用任何改动。这种“规则兜底、例外覆盖”的设计让配置、页面、代码三者之间保持清晰关系避免出现每个模块一套逻辑的局面。4.3 表单页面的两种实现方式比较编辑页是这套方案里最容易变复杂的地方。有两种做法可以选我分别说一下适用边界。第一种做法每个模块单独写表单页面字段固定只做公共部分的统一封装。优点是页面直观、改起来快、后端不必维护字段元数据缺点是新增模块仍然要写一个新页面。第二种做法表单页面也完全动态渲染后端根据配置表返回字段列表包括字段名称、控件类型、是否必填、下拉数据源前端用Layui的laytpl模板循环生成表单控件。优点是连页面都不用写了缺点是配置工作量大复杂表单状态联动很难用通用配置覆盖越用越重。我个人的建议是普通管理模块用第一种页面文件没多少成本维护直观只有字段极度标准化的模块才用第二种。强行把动态渲染做到极致后期多半会陷入“为了动态而动态”的泥潭不值得。5. 实测踩坑与排查经验5.1 实体类加载与Bean名称问题这块是我在实际项目中遇到最多、也最容易让人困惑的问题特意整理成对照表。现象可能原因解决方法ClassNotFoundException配置表里CLASS_NAME写错或者实体类没在同一个工程核对全限定类名检查类文件是否编译到了classpathNoSuchMethodException实体类没有无参构造方法所有实体类必须提供无参构造这是反射创建实例的前提NoSuchBeanDefinitionException配置表里SERVICE_BEAN和Spring容器里的Bean名称不一致确认Service注解的名称值比如Service(customerService)Bean类型不匹配配置表指向的Service没有实现DynamicService接口检查实现类是否implements了统一接口并实现了所有方法这里特别想提醒一个容易忽视的细节Spring的Service如果不指定名称默认Bean名称是类名首字母小写比如CustomerServiceImpl默认Bean名是customerServiceImpl。如果配置表里直接写了customerService运行期会报找不到Bean。我建议在所有业务Service上显式写明注解值并且在配置表里存与注解值完全一致的字符串。5.2 参数绑定失败的几种原因参数绑定在这个方案里是最容易出bug的环节主要表现是保存后实体部分字段为null。排查时我一般按这个顺序先确认前端表单字段的name和实体属性名是否一致。前端写customerName实体属性是customer_name绑定自然失败。再确认setter方法是否存在。实体类如果用Data这类工具生成了setter一般没问题手写实体时很容易漏写。然后看类型转换。页面传过来的是字符串实体属性是Date或BigDecimal时转换失败会抛异常但异常容易被吞掉导致整个save静默失败。最后看字段类型是包装类还是基本类型建议统一用包装类型。排查时我会在bindParams方法里加一个日志输出把每次成功绑定的setter方法和值打印出来对照请求参数一眼就能看出哪个字段没匹配上。上线时记得把日志级别调高避免刷屏。5.3 Oracle字段大小写、空串与日期类型Oracle在动态方案里有三个坑必须提前防住。第一个是列名大小写。前面提过JDBC返回的元数据列名是全大写的MyBatis不开驼峰映射的话实体属性全为null。而且Oracle里新建表时如果没加双引号列名默认大写所以建表语句里写customer_name和CUSTOMER_NAME效果一样但对实体映射的影响非常大。我统一约定建表时不加双引号全部写上大写下划线风格的列名。第二个是空字符串问题。Oracle里会被当成NULL处理这和MySQL很不一样。动态绑定参数时如果前端传了空字符串实体属性赋值后写入数据库会变成NULL。如果表里这个字段有非空约束直接插入就会报ORA-01400。我在通用Service的save里加了一个统一处理所有值为空字符串的字段如果对应数据库字段允许为空直接转成null如果字段必填则返回明确的校验提示给前端避免数据库抛错不友好。第三个是NUMBER类型映射。Oracle的NUMBER(1)在没有约束时Java侧可能映射成BigDecimal而不是Boolean如果实体用Boolean接收会有类型转换问题。我的处理是所有布尔字段在Oracle里统一用CHAR(1)存0/1Java实体用Integer接收前端再转换成布尔值展示。这样绕开了Oracle没有原生布尔的限制也省掉不少类型转换的麻烦。5.4 性能、安全与高频调用缓存反射实例化和动态分派确实比直接调用多一些开销但在常规CRUD场景里这个开销完全可以忽略。不过有一类性能陷阱要注意如果通用Service在循环里反复调Class.forName或者反复调用同一个setter每次反射调用都会有类加载和方法解析的成本。我在实际测试中发现几百次循环调用时差异不明显但如果是批量插入上千条数据反射调用setter的性能损耗就会被放大。优化方式很简单用一个ConcurrentHashMap在类加载结果和setter方法上做缓存以className为key缓存Class对象和方法列表绑定参数时直接从缓存取Method数组不再每次都反射扫描方法。这样既能保留动态方案的扩展性又避免了高频反射的性能问题。安全和权限也不能漏。通用接口把整个系统的CRUD能力都暴露出来了任何能访问系统的人都可能通过修改typeCode去操作他没有权限的模块。所以无论怎么封装Controller入口处必须加权限校验判断当前登录用户是否有对应typeCode模块的访问权限。我采用的是在配置表里增加一个AUTH_CODE字段用现成的权限框架做匹配接口层面再做一次严格鉴权防止有人直接拼接URL越权访问。6. 项目落地效果与后续扩展思路这个动态反射方案在某物资管理模拟项目中落地后新增业务模块的开发效率提升非常明显。之前新上一个基础数据模块从建表到页面跑通大概需要两到三天时间主要是反复复制修改前后端代码。使用这套方案后建好表、写好实体类、配好映射记录、写一个页面在一天内就能完成而且公共逻辑的bug修复只需要改一处所有模块同时生效回归测试的范围大幅缩小。关于后续扩展我还在两个方向上做过尝试。一是把映射配置表做成后台管理界面让系统管理员自己维护typeCode和实体类、ServiceBean之间的关系这样新增模块甚至不需要开发人员接触配置库。二是把通用Service里的代码再往下抽象比如把“列表查询关键词”、“排序字段”、“日期范围查询”都做成可配置项放进映射表里页面和代码的匹配度可以进一步提高。这些扩展都建立在本次动态反射的基础之上不改动核心分发逻辑。回到一开始的问题做这套方案不是为了让代码看起来“高级”而是真正意识到当业务模块多到一定程度时把公共骨架抽出来用配置驱动是一种系统级的解法。如果你手头也有一大批结构相似的表和页面不妨试试用一个单独的模块把这套逻辑搭起来先拿一个冷门业务模块跑通再逐步铺开。反射用对了地方十几张表的维护压力能瞬间减轻大半。
阅读完成 · 觉得有帮助?