☰
RAP文件上传实战:Stream设计、行为实现与Fiori对接
2026/10/14 8:01:53 网站建设 项目流程

1. 先说清楚:为什么文件上传在 RAP 里这么“别扭”

在 SAP BTP 上做 RAP(ABAP RESTful Application Programming)开发,只要业务对象涉及附件、导入、图片,迟早都会遇到文件上传的需求。我刚接手第一个 RAP 项目时,产品经理丢过来一个需求:要给“供应商主数据”加一个功能,既能批量导入 Excel,又能在供应商档案里挂几张现场照片。第一反应是这有什么难的?文件上传嘛,老 ABAP 里 FM 一把梭。结果真动手才发现,RAP 的“一切皆 BO”理念在文件面前一点都不友好——传统 Upload 逻辑压根塞不进去。

RAP 的核心是业务对象,我们看到的是 CDS 视图、行为定义、OData 服务,但文件不是结构化数据,它是一坨字节流。你没法在 CDS 视图里写一个字段叫“文件内容”,然后让 EML 语句直接 SELECT 出来——至少在标准能力里做不到。真正要做的,是把 Stream(字节流)作为一种“业务对象的附带资源”挂到实体上,让它和普通字段一样被 OData 层识别、被前端消费。

这篇文章,我把当时从零摸索到最终落地的全过程拆开说:数据模型怎么设计,行为定义怎么写,Excel 和图片两种典型文件怎么进入 BO,前端 Fiori 怎么配合,以及我踩过的那些 stream disconnected 坑。适用对象是已经会用 CDS + Behavior Definition 做基础 CRUD、但还没碰过文件能力的人。

2. 整体设计:用“三个视图 + 一个行为”把文件装进 BO

2.1 数据模型设计:Stream 表住哪里

先说结论:RAP 的文件上传,本质是给已有的 BO 节点增加一个“介质对象”,也就是 Stream 内容。以我的供应商场景为例,供应商主数据根节点是I_Supplier(自定义 CDSZR_SUPPLIER),我在设计时直接在根节点上关联了一个附件实体ZR_SUPPLIER_ATTACH。

关键点在于,RAP 里的 Stream 不是一个简单的“CLOB/BLOB 字段”,而是独立的“流节点”。在 CDS 定义里,它长这样:

define root view entity ZR_SUPPLIER_ROOT as select from zsupplier composition [0..*] of ZR_SUPPLIER_ATTACH as _attach { key supplier_id, supplier_name, _attach } define view entity ZR_SUPPLIER_ATTACH as select from zsupplier_attach { key supplier_id, key file_id, file_name, file_type, /* MIME 类型 */ file_size, content /* xstring,实际流内容存放字段 */ }

可以注意到,content字段类型是xstring,这就是 Stream 的载体。但如果你以为把content像普通字段一样暴露出去就够了,那就踩坑了——在行为定义里,必须显式声明这个字段是 Stream,前端 OData 才会把它当作二进制流发送,而不是 Base64 字符串塞在 JSON 里。

注意:不要在 CDS 里把所有附件相关字段都塞到主表。我最初图省事,把 supplier 表和附件表合并成一个视图,结果行为定义里 Stream 和普通字段互相打架,EML 操作时常报“Stream data not allowed for non-stream entity”。拆成 composition 子节点,逻辑清晰,行为实现也干净。

2.2 行为定义:Upload / Download 的能力声明

数据模型定了,接下来是行为定义。RAP 通过 Behavior Definition 里的stream关键字来声明“哪些字段可以被流式操作”。拿我的附件节点举例:

define behavior for ZR_SUPPLIER_ATTACH alias attach persistent table zsupplier_attach draft table zsupplier_attach_d lock master authorization master ( instance ) etag master field etag { field ( numbering : managed ) supplier_id, file_id; field ( readonly ) supplier_name; create; update; delete; stream function file_upload for file upload; stream function file_download for file download; stream function file_import for file import; stream function file_export for file export; mapping for zsupplier_attach { supplier_id = supplier_id; file_id = file_id; } }

这里的设计意图要展开说:

  • stream function file_upload for file upload:声明该实体支持“上传”能力,OData 层会生成一个POST /SupplierAttach/$value之类的端点,前端把文件二进制 POST 上来,框架自动调用行为实现里的update_stream方法。
  • stream function file_download for file download:对应GET .../$value,前端拿到字节流渲染。
  • file_import/file_export:这是较新版本(2302 之后)提供的“批量导入导出”能力,注意,这两个并不是把二进制塞进去,而是配合 Excel 模板做结构化导入导出。如果你的目标是“把 Excel 文件本体存起来”,用file_upload;如果目标是“解析 Excel 内容并落库”,那么 RAP 提供的 import 能力实际是把 Excel 的每一行映射到 BO 的节点实例上,走的是另一条链路。

我首次实现时,看到文档上 file import/export 眼前一亮,以为能用它直接解析 Excel,结果它要求前端使用 SAP 提供的特定上传组件,传的是“带模板的 OData 数据载荷”,根本不是原始 xlsx 文件。所以最终我的方案是:file_upload负责把原始文件接入,文件类型由后端校验;文件“内容解析”放到行为实现内部做。

2.3 为什么必须启用草稿(Draft)能力

如果你做的是 Fiori Elements 应用,那 Draft 是绕不开的。RAP 文件上传有个特别坑的地方:文件和主数据是分开提交的。用户在界面选好 Excel、填完供应商名称,如果不启用 Draft,那么用户点了“保存”,整个过程要先把主数据写入活动表,再写入附件——如果中间网络断了,数据状态就容易不一致。而 Draft 机制可以保证:文件上传先落到草稿表的 Stream 字段,等主数据整体保存时才一起激活。

所以我的行为定义里,根节点ZR_SUPPLIER_ROOT也声明了 draft:

define behavior for ZR_SUPPLIER_ROOT alias Supplier persistent table zsupplier draft table zsupplier_d lock master etag master field etag { field ( numbering : managed ) supplier_id; field ( mandatory ) supplier_name; create; update; delete; association _attach { create; } mapping for zsupplier { supplier_id = supplier_id; supplier_name = supplier_name; } }

Draft 启用后,文件上传到草稿代理(draft 副本)时不会阻塞主数据并发。有个小细节:启用 draft 后,etag字段我建议必须加——否则上传后再次 GET 文件,会碰到“entity tag mismatch”的并发问题。

3. 代码实现:get_stream / update_stream 的完整落地

文件上传能力的落地,绕不开四个行为方法:update_stream、get_stream、create_import、create_export。作为核心实现,我把代码完整贴出来,逐步讲。

3.1 update_stream:Excel 与图片的进入通道

在行为实现类里,最核心的是update_stream方法。它接收前端 POST 上来的二进制内容,以及可选的CONTENT-TYPE头信息,然后我们把数据写入业务表的content字段。

METHOD update_stream FOR UPDATE stream /attach/file_upload. DATA(ls_attach) = VALUE zsupplier_attach( ). DATA(lv_type) = io_stream->get_content_type( ). " 通过 key 从持久化表取当前记录 READ ENTITIES OF zr_supplier_root IN LOCAL MODE ENTITY attach FROM VALUE #( ( supplier_id = supplier_id file_id = file_id ) ) RESULT DATA(lt_existing) FAILED DATA(lt_failed). IF lt_failed IS NOT INITIAL. " 当附件记录不存在时,由上传动作隐式创建 INSERT INTO zsupplier_attach VALUES ( VALUE #( supplier_id = supplier_id file_id = file_id ) ). ENDIF. " 用 Stream 对象读取完整二进制 DATA(lv_content) = io_stream->get_stream_data( ). " 大小与类型校验(细节见 3.3) lv_type = to_lower( lv_type ). IF lv_type NOT IN ( 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' 'image/png' 'image/jpeg' ). RAISE EXCEPTION TYPE /dmo/cx_stream_error EXPORTING text = 'Unsupported file type'. ENDIF. MODIFY ENTITIES OF zr_supplier_root IN LOCAL MODE ENTITY attach UPDATE FIELDS ( content file_type file_size file_name ) WITH VALUE #( ( supplier_id = supplier_id file_id = file_id content = lv_content file_type = lv_type file_size = xstrlen( lv_content ) ) ) FAILED DATA(lt_fail) REPORTED DATA(lt_report). " 若流内容异常为空,RAP 框架会在这里补一个“流被中断”错误 IF lt_fail IS NOT INITIAL OR xstrlen( lv_content ) = 0. RAISE EXCEPTION TYPE /dmo/cx_stream_error EXPORTING text = 'Stream is empty or update failed'. ENDIF. ENDMETHOD.

有几个细节值得单独说。第一,io_stream->get_stream_data( )会把整个请求体一次性读进内存。Excel 还好,图片也还行,但如果将来要支持视频或大文件,这方法就扛不住了——那时候需要改成分段读取,比如每 4MB 读一段,循环写入磁盘或数据库。到目前为止,我的业务场景最大只有 20MB 的 Excel,一次性读没问题,但必须设置 OData 请求大小上限。

第二,READ ENTITIES和MODIFY ENTITIES我用了IN LOCAL MODE,这是 RAP 实现里的固有要求——行为实现内部操作同一个 BO 时,如果不加 LOCAL MODE,框架会认为你要嵌套调用 OData 服务,容易触发循环调用错误。

第三,异常处理。/dmo/cx_stream_error是我自定义的全局异常类,继承自CX_STATIC_CHECK。RAP 行为方法里可以简单RAISE EXCEPTION,但建议定义专门异常类,这样日志里能看到具体原因,而不是笼统一句CX_ABAP_RUNTIME_ERROR。

3.2 get_stream:给前端和报表一个统一出口

上传解决了,读取自然要对等实现。get_stream方法把存储的 xstring 转成流对象返回。听起来简单,但有个容易犯错的点:图片和 Excel 的 HTTP 响应头不一样。

METHOD get_stream FOR READ stream /attach/file_download. READ ENTITIES OF zr_supplier_root IN LOCAL MODE ENTITY attach FROM VALUE #( ( supplier_id = supplier_id file_id = file_id ) ) RESULT DATA(lt_data) FAILED DATA(lt_failed). IF lt_failed IS NOT INITIAL OR lt_data IS INITIAL. RAISE EXCEPTION TYPE /dmo/cx_stream_error EXPORTING text = 'Attachment not found'. ENDIF. " 关键一步:设置 Content-Type DATA(lv_type) = to_lower( lt_data[ 1 ]-file_type ). CASE lv_type. WHEN 'image/png'. result_content_type = 'image/png'. WHEN 'image/jpeg'. result_content_type = 'image/jpeg'. WHEN 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'. result_content_type = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'. WHEN OTHERS. result_content_type = 'application/octet-stream'. ENDCASE. " 关键一步:把你的实体数据写回 response result_stream = io_stream->get_instance( ). result_stream->set_stream_data( lt_data[ 1 ]-content ). " 文件名通过响应头带出去,前端才能正确触发下载 io_stream->set_property( name = 'Content-Disposition' value = |attachment; filename="{ lt_data[ 1 ]-file_name }"| ). ENDMETHOD.

这里有个容易被忽略的问题:文件名。如果只是设置 Content-Type 而忘了 Content-Disposition,浏览器会直接尝试在页面里打开 Excel,而不是下载。我第二次联调时碰到这个问题,前端同事说“你这是个裸流,没有文件名,Fiori 控件根本不知道保存成什么名字”。加了这个响应头才解决。

另外,get_stream方法在 draft 和 active 两种状态下都要处理。如果用户编辑了草稿、上传了新文件,但还没保存激活,那么 GET 请求必须能访问到 draft 表里的流内容。因此上述代码里READ ENTITIES默认的行为是“只读活动数据”,需要额外在前面加DATA(...) IN LOCAL MODE前检查 draft 状态。我实际会先试读活动,读不到再试 draft,用IN LOCAL MODE。这段逻辑虽然丑,但胜在稳定。

3.3 文件内容校验:类型、大小、文件名,一个都不能少

文件上传最容易出问题的不是写入,而是校验。一开始我只校验了后缀名,后来发现用户改个后缀就绕过限制了。做文件上传,必须校验的是 MIME 类型 + 文件签名(magic bytes),同时配合文件大小限制。

MIME 类型校验上面代码里已经写了,但它有个隐患:前端 Content-Type 字段是可以伪造的,用户用 Postman 手动改一下就能绕过。所以我加了一个“文件签名”校验,用文件的前几个字节判断真实格式:

METHOD validate_file_signature. DATA(lv_hex) = lv_content+0(8). " 取前8字节转hex " xlsx 的 zip 签名是 50 4B 03 04 " PNG 固定签名 89 50 4E 47 " JPEG 以 FF D8 FF 开头 IF lv_hex NOT CP '504B0304*' AND lv_hex NOT CP '89504E47*' AND lv_hex NOT CP 'FFD8FF*'. RAISE EXCEPTION TYPE /dmo/cx_stream_error EXPORTING text = 'Real file type does not match extension'. ENDIF. ENDMETHOD.

大小限制更简单但也更关键。如果用户上传一个 5GB 的“Excel”,xstring 撑爆内存不说,数据库可能直接TSV_TNEW_PAGE_ALLOC_FAILED。我在 BO 行为实现里做了大小判断,xstrlen( content ) > 20 * 1024 * 1024就拒绝。同时还要在 Gateway service 的maintain_sap_communication_scenario或builder里设置 OData 请求最大体量。

表驱动校验也可以做:建一张配置表zfile_type_config,key 是 MIME 类型,value 是允许的最大字节数。这样业务人员可以自己调限额,不用每次开发介入。测下来挺好用,尤其是图片可以 2MB、Excel 可以 20MB,各自独立配置。

4. 前端对接:Fiori 上传控件与 OData Stream 的握手

4.1 上传控件怎么绑定到 Deep Entity

后端能力全就绪,前端不配合也是白搭。Fiori Elements 的标准 FileUpload 控件要绑定到 OData 模型的一个实体属性上——但这个属性在 RAP 的 OData 暴露里,其实对应的是 Stream 的$value端点。在 annotation 里这样声明:

<Annotations Target="SAP.ZR_SUPPLIER_ROOT.Attach"> <Annotation Term="com.sap.vocabularies.UI.v1.Identification"> <Record Type="com.sap.vocabularies.UI.v1.DataField"> <PropertyValue Property="Value" Path="file_content"/> </Record> </Annotation> </Annotations>

前端拿到file_content,会自动感知到这个字段是流字段(因为 metadata 里它有application/octet-stream的 EDM 类型定义),然后走二进制 POST。

更准确的集成方式,其实是后端 OData 暴露file_upload函数后,前端的sap.m.UploadCollection控件的uploadUrl直接指向/sap/opu/odata4/sap/zsupplier_srv/srvd_a2x/sap/zr_supplier_root/0001/Supplier(%27SUP001%27)/_Attach/$value。这样控件自带的文件选择、进度条都可用,也很省心。

4.2 Excel 的“二次处理”与图片的 Metadata 存储

上传只是一个通道。Excel 上传后通常要做两件事:存原始文件、解析内容落库。这两件事建议拆开。我一开始把解析逻辑写在update_stream里,导致上传慢、异常多——用户上传一个格式不对的 Excel,整个文件状态都坏了。

后来改成:update_stream只保存原始文件,前端再调用一次常规的 actionparse_excel_and_create_lines,让后端去读刚才存的 xstring,解析并落库。

用 ABAP 解析 xlsx,最稳定的路径是先把content字段写到一个临时文件,再用CL_XLSX_READER读。直接内存读也能行,但遇到大文件容易崩。

图片的处理不一样——图片基本不需要“解析”,只需要存储 + 展示。但图片有个 Metadata 问题:用户上传时可能填了“图片描述”。我的做法是在附件表里增加description字段,前端上传控件里加一个 input 框,上传时一并提交。这样图片文件和它的业务元数据在同一个 BO 里,查询、展示、权限控制都能统一走 RAP 行为。

5. 常见问题与排查心得:那些 stream disconnected 背后来

5.1 问题速查表

整理一下我在开发过程中遇到的高频异常:

现象根本原因解决方案
STREAM_DISCONNECTED_BEFORE_COMPLETION上游数据流(客户端到服务器)在读取完之前断开,常见于上传大文件超时/网络抖动调大 Gateway 的请求超时;检查是否有反向代理夹在中间(代理默认只读前 1MB 就转发,也会中断流)
STREAM_DISCONNECTED_BEFORE_COMPLETION: TRANSPORT_ERROR后端处理耗时太久,前端连接被空闲超时关闭后端尽量异步化:先落临时表立即返回“已接收”,后台 Job 解析后再更新状态
FILE_CONFLICTetag 不一致导致并发冲突,用户同时打开两个标签页操作同一供应商在 BO 上正确实现etag master field,并在前端关闭自动保存,让用户在显式“保存”时才提交
上传后 Excel 打开提示“文件损坏”前端把二进制内容错误地 Base64 编码进 JSON,而不是走$value确保 metadata 里字段以 stream 类型暴露,且前端uploadUrl指向$value,不要走普通实体 POST
上传成功但下载是空文件get_stream返回了 POST 请求的缓冲区,而非数据库内容检查行为实现是否调用了get_stream_data()并把结果写入独立的 xstring 变量,避免引用传递
MIME 类型永远都是application/octet-stream网关层未透传 Content-Type,或未在行为实现中读取确认 OData service 的配置里content-Type使用text/plain之类时是否做了转换,优先在行为实现中用io_stream->get_content_type( )
上传 10MB 以上文件报内存溢出一次性把所有流读进 xstring,内存不足常规做法:升级内存设置;更优做法:段式读取,分段写数据库/临时文件

5.2 实测体会:踩坑记录与建议

以上最让我头疼的就是stream disconnected before completion这个报错。我一开始以为是代码问题,反复看 update_stream,但代码逻辑没问题。后来发现是 SAP BTP 环境里有个内嵌的反向代理,它默认在“客户端停止发送数据超过某个 idle 时间”时直接掐断连接。解决方式是前端在UploadCollection里设置了maxFileSize并且加了超时重试,同时后端把大 Excel 的解析从上传链路里摘干净。现在上传链路基本只负责“接流、校验、落库”,解析另起异步作业。

还有一点值得特别提醒:不要用 Base64 传文件。不少从老 UI5 转过来的同事,习惯把FileReader.readAsDataURL的结果塞进 JSON,结果到 RAP 这边就各种不兼容。RAP 的 Stream 能力是为“二进制直接 POST 到$value”设计的,这也符合 OData V4 规范。前端上传控件选型时,优先选支持uploadUrl指向流端点的组件。

最后分享一个我现在的做法:把 Stream 上传单独封装成一个“附件微服务”BO——任何业务对象需要挂文件,直接associate到它,而不用每个业务对象都重复实现一遍 stream 行为。复用性高,维护成本低,新业务接入只需写一个 association,半天就能跑通。

回到最初那个供应商上传需求,现在用户能直接在 Fiori 里拖入 Excel,然后在同一界面填表单保存,Excel 原文件被挂到供应商底下;点开图片缩略图,后端get_stream把 PNG/JPEG 流返回,前端一次渲染到位。整个过程不用再走“下载到本地再手动导入”的弯路,文件真正成了业务对象的一部分。这套经验放到其他 S/4 扩展项目里也完全通用——可以说,摸清了 Stream 在 RAP 里的脾气,以后做任何附件类需求都不慌了。

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

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

立即咨询