简介:一套基于SpringBoot、MyBatis、Thymeleaf、Quartz、Shiro和SAPJCO3构建的后台管理系统源码,面向需要将外部系统与SAP进行数据交换的Java开发人员,解决了通过RFC函数获取或推送数据、并以接口和定时任务方式对外提供服务的核心问题。资源共336个文件,包含98个Java业务代码、41个JavaScript脚本、28个HTML页面、25个CSS样式,以及配置文件和SQL脚本,压缩包整体约9.46MB,前端采用单页面响应式简约设计,后端覆盖框架配置与核心逻辑。目前已有430人学习下载,适合作为SpringBoot整合SAP中间件、权限框架和任务调度的参考项目。通过阅读源码可掌握SAPJCO3调用RFC的完整流程、Shiro安全认证配置、Quartz动态同步任务的写法,以及MyBatis-Plus持久层设计,为自建集成平台提供可落地的范本。
1. sapweb 是什么:把 RFC 交互从黑匣子变成 Web 服务
sapweb 这套资源解决的是企业里很常见的一个尴尬问题:业务部门要的数据在 SAP 里,但没人愿意天天登录 SAP GUI 去跑事务码,Java 工程师想调 RFC 又缺一个能直接跑的工程示例。它基于 Spring Boot + MyBatis + Thymeleaf + Quartz 2 + Shiro + SAP JCo 搭出一条完整链路,SAP 连接、RFC 调用、权限控制、数据落库、页面展示、定时调度全都有代码可抄。适合两类人:一类是要做 SAP 集成的 Java 工程师,另一类是刚接手 ERP 对接、被 RFC 黑匣子搞蒙的团队。下面按我实际拆这套工程时的顺序,把连接配置、业务链路、页面交互和调度排查一层层讲清楚。
2. SAP JCo 连接与 RFC 调用:先让 SAP 开口说话
2.1 依赖引入:sapjco3.jar 为什么不在 Maven 中央仓库
先解决依赖问题。SAP JCo 的官方包在 Maven 中央仓库里找不到,因为 SAP 把 JCo 打在了自己 Support 门户下载的压缩包里,里面包含 sapjco3.jar 和对应操作系统的本地库文件。Windows 上是 sapjco3.dll,Linux 上是 libsapjco3.so,这两个本地库决定了 JCo 是否能被 JVM 加载起来。
常见的做法是把下载好的 sapjco3.jar 放进项目 lib 目录,用 system scope 引:
<dependency> <groupId>com.sap.conn.jco</groupId> <artifactId>sapjco3</artifactId> <version>3.0</version> <scope>system</scope> <systemPath>${project.basedir}/lib/sapjco3.jar</systemPath> </dependency>system scope 的坑在于:用 spring-boot-maven-plugin 打 fat jar 时默认不会把 system 依赖打进去,需要在插件配置里额外加 includeSystemScope。如果你们公司有私服,我一般更推荐先用 install-file 把它推到私服再正常引:
mvn install:install-file -Dfile=/opt/sapjco/lib/sapjco3.jar \ -DgroupId=com.sap.conn.jco -DartifactId=sapjco3 \ -Dversion=3.0 -Dpackaging=jar本地库文件不要放到 Maven 里,交给运维直接放到服务器的 PATH 目录下,或者启动脚本里显式声明-Djava.library.path=/opt/sapjco/lib。这样开发环境和生产环境分离,避免开发机能跑、服务器上 UnsatisfiedLinkError 这种经典翻车。
2.2 jcoDestination 连接文件与连接池参数
JCo 3.x 的标准用法是从 classpath 读取一个以.jcoDestination结尾的配置文件,文件名就是连接目标名称。我在这个工程里建了一个RFC_DEST.jcoDestination:
jco.client.ashost=10.10.20.5 jco.client.sysnr=00 jco.client.client=800 jco.client.user=RFC_USER jco.client.passwd=your_password jco.client.lang=ZH jco.destination.pool_capacity=10 jco.destination.peak_limit=20参数含义按表格理解就行:
| 参数 | 作用 |
|---|---|
| jco.client.ashost | SAP 应用服务器地址 |
| jco.client.sysnr | SAP 系统编号,通常两位数字 |
| jco.client.client | SAP 客户端号,如 800 |
| jco.client.user / passwd | RFC 通信账号密码 |
| jco.client.lang | 登录语言,ZH 或 EN |
| jco.destination.pool_capacity | 连接池容量,默认值偏保守 |
| jco.destination.peak_limit | 峰值连接数上限 |
连接池容量这里值得多说一句:默认值往往很低,之前遇到过一个生产事故,就是连接池配小了,早上业务高峰期 RFC 调用全部排队超时。一般先按每台部署实例 10 个连接起步,再结合实际并发量观察。RFC 调用是阻塞式的,一个连接在等待 SAP 返回时不能被别的线程复用,所以容量必须大于等于最大并发线程数。
2.3 一次 RFC 调用的完整骨架
连接配好后,最核心的就是把 RFC 调起来。以物料批量查询为例,JCo 的调用骨架长这样:
public List<MaterialDTO> queryMaterialList(String materialType) throws JCoException { JCoDestination destination = JCoDestinationManager.getDestination("RFC_DEST"); JCoFunction function = destination.getRepository().getFunction("BAPI_MATERIAL_GETLIST"); function.getImportParameterList().setValue("MATERIAL_TYPE", materialType); function.getImportParameterList().setValue("MAX_ROWS", 100); function.execute(destination); JCoTable table = function.getTableParameterList().getTable("MATNRLIST"); List<MaterialDTO> result = new ArrayList<>(); for (int i = 0; i < table.getNumRows(); i++) { table.setRow(i); MaterialDTO dto = new MaterialDTO(); dto.setMaterialCode(table.getString("MATERIAL")); dto.setMaterialDesc(table.getString("MATL_DESC")); result.add(dto); } return result; }JCoDestinationManager.getDestination是按名称拿连接池里的连接,这个名称对应上面配置文件的文件名。getRepository().getFunction是从远端 SAP 取函数元数据,第一次调用会走网络,之后 JCo 会缓存函数定义,所以同一个函数的二次调用会快很多。
import 参数列表是传给 SAP 的入参,MATERIAL_TYPE是物料类型,MAX_ROWS限制返回行数;table 参数列表拿的是返回的表结构,遍历时setRow(i)把游标挪到对应行,再用getString取字段值。这里要注意字段名大小写严格按 SAP 侧的字段名来,比如MATERIAL而不是material,Java 侧统一用 String 接收,避免类型转换问题。
2.4 RETURN 结构与 BAPI 事务提交
SAP 的 BAPI 函数习惯性会返回一个叫RETURN的结构体,里面带TYPE、CODE、MESSAGE三个关键字段。TYPE 常见取值是 S(成功)、E(错误)、W(警告)、A(中止),调用后必须先判这个结构再继续处理数据:
JCoStructure returnStruct = function.getExportParameterList().getStructure("RETURN"); String type = returnStruct.getString("TYPE"); if ("E".equals(type) || "A".equals(type)) { throw new RfcBusinessException(returnStruct.getString("CODE"), returnStruct.getString("MESSAGE")); }还有一个新手最容易漏的点:如果调用的是创建、修改类 BAPI,比如 BAPI_MATERIAL_SAVEDATA,SAP 侧只是把数据放到更新任务里,必须显式调用BAPI_TRANSACTION_COMMIT后才会真正落库。这步漏掉,函数返回 S 但业务数据没写进 SAP,排查起来相当费劲。我一般会在工具类里把「调用 BAPI + 判 RETURN + 提交事务」封装成一个模板方法,所有写操作走同一个入口,避免每个开发重复踩坑。
3. Spring Boot 业务链路:Shiro 权限、MyBatis 落库与接口设计
3.1 Shiro 的 Realm 与过滤链
权限这块选 Shiro 而不是 Spring Security,主要原因是这套技术栈偏传统,Shiro 的过滤链配置直观,学习成本低。自定义 Realm 只需实现两个方法:
public class UserRealm extends AuthorizingRealm { @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { SysUser user = (SysUser) principals.getPrimaryPrincipal(); SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(); info.addRole(user.getRoleCode()); return info; } @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { UsernamePasswordToken upToken = (UsernamePasswordToken) token; String username = upToken.getUsername(); SysUser user = userMapper.findByUsername(username); if (user == null) { throw new UnknownAccountException("账号不存在"); } return new SimpleAuthenticationInfo(user, user.getPassword(), getName()); } }doGetAuthenticationInfo负责登录校验,返回的 SimpleAuthenticationInfo 里第二个参数是数据库里的密码,Shiro 会拿它和用户输入做比对;doGetAuthorizationInfo在每次请求时被调用,用来判断角色权限。密码加密这里建议至少用 MD5 加盐,或者直接上 BCrypt,别用明文存库。
过滤链配置在 ShiroConfig 里,用 LinkedHashMap 保证顺序:
@Bean public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) { ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean(); factoryBean.setSecurityManager(securityManager); Map<String, String> filterChain = new LinkedHashMap<>(); filterChain.put("/login", "anon"); filterChain.put("/css/**", "anon"); filterChain.put("/js/**", "anon"); filterChain.put("/api/rfc/**", "authc"); filterChain.put("/**", "authc"); factoryBean.setFilterChainDefinitionMap(filterChain); return factoryBean; }anon 表示匿名可访问,authc 表示必须登录。顺序很关键:/login必须放在最前面,Shiro 是按LinkedHashMap顺序逐个匹配的,一旦/**先命中,后面的规则全失效。/api/rfc/**单独列出来是因为后端接口要被页面 JavaScript 异步调用,后面会专门讲。
3.2 MyBatis 批量落库与字段映射
RFC 拉回来的数据往往一次性几千行,逐条 insert 显然不现实。MyBatis 里最常见的批量落库方式是 BATCH 执行器配合 XML 批量插入:
<insert id="batchInsertMaterial" parameterType="list"> INSERT INTO t_material(material_code, material_desc, sync_time) VALUES <foreach collection="list" item="item" separator=","> (#{item.materialCode}, #{item.materialDesc}, NOW()) </foreach> </insert>@Autowired private SqlSessionFactory sqlSessionFactory; public void batchInsert(List<MaterialDTO> list) { SqlSession session = sqlSessionFactory.openSession(ExecutorType.BATCH); try { MaterialMapper mapper = session.getMapper(MaterialMapper.class); for (MaterialDTO dto : list) { mapper.insertMaterialSingle(dto); } session.commit(); } finally { session.close(); } }BATCH 模式下,循环调用 mapper 的 insert 并不会立即执行 SQL,MyBatis 会把语句累积到一批,到 commit 时统一发给数据库,这在大量数据场景下性能比逐条提交高一个量级。XML 里的 foreach 适合一次两三百行的中等批量,如果单次条数上千,拼接出来的 SQL 会很大,MySQL 的 max_allowed_packet 可能直接报错,这种情况建议控制每次批量 500 行左右。
字段映射这里有个偷懒技巧:SAP 侧字段全是 MATNR、MAKTX 这种大写加下划线风格,Java 侧习惯驼峰命名。在 application.yml 里开一个开关,就能省掉所有手写 resultMap:
mybatis: configuration: map-underscore-to-camel-case: true开启后MATERIAL_CODE会自动映射到 Java 属性的materialCode,注意 Java 属性不要用大写开头。
3.3 同步查询与异步任务:接口怎么分
接口设计上要明确区分两种场景。用户在前端点按钮查少量数据,这种走同步调用没问题,Controller 直接调 JCo,把超时时间放宽到 10 秒即可;但大批量拉数不能这么干,RFC 调用本身阻塞式,几分钟的业务让 HTTP 请求一直挂着,网关和浏览器都受不了。
我的做法是拆两步:前端点击「触发同步」时,Controller 只往本地任务表插一条待执行记录,真正干活交给 Quartz 异步任务;前端再通过轮询接口查执行进度。这样既保证用户操作快速返回,又不会把 Web 容器线程池占满。RFC 的并发控制也建议单独做,用 Java 信号量限制同时进入 SAP 的连接数,避免一处大查询把整个连接池打爆,导致其他小查询全部排队。
4. Thymeleaf 与 JavaScript:把 RFC 结果搬到浏览器
4.1 Thymeleaf 数据传递与内联脚本
Thymeleaf 干的是服务端渲染的活,但总有场景需要把后端数据交给 JavaScript 处理。不要用字符串拼接,Thymeleaf 3 支持原生内联脚本:
<script th:inline="javascript"> const initStatus = /*[[${syncStatus}]]*/ 'idle'; const userRole = /*[[${session.user.roleCode}]]*/ ''; </script>模板渲染时/*[[${syncStatus}]]*/这段会被替换为实际值,比如'running',浏览器拿到的就是合法的 JavaScript 赋值语句。后面那个'idle'是模板渲染失败时的兜底值,这样页面直接打开也不会语法报错。
这种方式的坑在于:th:inline="javascript"只对当前 script 标签生效,忘了写属性,[[...]]就会被当成普通文本输出,控制台直接报 JavaScript 语法错误。另外内联传对象时,Thymeleaf 输出的是 JSON 字符串,别拿它直接当对象用,我一般会加JSON.parse()包一层。
4.2 fetch 异步调用与 401 处理
前端调 RFC 接口,用 JavaScript 原生 fetch 就够了,不需要额外引 axios。核心代码:
async function queryMaterial() { const resp = await fetch('/api/rfc/material?type=HALB', { headers: { 'Content-Type': 'application/json' } }); if (resp.status === 401) { window.location.href = '/login'; return; } if (!resp.ok) { throw new Error('RFC 调用失败,HTTP ' + resp.status); } const data = await resp.json(); renderTable(data); }注意这里对 401 做了单独处理。Shiro 拦截未登录请求时,页面跳转能拿到登录页,但 fetch 是异步请求,浏览器拿到 401 后不会自动跳页。我统一在前端封装了一个 request 方法,所有 fetch 都走这个入口,遇到 401 直接window.location.href切到登录页,不然用户看到的就是一个无声无息失败的页面,连为什么都不清楚。
4.3 进度轮询与定时器清理
RFC 同步任务在后台跑时,前端要通过轮询展示进度。我用 setInterval 每 2 秒拉一次,直到任务完成才清掉:
const timer = setInterval(async () => { const resp = await fetch('/api/sync/progress'); const state = await resp.json(); document.getElementById('progress').innerText = state.finished + '/' + state.total; if (state.finished >= state.total) { clearInterval(timer); loadTable(); } }, 2000);这里的血泪经验是:页面切走时 setInterval 还在跑,用户已经点了别的菜单,定时器还在发请求,轻则多打几条无用日志,重则后端数据库连接被拖垮。我习惯在进入页面时记录 timer 到全局变量,离开页面统一清理,或者在每次轮询回调里检查document.visibilityState,页面隐藏时主动停止请求。
5. Quartz 2 定时调度避坑与排查:凌晨任务为什么翻车
5.1 Job 与 Trigger 的定义方式
这部分用 Quartz 2 的 Java 配置,比 XML 直观。先定义一个 JobDetail 和对应的 CronTrigger:
@Bean public JobDetail materialSyncJobDetail() { return JobBuilder.newJob(MaterialSyncJob.class) .withIdentity("materialSyncJob") .storeDurably() .build(); } @Bean public Trigger materialSyncTrigger() { return TriggerBuilder.newTrigger() .forJob(materialSyncJobDetail()) .withIdentity("materialSyncTrigger") .withSchedule(CronScheduleBuilder.cronSchedule("0 0 2 * * ?")) .build(); }0 0 2 * * ?表示每天凌晨 2 点整触发。Quartz 的 Cron 表达式是 7 位的,第一位是秒,和 Linux crontab 的 5 位格式不一样,这个最容易写错。如果你想 2 点半跑,要写0 30 2 * * ?,不是0 2 30 * * ?。
Job 类实现 org.quartz.Job 接口,在里面拿 Spring 管理的 Service 时有个坑:Quartz 每次执行都会 new 一个 Job 实例,@Autowired 直接注进去是 null。标准解法是做一个 SpringContextHolder,从静态上下文里取 Bean:
public class MaterialSyncJob implements Job { @Override public void execute(JobExecutionContext context) throws JobExecutionException { MaterialSyncService service = SpringContextHolder.getBean(MaterialSyncService.class); try { service.syncMaterialsFromSap(); } catch (Exception e) { throw new JobExecutionException(e); } } }JobExecutionException 是给 Quartz 调度器的信号,抛出后调度器会按策略决定是否重跑,比在 Job 内部吞掉异常更规范。
5.2 高频踩坑记录:现象、原因与解决
这些坑是我拆这套工程过程中真实遇到过的,按现象、原因、解决分别列:
现象一:系统运行几天后,RFC 调用全部卡死,日志刷 JCo 资源不足。原因:当时没有用连接池,每个请求都创建一个新的 JCoDestination,连接没关闭,最终把 SAP 侧允许的并发连接数耗尽。 解决:统一走 JCoDestinationManager 的命名连接,并把 pool_capacity 配到合理值,同时用信号量控制并发数。
现象二:本地启动直接报 UnsatisfiedLinkError: no sapjco3 in java.library.path。原因:sapjco3.jar 只是 Java 类库,JCo 还需要加载本地的 dll 或 so 文件,而且本地库位数必须和 JVM 位数一致,32 位 JVM 配了 64 位本地库一样报错。 解决:下载完整安装包,确认本地库与 JVM 位数匹配,开发机放系统 PATH,生产机在启动脚本里指定-Djava.library.path。
现象三:RFC 返回的中文在数据库里变成乱码,页面上显示问号。原因:JVM 默认编码和 SAP 侧字符集不一致,加上数据库连接串里没设 characterEncoding。 解决:JVM 加-Dfile.encoding=UTF-8,JDBC URL 里显式加characterEncoding=utf8,同时检查 SAP 侧字段长度,中文在 UTF-8 下占三个字节,字段长度不够也会显示成乱码。
现象四:定时任务到点跑了两遍,SAP 里出现重复数据。原因:应用部署了两个实例,每个实例的 Quartz 调度器都在触发同一个 Job;或者某个 Job 执行时间超过了触发间隔,上次没跑完,下次又触发。 解决:Job 上标@DisallowConcurrentExecution防止单实例内重入,多实例部署时启用 Quartz JDBC 集群模式,把调度锁交给数据库,或者在任务入口加分布式锁。
现象五:前端 fetch 调 /api/rfc 接口返回 401,页面白屏。原因:Shiro 过滤链把/api/rfc/**拦截了,但异步请求没有跳登录页,前端也没处理 401。 解决:过滤链里把需要登录的接口保留 authc,前端封装统一请求方法,遇到 401 跳登录页;如果某些 RFC 接口允许匿名查,则显式配成 anon,注意 anon 规则放在 authc 前面。
6. 进阶技巧:用增量水位表实现断点续传
6.1 增量同步标记怎么落在表里
定时同步不能每次把全量数据都拉一遍,SAP 侧数据量一大,全量拉取既慢又容易超时。我在这个工程里加了一张水位表,专门记录每个同步任务的增量标记:
CREATE TABLE t_sync_watermark ( sync_type VARCHAR(32) PRIMARY KEY, last_sync_key VARCHAR(64), last_sync_time DATETIME, sync_status VARCHAR(16) );同步 Job 的逻辑变成:先查水位,拿到上次同步到的位置,然后只请求这个位置之后的数据,成功后把新位置写回水位表;如果中间失败,水位不更新,下次重跑从断点处继续。
String lastKey = waterMarkMapper.getLastSyncKey("MATERIAL"); List<MaterialDTO> list = rfcService.queryFromLastKey(lastKey); int affected = materialMapper.batchInsertIgnore(list); String maxKey = list.get(list.size() - 1).getMaterialCode(); waterMarkMapper.updateLastSyncKey("MATERIAL", maxKey);这里的边界要处理好:如果水位是时间戳,注意 SAP 返回时间有服务器时区问题,我习惯用 SAP 返回的单据号或物料号做水位,字符串递增排序稳定,不依赖时区。batchInsertIgnore 配合本地表的唯一索引,是为了让重复执行不产生脏数据,SAP 的 RFC 不是事务性的,网络断了你不知道 SAP 到底提交没有,本地唯一索引是最后的后悔药。
从那以后,我每次上线这类定时同步任务,都会先建好水位表,再把增量逻辑写进 Job,让失败重跑从断点继续,而不是全量刷一遍。这套 sapweb 骨架代码我整理成了完整工程,JCo 配置示例、Quartz 任务、Shiro 过滤链都是现成的,你拿到后替换连接参数就能起服务,希望帮到你。
本文还有配套的精品资源,点击获取