SpringBoot整合OnlyOffice
SpringBoot整合OnlyOffice实现在线编辑
公司有一个需求,就是实现 *Word* , *Excel* ,等文件的在线编辑,市场上面进行了多方面的选型,考虑了 *[OpenOffice](https://openoffice.apache.org/)* , *[Office Online](https://www.microsoft.com/zh-cn/microsoft-365/free-office-online-for-the-web?legRedir=true&CorrelationId=13c8a865-b9b0-48ff-b3ed-3ea9ec31cd55)*, 但是最终还是选择了 *[OnlyOffice](https://www.onlyoffice.com/zh/)* 这个产品。
他的一个很大的优势在于开源,支持协同,社区比较活跃。api比较全面,还有中文的文档。还有一点比较好的就是支持协同,并且支持协同,虽然协同在社区版中存在限制,但是支持代码修改,可以重新编译。社区的大佬很多,很赞。唯一遗憾的就是效率比较低,在使用私有对象存储的时候存在延迟。其他的没有使用到,所以不进行评论。中文文档:[https://api.onlyoffice.com/zh/editors/basic](https://api.onlyoffice.com/zh/editors/basic)
1. 搭建私有的OnlyOffice的服务
搭建过程这里就不进行涉猎了,建议使用docker进行搭建,下载官方镜像包即可,(现在dockerhub被墙,自行解决,不建议自己再次打包,因为我在尝试的时候总是出现莫名奇妙的问题可能是我的问题。推荐使用官网原版镜像)。根据官方文档一步步操作即可。搭建过程中,如果是自己玩建议不要开启 **JWT** ,生产环境建议开一下。但是开的成本就是你对接的时候需要获取token然后在进行交互。
2. SpringBoot进行交互
2.1 环境
java: 17
boot: 3.0.5
页面:一个h5页面即可
需要的其他依赖
<!-- ... 其他的依赖自行添加即可,不重要,比如 fastjson2,jackson 等 -->
<!-- 这个JAR 主要的作用是与OnlyOffice交互的时候生成token使用的 -->
<dependency>
<groupId>com.inversoft</groupId>
<artifactId>prime-jwt</artifactId>
<version>1.3.1</version>
</dependency>
2.2 我们的流程
我们使用一个 H5 页面即可,页面通过加载一个 app.js 。然后通过一个 config 进行渲染,就可以实现一个编辑。app.js 是核心js文件
- only office 我只使用他的一个编辑的功能(这是一个核心,就是编辑文件,文件的来源和存储与它无关)
- 被编辑的文件从哪里获取?从 config 对象中的配置获取,这里就需要自行实现。
- 编辑后的文件如何获取?config对象中有一个回调地址,这个地址会给到服务器一个编辑的状态,并且携带一个获取编辑后文件的url(这个url就是only office 服务中的一个文件下载地址),根据这个url来获取编辑后的文件。然后在对这个文件进行存储。
回调的实现参考:https://api.onlyoffice.com/zh/editors/callback#status
2.3 接口规划
一共设计三个接口,
- 获取编辑器的配置
- 获取需要编辑的文件流
- 编辑后保存文件的回调
保存后的文件:注意,这里编辑后的文件并不是在回调里面以流的形式给,而是在回调接口里面给服务器一个状态,根据状态去获取一个下载编辑后文件的一个地址,然后根据地址去主动的获取文件。
2.3.1 获取编辑器配置的接口
/**
* 被编辑文件的下载连接
* 这里就是自己服务的配置地址
* only office 调用你的服务的地址,一定是 onlyoffice服务可以ping通的你的项目地址。ping不通=白搭
*/
@Value("${only.office.downUrl}")
private String downFileUrl = "";
/**
* 这里是回调地址:例如 http://192.168.0.10:8080/office/edit/callback/{fileId}
* 自行定义即可(就是后面自己编写的接口,但是一定要通可onlyoffice服务互通)
* only office 调用你的服务的地址,一定是 onlyoffice服务可以ping通的你的项目地址。ping不通=白搭
*/
@Value("${only.office.callBackUrl}")
private String editCallBackUrl = "";
@Operation(summary = "根据文件的ID来获取在线编辑的配置和token")
@PostMapping("/token/{fileId}")
@Parameters({
@Parameter(name = "fileId", description = "不是对象ID是文件的ID", in = ParameterIn.PATH)
})
public ResultVo<?> getToken(@PathVariable String fileId) {
String fileKey ;
if (redisUtil.hHasKey(RedisName.ONLY_OFFICE_FILE_KYE,fileId)) {
fileKey = redisUtil.hget(RedisName.ONLY_OFFICE_FILE_KYE,fileId).toString();
//return ResultVo.error(CustomExceptionType.ONLY_OFFICE_COORDINATION_ERROR);
}else{
fileKey = fileId + RandomUtil.randomNumbers(10);
}
String json = """
{
"document": {
"title": "%s",
"key": "%s",
"fileType":"%s",
"lang": "zh-CN",
"permissions": {
"comment": true,
"commentGroups": {
"edit": ["Group2", "Group1"],
"remove": [""],
"view": ""
},
"copy": true,
"deleteCommentAuthorOnly": false,
"download": true,
"edit": true,
"editCommentAuthorOnly": false,
"fillForms": true,
"modifyContentControl": true,
"modifyFilter": true,
"print": true,
"review": true,
"reviewGroups": ["Group1", "Group2", ""]
},
"url": "%s"
},
"editorConfig": {
"customization":{
"autosave": true,
"forcesave": true
}
"lang": "zh-CN",
"callbackUrl": "%s",
"onEditing": {
"mode": "fast",
"change": true
},
"mode": "edit",
"user": {
"group": "Group1",
"id": "%s",
"name": "%s"
}
}
}
""";
// TODO 这里文件的key可以通过redis进行保存,这样可以支持多人在线协同,现在不做处理
json = String.format(json, fileInfo.getFileName(),
fileKey,
// TODO 这里是文件类型,自行定义
'xlsx',
// TODO 这里是文件下载地址,fileId 为文件的唯一标识,自行定义
downFileUrl + fileId,
// TODO 这里是定义回调地址,fileId 为文件的唯一标识用来区分是那个文件编辑的回调。
editCallBackUrl + fileId,
"userid","username");
Map<String, Object> map = JSONObject.parseObject(json, new TypeToken<Map<String, Object>>() {
}.getType());
// TODO 这里是获取onlyoffice 交互的token,自己写的建议直接注释
// String token = jwtManager.createToken(map);
// map.put("token", token);
// TODO 这个key可以直接注释,这里主要作用是协同
redisUtil.hset(RedisName.ONLY_OFFICE_FILE_KYE,fileId,fileKey,60*60*24);
return ResultVo.success(map);
}
2.3.2 文件下载地址
这个接口的作用就是获取一个文件流,根据ID来获取一个文件流
这里的地址就是上一个接口中下载文件的地址。
@GetMapping("down/file/{fileId}")
@Operation(summary = "根据参数下载一个文件")
public void downFolderById(@PathVariable String fileId, HttpServletResponse response){
// TODO 1. 根据文件的唯一ID来获取数据库中的记录
EtmfFileInfo fileInfo = fileInfoOpt.getById(fileId);
// TODO 2. 根据下载路径从 minio 中获取文件流 (因为我们使用的是minio,其他的自行切换即可)
try (InputStream inputStream = smoMinIoUtils.downloadFile(fileInfo.getFileUrl())) {
downFileInfo(response, fileInfo, inputStream);
} catch (ServerException | ErrorResponseException | InsufficientDataException | IOException |
NoSuchAlgorithmException | InvalidKeyException | InvalidResponseException | XmlParserException |
InternalException e) {
JwtUtil.responseError(response, 500L, "文件下载失败:" + e.getMessage());
}
}
public static void downFileInfo(HttpServletResponse response, EtmfFileInfo fileInfo, InputStream inputStream) throws IOException {
response.setCharacterEncoding("UTF-8");
response.setContentType("application/octet-stream; charset=UTF-8");
response.addHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(fileInfo.getFileName(), StandardCharsets.UTF_8));
ServletOutputStream stream = response.getOutputStream();
IOUtils.copy(inputStream,stream);
stream.flush();
stream.close();
}
2.3.3 文件下载地址
这里是文件的回调地址,主要就是获取一个状态码,然后根据状态码判定是否保存文件。
@Operation(summary = "文件编辑之后的回调")
@Parameters({
@Parameter(name = "fileId", description = "文件的ID", in = ParameterIn.PATH)
})
@PostMapping("/edit/callback/{fileId}")
public void editCallBack(@PathVariable String fileId, HttpServletRequest request, HttpServletResponse response) {
try {
PrintWriter writer = response.getWriter();
String body;
try {
Scanner scanner = new Scanner(request.getInputStream());
scanner.useDelimiter("\\A");
body = scanner.hasNext() ? scanner.next() : "";
scanner.close();
} catch (Exception ex) {
writer.write("get request.getInputStream error:" + ex.getMessage());
return;
}
if (body.isEmpty()) {
writer.write("empty request.getInputStream");
return;
}
JSONObject jsonObj = JSON.parseObject(body);
int status = (Integer) jsonObj.get("status");
log.debug("================文件编辑获取到的参数是:{}", JSON.toJSONString(jsonObj));
int saved = 0;
if (List.of(2,3,6).contains(status)) {
String downloadUri = (String) jsonObj.get("url");
log.debug("================文件进行保存处理,需要保存的状态值是:{},可以获取到文件的路径是:{}", status,downloadUri);
try {
URL url = new URL(downloadUri);
// 根据文件下载地址来获取编辑后的文件流
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
InputStream stream = connection.getInputStream();
if (stream == null) {
throw new Exception("Stream is null");
}
// TODO 根据文件的唯一标识获取数据库中文件记录
EtmfFileInfo fileInfo = fileInfoOpt.getById(fileId);
// TODO 根据文件流创建一个文件
File savedFile = new File(fileInfo.getFileName());
try (FileOutputStream out = new FileOutputStream(savedFile)) {
int read;
final byte[] bytes = new byte[1024];
while ((read = stream.read(bytes)) != -1) {
out.write(bytes, 0, read);
}
out.flush();
}
// TODO 根据文件上传到 MINIO中
boolean b = smoMinIoUtils.uploadFile(fileInfo.getFileUrl(), savedFile);
log.info("编辑文件后,文件上传状态:{},上传的文件是:{},Id是:{}",b,fileInfo.getFileName(),fileId);
savedFile.delete();
connection.disconnect();
} catch (Exception ex) {
saved = 1;
ex.printStackTrace();
}finally {
// 正常保存的时候剔除掉redis缓存
if (status == TWO) {
redisUtil.hdel(RedisName.ONLY_OFFICE_FILE_KYE,fileId);
}
}
}
writer.write("{\"error\":" + saved + "}");
writer.flush();
writer.close();
log.debug("======================编辑完成--------------返回值是:{}","{\"error\":" + saved + "}");
} catch (IOException e) {
e.printStackTrace();
throw new SmoGlobalException(CustomExceptionType.OTHER_ERROR);
}
}
3. 总结
文件的在线编辑主要就是依托与onlyoffice实现的,而编辑器的配置是通过我们的接口来定义的,接口中的配置可以自由的定义编辑器的文件类型,窗口大小,文件来源,回调地址,保存类型等等。
你需要编辑的文件可以放在任意的位置,只要你的接口可以通过流的方式给到onlyofiice编辑器即可。
文件编辑后的处理都是在回调中处理的,最好先看一下文档的回调写法。回调的时候记得打印日志,观察一下接口的内容,一定要记得是通过回调中的url参数来获取编辑后的文件流的,并不是通过回调接口直接把文件流给到你。我在这里没有注意看饶了弯路。所以提醒一下。