☰
SAP RFC Web集成:sapjco3连接池与Quartz定时任务实战
2026/10/26 13:25:12 网站建设 项目流程

简介:这份源码包面向有 Java Web 基础、需把企业 SAP 数据与外部系统对接的开发者,核心是用 Spring Boot 整合 MyBatis、Thymeleaf、Quartz 2、Shiro 与 SAPJCo,搭建含后台管理、权限控制和 RFC 定时同步的系统。包内共 336 个文件,约 9.46MB,以 98 个 Java 源码、28 个 HTML 页面、41 个 JavaScript 文件及 CSS、XML、SQL 配置为主,覆盖业务分层、前端模板、数据库脚本与 SAP 连接环境。已有 432 人学习,可参考其模块划分、接口暴露、权限配置、定时任务和 RFC 数据同步流程,也能从依赖与配置中梳理 SAP 排错思路,适合作为企业集成、后台管理模板或课程设计参考。

1. 需求是"三天内能点按钮查 SAP 数据":这套 sapweb 到底在补哪一段

对方只给一张 RFC 函数清单和一个账号,要求三天内能登录、能点按钮、能看结果。这类活儿真正难的地方从来不是写 Java,而是把 SAP 的 RFC 调用塞进一个正常的 Web 系统里,同时不让连接、会话和定时任务互相拖累。sapweb 就是为这类需求做的一套基础框架:SpringBoot 起服务,Shiro 管登录与按钮级权限,MyBatis 存调用记录,Quartz 2 跑定时拉取,Thymeleaf 出页面,浏览器侧用原生 JavaScript 轮询任务进度,底层靠 sapjco3 直连 SAP 的 RFC 函数模块。适合手上有一批 RFC 函数名、需要一个能交付给业务方点、而不是写个 main 方法跑完就交付的开发者。

2. 依赖与分层:sapjco3 不是中央仓库里能直接拉下来的包

很多人第一次做这类项目,会在pom.xml里随手写一个 sapjco3 的坐标,然后发现根本拉不下来。sapjco3 从来不在公共仓库里,它是一份需要单独获取的 jar 加本地库文件,而且 jar 和本地库必须成对出现、位数必须一致。这个约束直接决定了项目的构建方式和部署方式,也决定了后面所有"本地能跑、服务器上起不来"的问题根源。

2.1 把 sapjco3 装进本地仓库:jar 与本地库两步走

拿到 jar 之后不要直接<scope>system</scope>挂在 pom 里,那样打出来的包在别的机器上一定缺文件。稳妥做法是先装进本地仓库或私服,让 Maven 按正常依赖解析:

# 把 sapjco3.jar 装进本地仓库,坐标自己定,版本按手上拿到的实际版本替换 3.x mvn install:install-file \ -Dfile=sapjco3.jar \ -DgroupId=com.sap.conn \ -DartifactId=sapjco3 \ -Dversion=3.x \ -Dpackaging=jar # 确认装进去了,能看到路径就说明成功 ls ~/.m2/repository/com/sap/conn/sapjco3/3.x/

pom 里再按普通依赖引入,同时把本地库目录做成可配置项:

<dependency> <groupId>com.sap.conn</groupId> <artifactId>sapjco3</artifactId> <version>3.x</version> </dependency>

装完之后还有第二步:本地库。Windows 下是sapjco3.dll,Linux 下是libsapjco3.so,它必须出现在 JVM 的java.library.path里。我一般会在项目下建一个native/目录,打包时随包分发,启动脚本里显式指定:

# 启动时把本地库目录挂进 java.library.path,注意是目录不是文件 java -Djava.library.path=/opt/app/native \ -jar sapweb.jar

这里有个容易被忽略的点:-Djava.library.path指向的是目录,不是.so文件本身;而且如果容器里同时存在 32 位和 64 位两个版本,JVM 会按架构挑选,版本对不上就直接UnsatisfiedLinkError。判断方法很简单,看报错信息里那句no sapjco3 in java.library.path后面跟的路径清单,路径对了还报错,基本就是位数或文件名不对。

2.2 五层职责切分与启动顺序

这个栈的组件不算多,但边界一旦划错,症状会非常迷惑。比如把 RFC 调用写进 Shiro 的 Realm 里,或者让 Quartz 的 Job 自己去 new 一个 Destination,最后都会变成"越跑越慢、连接数不释放"。我习惯按下面这张表切:

组件职责不该做的事初始化时机
Shiro登录、角色、按钮级权限、会话放业务查询、放 RFC 调用过滤器链最先
Controller参数校验、编排、返回视图或 JSON直接写 JCo 代码请求级
Service业务规则、事务边界管理连接池请求级,单例
JCo Destination连接复用、超时控制每请求 new 一次容器启动时单例
Quartz定时触发、任务持久化承载长事务容器启动后

顺序上有个硬约束:Shiro 的过滤器在请求进来时最先执行,所以任何跟 RFC 相关的初始化如果放在过滤器里,会在用户还没登录时就触发连接,白白占住连接池。正确做法是让 Destination 在 Spring 容器启动时初始化一次,用懒加载兜底,Quartz 则在容器完全就绪后再启动调度器,避免第一个触发点比数据源还早。

2.3 Destination 参数表:连接池、超时、语言、Trace

JCo 的连接参数通常写在属性文件或DestinationDataProvider里,字段名是固定的,写错一个就是连不上或者连上了秒断。常用的几个:

参数含义建议值踩坑点
jco.client.ashost应用服务器地址按实际填不要带协议头
jco.client.sysnr系统编号两位数字写成00不是0
jco.client.client集团号三位少一位会报登录失败
jco.client.lang登录语言ZH或EN影响返回文本的语言与编码
jco.destination.pool_capacity连接池上限5~10设太大把对方连接占满
jco.destination.peak_limit峰值上限不超过 pool_capacity 的 2 倍超过会被对端拒绝
jco.client.conn_timeout建连超时(毫秒)30000网络抖动时太短会大量失败

pool_capacity和peak_limit这两个参数值得单独说。很多人为了"性能好"直接调到几十,结果对方系统那边看到一堆空闲连接,运维直接找过来。这两个值是全局共享的,不是每个用户一份,所以按并发线程数估就行,一般 Web 请求并发 5、定时任务 2 的规模,配 5~10 足够。超时也别图省事全设成 0(无限等待),一个卡死的 RFC 会把 Tomcat 线程池里的线程一直占着,后面所有请求排队,表现出来就是"整站卡住但日志没报错"。

3. RFC 调用链路:从 JCoDestination 到 JCoTable 的完整取数过程

真正写代码的阶段,绝大部分时间花在三件事上:连接怎么建、参数怎么塞、结果怎么取。这三个动作在 JCo 里各有各的坑,尤其是表参数(JCoTable)的遍历方式,跟日常用 JDBC 取ResultSet的直觉完全不一样——它是个游标式的结构,nextRow()之前必须先判断有没有数据,而且取完一行要立刻读字段,不能缓存引用。

3.1 Destination 单例与异常分类

先把连接对象管起来。我一般用一个配置类把属性读进来,注册成DestinationDataProvider,这样整个应用里只有一个 Destination 实例,JCo 内部自己维护连接池:

@Configuration public class JCoConfig { @Value("${sap.ashost}") private String ashost; @Value("${sap.sysnr}") private String sysnr; @Value("${sap.client}") private String client; @Value("${sap.user}") private String user; @Value("${sap.passwd}") private String passwd; @PostConstruct public void register() { // 注册自定义的 Destination 提供者,全局只做一次 Environment.registerDestinationDataProvider(new DestinationDataProvider() { @Override public Properties getDestinationProperties(String name) { Properties p = new Properties(); p.setProperty(DestinationDataProvider.JCO_ASHOST, ashost); p.setProperty(DestinationDataProvider.JCO_SYSNR, sysnr); p.setProperty(DestinationDataProvider.JCO_CLIENT, client); p.setProperty(DestinationDataProvider.JCO_USER, user); p.setProperty(DestinationDataProvider.JCO_PASSWD, passwd); p.setProperty(DestinationDataProvider.JCO_LANG, "ZH"); p.setProperty(DestinationDataProvider.JCO_POOL_CAPACITY, "5"); p.setProperty(DestinationDataProvider.JCO_PEAK_LIMIT, "10"); return p; } @Override public boolean supportsEvents() { return false; } }); } }

参数说明上,JCO_LANG设成ZH会影响返回的文本字段语言,如果业务方要的是英文描述就改成EN,别在代码里做翻译。supportsEvents()返回 false 是因为不用 JCo 的服务端事件回调,开了反而多一条长连接。

获取连接时统一走一个工具方法,异常按JCoException的 group 和 key 分类处理,而不是笼统 catch:

public JCoDestination getDestination() throws JCoException { try { // 按注册时的名字取,JCo 会自己从池里借一个 return JCoDestinationManager.getDestination("SAP_PRD"); } catch (JCoException e) { // group=1 多为登录/网络问题,group=2 多为函数元数据问题 log.error("JCo 连接失败 group={} key={}", e.getGroup(), e.getKey(), e); throw e; } }

getGroup()和getKey()这两个值特别有用。group 为 1 的通常是连接层面(地址、账号、网络),group 为 2 的多是函数模块层面的(函数名写错、参数不存在)。线上排查时先看这两个数字,能把排查范围砍掉一半,不用每次都从头翻日志。

3.2 参数填充与 JCoTable 遍历

调用一个带表参数的 RFC,标准写法是这样的:

public List<MaterialDto> queryMaterials(String werks, String dateFrom) throws JCoException { JCoDestination dest = getDestination(); JCoFunction fn = dest.getRepository().getFunction("ZRFC_MATERIAL_QUERY"); if (fn == null) { throw new IllegalStateException("RFC 函数不存在或未激活"); } // 单值入参直接 setValue,类型由函数元数据决定,不要自己强转 fn.getImportParameterList().setValue("I_WERKS", werks); fn.getImportParameterList().setValue("I_DATE", dateFrom); fn.execute(dest); // 表参数必须先 getTable,再循环 nextRow JCoTable table = fn.getTableParameterList().getTable("T_MATERIAL"); List<MaterialDto> list = new ArrayList<>(table.getNumRows()); for (int i = 0; i < table.getNumRows(); i++) { table.setRow(i); MaterialDto d = new MaterialDto(); d.setMatnr(table.getString("MATNR").trim()); // 定长字段要 trim d.setMaktx(table.getString("MAKTX")); d.setMeins(table.getString("MEINS")); list.add(d); } return list; }

几个必须解释的点:第一,getFunction返回 null 通常意味着函数在对方系统里没激活或者名字大小写不对,直接给一个明确的业务异常比抛 NPE 好排查。第二,setRow(i)这种按索引定位的方式比nextRow()更适合需要分批处理或回溯的场景,两者别混用。第三,从 SAP 取回来的字符字段是定长的,MATNR之类的物料号后面会补空格,不 trim 直接入库,后面按主键查就永远查不到,这是最典型的"数据看着对、就是匹配不上"。

结果集超过几千行时不要一次性全部读进内存,可以按setRow分段处理,每 500 行落一次库,把内存和事务大小都压下来。

3.3 落库:MyBatis 批插与事务边界

从 RFC 拿到的数据通常要落到本地库做缓存或留痕。用 MyBatis 的批处理模式比循环单条插入快一个数量级:

@Service public class MaterialSyncService { @Autowired private SqlSessionFactory sqlSessionFactory; public int saveBatch(List<MaterialDto> list) { // ExecutorType.BATCH 让同一个 SqlSession 内的 insert 合并成批 try (SqlSession session = sqlSessionFactory.openSession(ExecutorType.BATCH, false)) { MaterialMapper mapper = session.getMapper(MaterialMapper.class); for (MaterialDto d : list) { mapper.insertOne(d); } session.commit(); return list.size(); } } }

对应 XML 里就是一条普通insert,不用写<foreach>:

<insert id="insertOne" parameterType="MaterialDto"> INSERT INTO t_material (matnr, maktx, meins, sync_time) VALUES (#{matnr}, #{maktx}, #{meins}, NOW()) </insert>

事务边界要卡在这一层,不要在循环里开事务。另外ExecutorType.BATCH下如果中途某条失败,报错信息只会告诉你"批处理中有错误",定位不到具体哪一行,所以批量落库前最好先校验主键非空,把脏数据挡在外面,否则排查成本很高。

3.4 页面侧:Thymeleaf 渲染加 JavaScript 轮询

RFC 调用动辄十几秒,不能让浏览器干等。常见做法是把调用丢给异步任务,页面返回一个任务号,前端用原生 JavaScript 轮询状态。不引入前端框架,反而少一层构建。

// 页面加载后每 2 秒查一次任务状态,成功或失败就停 function pollStatus(taskId) { const timer = setInterval(async () => { const resp = await fetch(`/api/task/${taskId}`, { credentials: 'same-origin' }); if (!resp.ok) { clearInterval(timer); return; } const data = await resp.json(); if (data.state === 'RUNNING') return; // 还在跑,继续等 clearInterval(timer); document.querySelector('#result').textContent = data.state === 'SUCCESS' ? `共 ${data.rows} 行` : `失败:${data.msg}`; }, 2000); }

credentials: 'same-origin'是关键,Shiro 的会话靠 Cookie 维持,不带凭据的 fetch 会被当成未登录,拿回来一个登录页 HTML,resp.json()直接抛解析异常,前端表现就是"一直在转圈"。轮询间隔不要小于 1 秒,否则并发上来以后日志表会被状态查询刷爆。

4. Quartz 2 定时调度:把 RFC 拉取做成能改 cron 的可配置作业

定时拉取是这个系统里最容易出事的部分。它同时踩在两个不稳定源上:一个是外部的 RFC 接口耗时不可控,一个是 Quartz 自己的持久化和并发语义。我见过太多项目在这一块写成"启动时注册一次、要改 cron 就改代码重启",交付之后业务方每次调时间都得找开发。

4.1 持久化表与 Spring Bean 注入

用 JDBC 存储方式(jobStore.class配成JobStoreTX)之后,Quartz 会往自己的 QRTZ_ 系列表里写任务定义和触发状态。这套表必须提前用官方脚本建好,不同数据库脚本不一样,直接拿 MySQL 的脚本往别的库上跑,启动时会在scheduler.start()处直接炸。

真正高频的坑是 Job 里注入为 null:

@Component @DisallowConcurrentExecution // 同一个 JobDetail 不允许并发执行 public class MaterialSyncJob extends QuartzJobBean { @Autowired private MaterialSyncService syncService; // 不加处理这里恒为 null @Override protected void executeInternal(JobExecutionContext ctx) { String werks = ctx.getMergedJobDataMap().getString("werks"); int rows = syncService.sync(werks); ctx.getJobDetail().getJobDataMap().put("lastRows", rows); } }

原因是 Quartz 默认用SimpleJobFactory自己 new 实例,不经过 Spring 容器。解决办法是自定义一个JobFactory,在创建实例后交给 Spring 做依赖注入,再通过SchedulerFactoryBean.setJobFactory()注册进去。这一步做完,@Autowired才生效。另一种更省事的写法是把业务逻辑全放到 Service 里,Job 只负责从JobDataMap取参数调用 Service,但注入问题依然要解决,绕不过去。

4.2 动态增删改与 cron 校验

业务方要能自己改执行时间,就得把 Scheduler 的 API 用起来:

public void reschedule(String jobName, String group, String cron) throws SchedulerException { Scheduler scheduler = schedulerFactoryBean.getScheduler(); JobKey key = JobKey.jobKey(jobName, group); // 先按旧 key 重设触发,不存在则新建 TriggerKey tk = TriggerKey.triggerKey(jobName + "_trigger", group); if (!scheduler.checkExists(tk)) { JobDetail jd = JobBuilder.newJob(MaterialSyncJob.class) .withIdentity(key).usingJobData("werks", "1000").storeDurably().build(); scheduler.addJob(jd, false); scheduler.scheduleJob(TriggerBuilder.newTrigger() .withIdentity(tk).forJob(key) .withSchedule(CronScheduleBuilder.cronSchedule(cron)).build()); } else { scheduler.rescheduleJob(tk, TriggerBuilder.newTrigger() .withIdentity(tk).forJob(key) .withSchedule(CronScheduleBuilder.cronSchedule(cron)).build()); } }

cronSchedule()本身会做语法校验,非法表达式抛RuntimeException,所以在 Controller 层要提前 catch 并返回明确提示,别让异常冒到页面上变成 500。另外storeDurably()不能少,否则任务在没有触发器的瞬间会被自动清理掉,下次改 cron 时找不到 JobDetail。

4.3 并发控制与任务日志

@DisallowConcurrentExecution只能保证同一个 JobDetail 不并发,如果两个不同的 JobDetail 打同一个 RFC 函数,还是会撞在一起。这时候要么在业务层加分布式锁,要么在数据库里放一张运行中标记表。我倾向于用一张任务日志表同时解决"防重"和"可观测"两件事:

CREATE TABLE t_job_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, job_name VARCHAR(64) NOT NULL, fire_time DATETIME NOT NULL, end_time DATETIME, state VARCHAR(16) NOT NULL, -- RUNNING / SUCCESS / FAILED rows_affected INT DEFAULT 0, err_msg VARCHAR(500), INDEX idx_job_state (job_name, state) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

执行前插一条RUNNING,结束更新状态,并在启动时检查有没有残留的RUNNING记录(说明上次进程被杀)。misfire 策略也要显式指定,默认的"智能策略"在服务停机几小时后恢复时会一次性补跑多次,把对方系统打挂,一般改成withMisfireHandlingInstructionDoNothing()更稳。

5. 排查清单:Shiro 会话、JCo 本地库、乱码、注入失效的五条血泪记录

下面这五条都是我在真实交付现场遇到过的,按"现象 → 原因 → 解决"记下来,出问题时可以按顺序对号入座。

第一条:本地能跑,服务器一启动就UnsatisfiedLinkError: no sapjco3 in java.library.path。现象是打包上传后启动脚本一跑就退出,日志里堆栈指向JCoDestinationManager的静态初始化。原因是 jar 打进去了但本地库没跟过去,或者启动脚本里的java.library.path还指向开发机路径。解决方式是把 native 目录一起打进发布包,启动参数用相对路径解析成绝对路径再传,同时在启动后打一行日志把System.getProperty("java.library.path")输出,确认目录存在且文件可读。

第二条:页面上一操作就跳回登录页,前端控制台报 JSON 解析失败。现象是点查询按钮后页面刷新成登录界面,或者轮询接口一直解析报错。原因是 Shiro 的过滤器链把/api/**也拦了,未登录时返回 302 重定向,Ajax 请求跟随重定向拿到的是登录页 HTML。解决办法是在过滤器链里对/api/**单独配置,未认证时返回 401 的 JSON 而不是重定向,前端的 fetch 里再按 401 做跳转判断。顺带把静态资源路径加进匿名访问列表,不然每次刷新都要过一次鉴权。

第三条:从 SAP 取回的中文变成问号,英文正常。现象是物料描述字段入库后全是???。原因通常是两处:一是 Destination 的jco.client.lang没设成ZH,对方按默认语言返回;二是本地库表的字符集是utf8而不是utf8mb4,遇到生僻字截断。排查顺序是先看 JCo 返回的原始字符串在日志里是否正常(正常说明问题在库),再查连接串里的characterEncoding和表的字符集。这类问题不做日志打印几乎不可能定位,所以关键字段的原始值一定要在 debug 级别打出来。

第四条:Quartz Job 里@Autowired的 Service 一直是 null,任务空跑。现象是任务按时触发、日志也有,但数据一条没变。原因是 Job 实例由 Quartz 的JobFactory反射创建,不走 Spring 的依赖注入。解决是自定义JobFactory继承SpringBeanJobFactory并重写createJobInstance,在返回实例前调用autowireBean,再把它设置到SchedulerFactoryBean上。改完之后记得重启并确认任务日志表里有新的记录,因为旧任务定义里存的是旧的类信息。

第五条:某个 RFC 卡住之后,整个站点的请求全部排队。现象是某一时刻起所有接口都超时,但 CPU 和内存都不高,线程 dump 里一堆线程停在 socket read。原因是 RFC 调用没有设置连接超时和读超时,一个慢查询把所有 Web 线程占满。解决分三层:Destination 层设conn_timeout,Service 层用带超时的线程池包裹调用,页面侧改成异步任务加轮询。最忌讳的是在 Controller 里同步调 RFC,一旦对方抖动,故障面立刻从"一个功能不可用"扩大到"整个系统不可用"。

6. 上线前自检:三十分钟跑通一条完整链路

交付前我固定跑一套自检,目的不是测试覆盖率达到多少,而是把"配置对不对"和"链路通不通"这两件事在业务方点之前先确认掉。第一步是加一个只对管理员开放的自检端点,直接调一个无参数的 RFC 函数(大多数系统都有类似ZRFC_PING或者STFC_CONNECTION的探活函数):

@GetMapping("/admin/rfc/ping") @RequiresRoles("ADMIN") public Map<String, Object> ping() { long t0 = System.currentTimeMillis(); Map<String, Object> r = new HashMap<>(); try { JCoDestination dest = JCoDestinationManager.getDestination("SAP_PRD"); JCoFunction fn = dest.getRepository().getFunction("STFC_CONNECTION"); fn.getImportParameterList().setValue("REQUTEXT", "ping"); fn.execute(dest); r.put("ok", true); r.put("echo", fn.getExportParameterList().getString("ECHOTEXT")); r.put("pool", dest.getPoolCapacity()); // 看池子上限是否生效 } catch (JCoException e) { r.put("ok", false); r.put("group", e.getGroup()); // 1=连接 2=函数元数据 r.put("key", e.getKey()); } r.put("costMs", System.currentTimeMillis() - t0); return r; }

这个端点能一次暴露四类问题:本地库是否加载(加载失败连类都进不去)、账号权限是否正确(group=1 直接返回)、函数是否存在(返回 null 会抛异常)、连接池参数是否生效(getPoolCapacity的返回值)。@RequiresRoles("ADMIN")别忘了加,探活接口暴露在外网等于给了一个免密的连接测试入口。

配合一张手工核对清单,把容易漏的配置项过一遍:

检查项验证方式期望结果
本地库加载启动日志 + 自检端点无 UnsatisfiedLinkError
Destination 参数自检端点返回的连接池值与配置文件一致
RFC 函数激活自检端点调用目标函数返回正常,耗时 < 3s
字符编码拉一条含中文的记录入库后中文完整
Quartz 持久化重启后查 QRTZ_ 表任务定义仍在
任务日志手动触发一次有 RUNNING → SUCCESS 记录
权限拦截未登录访问 /api/**返回 401 JSON 而非 302
超时保护断网后发起调用在超时时间内返回失败而非挂死

清单跑完,再让业务方用真实账号点一遍页面,重点看轮询是否在自己预期的秒数内结束、失败时页面上有没有可读的错误提示而不是一片空白。

说个我自己的教训。早前一个类似的项目,自检全过、业务方也验收了,结果上线第三天定时任务把对方系统的连接池占满,原因是peak_limit我按开发环境的并发估的,生产上 Web 请求和定时任务同时跑,实际峰值翻了三倍。从那以后我每次上线前都强制走一遍"峰值压测 + 任务日志回归",把 Web 并发和定时任务叠在一起跑一轮,看连接池的实际占用曲线,而不是只看功能通不通。这份资源真正的价值不在于把六个框架拼起来,而在于它把 RFC 连接、权限、调度、日志这几条链路的骨架都搭好了,你拿到手之后重点改的是配置参数和业务函数,而不是从零解决UnsatisfiedLinkError。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询