1. 为什么一个注解就能“自动生成”上传下载
在传统 ABAP 里做附件上传,我最怕的就是 MIME 仓库那套流程:先 SMW0 建对象,再写 RFC 把文件内容塞进数据库,最后前端还得单独处理二进制流、自己拼 HTTP 请求。到了 RAP 时代,事情变得简单得多——CDS 视图里的内容字段只要打上@Semantics.largeObject注解,OData 层就会把该属性暴露成Edm.Stream类型。SAP Fiori Elements 一看到这种类型,会自动渲染出上传按钮、下载链接和文件名展示,不需要写一行前端代码。
这个注解背后做的事情,其实是把“媒体对象”的概念带进了 RAP 业务对象。OData 里普通字段是放在 JSON/Atom payload 里传输的,而 Stream 类型字段走的是另一条通道:文件内容通过/$value这个特殊 endpoint 单独流式读写,不会塞进业务数据的主请求里。所以 Fiori Elements 才能对内容字段做出“上传附件”和“下载文件”的交互,而不是把它当成一个普通字符串文本框。
要启用这个能力,CDS 视图里通常需要三个字段协同工作:
| 字段用途 | CDS 类型建议 | 注解 | 说明 |
|---|---|---|---|
| 文件内容 | abap.string( 0 )或abap.rawstring( 0 ) | @Semantics.largeObject : true | 实际存文件流的字段,文本文件用 string,二进制用 rawstring |
| 文件名称 | abap.char( 255 ) | @Semantics.fileName : true | 下载时生成Content-Disposition的文件名 |
| MIME 类型 | abap.char( 100 ) | @Semantics.mimeType : true | 告诉浏览器/前端该文件是什么类型,例如application/pdf |
文字版说“三个字段协同”,实际项目里我通常再加一个文件大小字段,虽然它不是注解必须的,但列表展示、前后端校验都用得上。这里有个细节容易踩坑:文件内容字段的类型选择。如果你只传文本文件、XML、日志,abap.string( 0 )没问题;但如果业务上要传 PDF、图片、Office 文档,必须用abap.rawstring( 0 ),并且在 handler 里把内容当xstring处理。别想着用一个通用字段通吃所有文件类型,HANA 上 char 大对象和二进制大对象的存储方式完全不同,到时候乱码了排查成本很高。
数据库表的定义也要跟上。CDS 里用abap.rawstring( 0 ),对应表字段建议用RAWSTRING;用abap.string( 0 )则对应STRING。这两个类型在 HANA 上分别落成 BLOB 和 CLOB,容量足够承载几十上百 MB 的文件。如果哪一天你要存视频、大安装包这类几百 MB 甚至 GB 级的东西,建议别直接放业务表里,而是把文件丢对象存储,RAP 里只存下载 URL,这一条经验后面我还会再展开。
2. 行为定义:让后台“知道”这是流媒体对象
光在 CDS 视图上打注解还不够。RAP 是行为驱动的编程模型,你得在行为定义(Behavior Definition)里显式声明这个 BO 支持哪些流式操作,后台才知道 create / update / read 这三种场景下要怎么处理文件流。
行为定义里加下面这一段:
define behavior for ZI_Attachment alias Attachment persistent table zatt_file lock master authorization master ( instance ) { create; update; delete; stream create, update, read; }stream create表示允许上传新文件的流内容,stream update表示允许覆盖已有文件的流内容,stream read表示允许下载文件内容。三个都写上是最省事的,但如果业务上只允许上传不允许下载,那stream create和stream update留着、stream read去掉就行。
有几个选型问题在这里必须提前想清楚,不然后期返工很麻烦:
第一,要不要启用 draft?Fiori Elements 的标准 List Report 默认不强制 draft,但如果行为定义里写了draft table,前端编辑就会进入草稿机制。媒体对象加上 draft 之后,文件流会分两个版本存储:一份在激活表,一份在草稿表,stream handler 里也要区分草稿态和激活态,复杂度直接翻倍。我的建议是:第一版先做非 draft 的简单版本,把整条链路跑通,再考虑要不要加 draft。很多内部管理类页面上传附件之后直接提交保存,draft 并不是刚需。
第二,persistent table 还是 managed?我上面写的是带persistent table的 managed 实现方式,这也是最稳的。让 RAP 帮你管理字段映射,create/update操作自动落库,stream handler 只负责文件流本身的写入和读出。如果你用 unmanaged,所有读写都要手写 SQL,为了一个上传功能没必要。
第三,服务定义和服务绑定。行为定义写完还需要把它暴露到 OData 服务里:
- Service Definition 里加上
ZI_ATTACHMENT这个投影视图或者直接暴露 CDS 视图; - Service Binding 里绑定成 OData V2 / V4。Fiori Elements 两种协议都支持,不过我用 V4 多,处理流式请求时更干净。
绑定完之后,先别急着去前端预览,打开 metadata 看一眼。找到附件实体对应的属性定义,正常情况下content字段的类型应该显示为Edm.Stream。如果这里显示成了Edm.String,说明注解没生效,多半是 CDS 视图没激活,或者内容字段类型用错了。
3. Handler 实现与数据落库:create_stream / update_stream / read_stream 完整链路
行为定义只是声明,真正处理文件流的是 Behavior Implementation 里的三个 stream handler 方法。这里我给出一套我在项目里验证过的最小实现逻辑。
先说create_stream。这个方法的职责是:当 Fiori Elements 上传一个新附件时,接收文件流并把它写入数据库。RAP 的做法比较特别,它会把create行为(业务数据)和create_stream行为(文件流)分开触发:你先创建一条附件记录,比如存文件名、MIME 类型、业务关联的外键,然后再单独上传流内容。所以 handler 里做的事情通常是“根据 key 找到刚创建的那条记录,再把流内容更新进去”。
METHOD create_stream. DATA: ls_attach TYPE zatt_file. SELECT SINGLE * FROM zatt_file WHERE attachment_uuid = @is_info-key-attachment_uuid INTO @ls_attach. IF sy-subrc = 0. ls_attach-content = it_content. ls_attach-mime_type = is_info-mime_type. ls_attach-filename = is_info-filename. MODIFY zatt_file FROM @ls_attach. ENDIF. es_response = VALUE #( filename = is_info-filename mime_type = is_info-mime_type ). ENDMETHOD.注意,早期版本里 stream handler 的入参结构可能不是cl_rap_event_stream_create=>ty_create_info这种写法,不同 SPS 版本 API 差异挺大。你在 Eclipse 里写方法时,直接用 FOR STREAM CREATE 的模板生成器,让它帮你把参数结构带出来,别去手抄网上的老代码。IT_CONTENT 就是文件二进制内容,类型是xstring,无论你 CDS 里用的是 string 还是 rawstring,handler 里拿到手都是字节流。
update_stream的逻辑和 create_stream 基本一致,区别只是它针对已存在的记录做覆盖。这里有一个实战技巧:当用户在 Fiori Elements 里“重新上传”一个附件时,前端可能只发文件流,不发业务字段。所以 update handler 里不要先读前端传过来的文件名,而是直接读数据库里已有记录,把新流内容写进去,同时保留旧文件名;然后根据前端是否带了新的 filename 再决定要不要更新文件名字段。
read_stream是下载链路的核心,写得不好会出现“能上传不能下载”的怪问题:
METHOD read_stream. SELECT SINGLE filename, mime_type, content FROM zatt_file WHERE attachment_uuid = @is_info-key-attachment_uuid INTO @DATA(ls_attach). IF sy-subrc = 0. es_response = VALUE #( content = ls_attach-content filename = ls_attach-filename mime_type = ls_attach-mime_type ). ENDIF. ENDMETHOD.这个方法本身很简单,但有几个细节必须注意:
文件名编码。如果文件名里有中文、空格或特殊字符,下载时浏览器经常出现乱码或截断。我建议在read_stream里不要只返回 filename 字段,还要检查网关侧的Content-Disposition处理方式。SAP BTP 环境通常没问题,但 On-Prem 网关如果做了额外代理,文件名要看是否走了 RFC 5987 编码。实在不行,就在前端拿到下载 URL 后用encodeURIComponent再做一次编码,双保险。
大文件的内存问题。xstring是一次性把整个文件读进内存的,几十 MB 没问题,几百 MB 就会比较吃紧。上传大文件时,网关默认请求体大小也有限制,这里等于是双重瓶颈。如果业务确定有大文件场景,我建议在 CDS 视图里直接存对象存储的 URL,而不是文件内容本身,然后单独做一个下载跳转接口,别让 RAP 去缓冲大文件流。
业务数据与流内容的原子性。这句话可能听起来很抽象,但实际场景很现实:用户点了上传,前端先创建附件记录,再传文件流。如果此时流传输失败,业务表里可能留下一条“有文件名没内容”的脏记录。我现在的处理习惯是,在 create 行为里先不提交最终状态,把附件记录状态标成“处理中”,等create_stream成功后才把状态改成“已完成”;前端查询列表时过滤掉“处理中”的数据。虽然没有完美的事务保证,但从用户体验上能避免一大半脏数据问题。
4. Fiori Elements 侧看到的界面与效果
当你把 OData 服务绑定好、metadata 里 content 属性确实变成了Edm.Stream,Fiori Elements 会怎么表现?直接说结论:List Report 和 Object Page 都不用写自定义扩展,标准功能就能覆盖大部分上传下载需求。
在 List Report 的表格里,如果 CDS 视图给 filename 字段加了@UI.lineItem注解,这一列会自动渲染成文件名,并且在行尾出现下载图标。单击图标触发read_stream,下载逻辑就走通了。这里要注意,不要把 content 字段加到lineItem或identification里,它已经是 Stream 类型,塞到表格里会让前端解析出问题。经验做法是让 content 字段在 UI 上保持隐藏,只展示 filename 和 mime_type。
在 Object Page 的编辑界面,Fiori Elements 会根据 Stream 字段自动生成文件上传控件。用户选择一个文件后,前端先把文件暂存,等用户点了保存按钮,RAP 行为才真正触发 create / create_stream。这个交互模式和传统 ABAP 那种“选完立刻传”不一样,刚上手的人可能会觉得“怎么点了没反应”,其实是保存后才提交。
如果你用的是 SAP Fiori Tools 或者 Business Application Studio 做本地预览,可以直接用预览服务跑这个应用,不需要额外做 UI 配置。但有一个前提:预览工程里要配置好 destination 指向你的后端服务,否则前端根本拿不到 metadata。我见过不少人卡在这一步,明明后端 SAp 都通了,页面却一直白屏,最后发现是 Fiori Tools 的 mta.yaml 里 destination 没配上。
前端的请求路径也值得看一眼。上传时,Fiori Elements 会先发一个 POST 创建业务数据,再发一个 PUT 或 POST 到类似/ZI_ATTACHMENT(Key)/content/$value的 endpoint 传文件流。下载时则是 GET/ZI_ATTACHMENT(Key)/content/$value。你可以在浏览器的 Network 面板里看到这两个请求,排错的时候特别有用。尤其是 CSRF token——Fiori Elements 会自动带上x-csrf-token头,但如果你拿 Postman 直接调接口,必须先 GET 一次/$metadata拿 token,再带着 token 去上传或下载,否则网关直接 403。这个坑我在测试接口时踩了好几次。
还有一个和 UI 相关的体验细节:如果你的业务场景是“只下载不上传”,比如只读展示某个合同附件的原文,那就别在行为定义里加stream create/update,只保留stream read。反而这样前端不会出现编辑按钮,整个页面也更干净,权限上也更安全。
5. 我踩过的 stream 坑:断流、超时与并发问题
写 RAP 流式功能的那段时间,我几乎把网上常见的stream disconnected类报错都撞了一遍。分享几个典型场景和我的排查链路,比直接贴报错有用得多。
场景一:上传到一半报stream disconnected before completion: stream closed before response.completed。
这个错误出现在大文件上传场景,症状是前端传了十几秒后请求中断,后端日志里没有明显的 ABAP dump。排查链路是这样的:先看是谁断开的流——如果是前端浏览器,多半是用户手动取消了或网络抖动;如果是网关层断的,要看 Web Dispatcher 或负载均衡的请求超时配置。SAP BTP 环境下,Cloud Connector 和 Destination 的超时时间默认值并不高,大文件上传容易被中间层超时掐断。我的处理方式是:能改配置就调大超时时间;不能改的就限制上传文件大小,比如前端加上传前校验,超过 50MB 直接提示不让传。与其让用户在十几秒后看到失败,不如一开始就拦住。
场景二:报transport error: network error,后台日志甚至出现error decoding response body。
这类报错往往是跨网络域调用导致的,比如 Fiori 前端在 BTP Cloud Foundry,后端 ABAP 环境在 On-Prem,中间走了 Cloud Connector。上传文件流经过多个跳转,任何一跳网络抖动都可能让响应体解析失败。我建议你先用 Postman 或 curl 直接打后端的$valueendpoint,绕开前端框架,看裸接口通不通。如果裸接口没问题,问题基本出在网络链路或前面的代理上;如果裸接口也断,那就是后端 stream handler 的问题,去查 ABAP 应用日志里的异常。
场景三:并发更新同一条附件记录,后一个请求覆盖了前一个。
RAP 默认有锁机制,lock master声明就是干这个的。但在非 draft 模式下,前端上传和编辑业务数据是两个独立请求,锁的持有时间很短。实际项目里出现过两个用户同时给一条订单补传附件,后上传的覆盖了先上传的。我的做法是:附件记录里加一个版本号或者last_changed_at字段,update 时用 SQL 条件更新判断版本号,更新行数为 0 就说明版本冲突,直接给前端抛业务错误。听起来老套,但在这个场景下就是最有效的兜底。
场景四:stream disconnected before completion: 由于目标计算机积极拒绝,无法连接。
这个报错最初是我在自己电脑上测试时遇到的。字面意思是连接目标时被拒绝,通常不是 ABAP 逻辑的问题,而是后端服务没起来、端口不通,或者 SAP Fiori Tools 的预览服务指向了错误的 destination。别傻乎乎去调 stream handler,先检查服务绑定有没有激活、Gateway 能不能 ping 通,再去看代码。
场景五:OData 响应里 stream 字段总是空。
这是纯注解问题。我遇到过 CDS 视图里 content 字段类型用了abap.string( 255 )这种定长类型,结果 metadata 里 content 被映射成Edm.String,Fiori Elements 根本不生成上传控件。后来改成abap.string( 0 )重新激活才正常。检查步骤其实很简单:看 metadata 里该属性是不是Edm.Stream,不是就赶紧排查字段类型和注解。
最后分享一个我留下的习惯:每次配好一个 RAP 流式对象,我都会先用 Postman 完整打一遍“创建业务数据 - 上传流 - 下载流 - 覆盖流”四条链路,确认无误后再让前端对接。这样把“网络问题”和“业务代码问题”彻底分开,后面排查效率能提高不少。文件上传下载这种功能,代码本身并不复杂,复杂的是链路里的各种边界条件。把@Semantics.largeObject这条核心链路摸透了,以后遇到图片预览、PDF 在线预览、附件替换这些变体需求,都是在这套骨架上加加减减的事。