Office365:WOPI集成

背景

前段时间,做了一个关于如何集成Office365的调研,探索如何将它集成到应用里面,方便多人的协同工作,这种应用场景特别在内部审计平台使用特别多,一些文档需要被不同角色查看,评论以及审批。

技术方案简介

通过快速的调研,发现已经有比较成熟的方案,其中之一就是微软定义的WOPI接口,只要严格按照其定义的规范,并实现其接口,就可以很快实现Office365的集成。

image.png

上面架构图,摘取至http://wopi.readthedocs.io/en/latest/overview.html,简单讲讲,整个技术方案,共有三个子系统:

  • 自建的前端业务系统
  • 自建的WOPI服务 - WOPI是微软的web application open platform interface-Web应用程序开放平台接口
  • Office online

我们可以通过iframe的方式把office online内嵌到业务系统,并且回调我们的WOPI服务进行相应的文档操作。

界面

界面的原型,通过iframe的方式,把office 365内嵌到了我们的业务页面,我们可以在这个页面上,多人协同对底稿进行查看和编辑。

image.png

样例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Office extends Component {
render() {
return (
<div className="office">
<form
id="office_form"
ref={el => (this.office_form = el)}
name="office_form"
target="office_frame"
action={OFFICE_ONLINE_ACTION_URL}
method="post"
>
<input name="access_token" value={ACCESS_TOKEN_VALUE} type="hidden" />
<input
name="access_token_ttl"
value={ACCESS_TOKEN_TTL_VALUE}
type="hidden"
/>
</form>
<span id="frameholder" ref={el => (this.frameholder = el)} />
</div>
);
}

componentDidMount() {
const office_frame = document.createElement('iframe');
office_frame.name = 'office_frame';
office_frame.id = 'office_frame';
office_frame.title = 'Office Online';
office_frame.setAttribute('allowfullscreen', 'true');
this.frameholder.appendChild(office_frame);
this.office_form.submit();
}
}

对前端应用来说,最需要知道的就是请求的API URL,e.g:

1
2
https://word-view.officeapps-df.live.com/wv/wordviewerframe.aspx?WOPISrc={your_wopi_service_dns}/wopi/files/
https://word-edit.officeapps-df.live.com/we/wordeditorframe.aspx?WOPISrc={your_wopi_service_dns}/wopi/files/demo.docx

视具体情况,请根据Wopi Discovery选择合适的API:

https://wopi.readthedocs.io/en/latest/discovery.html

交互图

接下来就是具体的交互流程了, 我们先来到了业务系统,然后前端系统会在调用后端服务,获取相应的信息,比如access token还有即将访问的URL, 然后当用户查看或者编辑底稿的时候,前端系统会调用office365,它又会根据我们传的url参数,回调WOPI服务,进行一些列的操作,比如,它会调用API获取相应的文档基本信息,然后再发一次API请求获取文档的具体内容,最后就可以实现文档的在线查看和编辑,并且把结果通过WOPI的服务进行保存。

image.png

WOPI服务端接口如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@RestController
@RequestMapping(value = "/wopi")
public class WopiProtocalController {

private WopiProtocalService wopiProtocalService;

@Autowired
public WopiProtocalController(WopiProtocalService wopiProtocalService) {
this.wopiProtocalService = wopiProtocalService;
}

@GetMapping("/files/{name}/contents")
public ResponseEntity<Resource> getFile(@PathVariable(name = "name") String name, HttpServletRequest request) throws UnsupportedEncodingException, FileNotFoundException {
return wopiProtocalService.handleGetFileRequest(name, request);
}

@PostMapping("/files/{name}/contents")
public void putFile(@PathVariable(name = "name") String name, @RequestBody byte[] content, HttpServletRequest request) throws IOException {
wopiProtocalService.handlePutFileRequest(name, content, request);
}


@GetMapping("/files/{name}")
public ResponseEntity<CheckFileInfoResponse> getFileInfo(@PathVariable(name = "name") String name, HttpServletRequest request) throws UnsupportedEncodingException, FileNotFoundException {
return wopiProtocalService.handleCheckFileInfoRequest(name, request);
}

@PostMapping("/files/{name}")
public ResponseEntity editFile(@PathVariable(name = "name") String name, HttpServletRequest request) {
return wopiProtocalService.handleEditFileRequest(name, request);
}

}

WopiProtocalService里面包含了具体对接口的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
@Service
public class WopiProtocalService {

@Value("${localstorage.path}")
private String filePath;

private WopiAuthenticationValidator validator;
private WopiLockService lockService;

@Autowired
public WopiProtocalService(WopiAuthenticationValidator validator, WopiLockService lockService) {
this.validator = validator;
this.lockService = lockService;
}

public ResponseEntity<Resource> handleGetFileRequest(String name, HttpServletRequest request) throws UnsupportedEncodingException, FileNotFoundException {
this.validator.validate(request);
String path = filePath + name;
File file = new File(path);
InputStreamResource resource = new InputStreamResource(new FileInputStream(file));

HttpHeaders headers = new HttpHeaders();
headers.add("Content-Disposition", "attachment;filename=" +
new String(file.getName().getBytes("utf-8"), "ISO-8859-1"));

return ResponseEntity.ok()
.headers(headers)
.contentLength(file.length())
.contentType(MediaType.parseMediaType("application/octet-stream"))
.body(resource);
}

/**
* @param name
* @param content
* @param request
* @TODO: rework on it based on the description of document
*/
public void handlePutFileRequest(String name, byte[] content, HttpServletRequest request) throws IOException {
this.validator.validate(request);
Path path = Paths.get(filePath + name);
Files.write(path, content);
}

public ResponseEntity<CheckFileInfoResponse> handleCheckFileInfoRequest(String name, HttpServletRequest request) throws UnsupportedEncodingException, FileNotFoundException {
this.validator.validate(request);
CheckFileInfoResponse info = new CheckFileInfoResponse();
String fileName = URLDecoder.decode(name, "UTF-8");
if (fileName != null && fileName.length() > 0) {
File file = new File(filePath + fileName);
if (file.exists()) {
info.setBaseFileName(file.getName());
info.setSize(file.length());
info.setOwnerId("admin");
info.setVersion(file.lastModified());
info.setAllowExternalMarketplace(true);
info.setUserCanWrite(true);
info.setSupportsUpdate(true);
info.setSupportsLocks(true);
} else {
throw new FileNotFoundException("Resource not found/user unauthorized");
}
}
return ResponseEntity.ok().contentType(MediaType.parseMediaType(MediaType.APPLICATION_JSON_UTF8_VALUE)).body(info);
}

public ResponseEntity handleEditFileRequest(String name, HttpServletRequest request) {
this.validator.validate(request);
ResponseEntity responseEntity;
String requestType = request.getHeader(WopiRequestHeader.REQUEST_TYPE.getName());
switch (valueOf(requestType)) {
case PUT_RELATIVE_FILE:
responseEntity = this.handlePutRelativeFileRequest(name, request);
break;
case LOCK:
if (request.getHeader(WopiRequestHeader.OLD_LOCK.getName()) != null) {
responseEntity = this.lockService.handleUnlockAndRelockRequest(name, request);
} else {
responseEntity = this.lockService.handleLockRequest(name, request);
}
break;
case UNLOCK:
responseEntity = this.lockService.handleUnLockRequest(name, request);
break;
case REFRESH_LOCK:
responseEntity = this.lockService.handleRefreshLockRequest(name, request);
break;
case UNLOCK_AND_RELOCK:
responseEntity = this.lockService.handleUnlockAndRelockRequest(name, request);
break;
default:
throw new UnSupportedRequestException("Operation not supported");
}
return responseEntity;
}
}

具体实现细节,请参加如下代码库:

WOPI架构特点

image.png

  • 数据存放在内部存储系统(私有云或者内部数据中心),信息更加安全。
  • 自建WOPI服务,服务化,易于重用,且稳定可控。
  • 实现了WOPI协议,理论上可以集成所有Office在线应用,支持在线协作,扩展性好。
  • 解决方案成熟,微软官方推荐和提供支持。

WOPI开发依赖

  • 需要购买Office的开发者账号(个人的话,可以申请一年期的免费账号:https://developer.microsoft.com/en-us/office/profile/
    )。
  • WOPI服务测试、上线需要等待微软团队将URL加入白名单(测试环境大约需要1到3周的时间,才能完成白名单)。
  • 上线流程需要通过微软安全、性能等测试流程。

具体流程请参加:https://wopi.readthedocs.io/en/latest/build_test_ship/settings.html

参考