背景
前段时间,做了一个关于如何集成Office365的调研,探索如何将它集成到应用里面,方便多人的协同工作,这种应用场景特别在内部审计平台使用特别多,一些文档需要被不同角色查看,评论以及审批。
技术方案简介
通过快速的调研,发现已经有比较成熟的方案,其中之一就是微软定义的WOPI
接口,只要严格按照其定义的规范,并实现其接口,就可以很快实现Office365的集成。
上面架构图,摘取至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内嵌到了我们的业务页面,我们可以在这个页面上,多人协同对底稿进行查看和编辑。
样例代码如下:
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:
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的服务进行保存。
WOPI服务端接口如下:
@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
里面包含了具体对接口的实现:
@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架构特点
- 数据存放在内部存储系统(私有云或者内部数据中心),信息更加安全。
- 自建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