背景
前段時(shí)間尔崔,做了一個(gè)關(guān)于如何集成Office365的調(diào)研,探索如何將它集成到應(yīng)用里面仪搔,方便多人的協(xié)同工作忧换,這種應(yīng)用場(chǎng)景特別在內(nèi)部審計(jì)平臺(tái)使用特別多恬惯,一些文檔需要被不同角色查看,評(píng)論以及審批亚茬。
技術(shù)方案簡(jiǎn)介
通過(guò)快速的調(diào)研酪耳,發(fā)現(xiàn)已經(jīng)有比較成熟的方案,其中之一就是微軟定義的WOPI
接口刹缝,只要嚴(yán)格按照其定義的規(guī)范碗暗,并實(shí)現(xiàn)其接口,就可以很快實(shí)現(xiàn)Office365的集成梢夯。
上面架構(gòu)圖言疗,摘取至http://wopi.readthedocs.io/en/latest/overview.html,簡(jiǎn)單講講颂砸,整個(gè)技術(shù)方案噪奄,共有三個(gè)子系統(tǒng):
- 自建的前端業(yè)務(wù)系統(tǒng)
- 自建的WOPI服務(wù) - WOPI是微軟的web application open platform interface-Web應(yīng)用程序開放平臺(tái)接口
- Office online
我們可以通過(guò)iframe的方式把office online內(nèi)嵌到業(yè)務(wù)系統(tǒng),并且回調(diào)我們的WOPI服務(wù)進(jìn)行相應(yīng)的文檔操作人乓。
界面
界面的原型勤篮,通過(guò)iframe的方式,把office 365內(nèi)嵌到了我們的業(yè)務(wù)頁(yè)面色罚,我們可以在這個(gè)頁(yè)面上叙谨,多人協(xié)同對(duì)底稿進(jìn)行查看和編輯。
樣例代碼如下:
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();
}
}
對(duì)前端應(yīng)用來(lái)說(shuō)保屯,最需要知道的就是請(qǐng)求的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
視具體情況,請(qǐng)根據(jù)Wopi Discovery選擇合適的API:
https://wopi.readthedocs.io/en/latest/discovery.html
交互圖
接下來(lái)就是具體的交互流程了姑尺, 我們先來(lái)到了業(yè)務(wù)系統(tǒng)竟终,然后前端系統(tǒng)會(huì)在調(diào)用后端服務(wù),獲取相應(yīng)的信息切蟋,比如access token還有即將訪問(wèn)的URL统捶, 然后當(dāng)用戶查看或者編輯底稿的時(shí)候,前端系統(tǒng)會(huì)調(diào)用office365柄粹,它又會(huì)根據(jù)我們傳的url參數(shù)喘鸟,回調(diào)WOPI服務(wù),進(jìn)行一些列的操作驻右,比如什黑,它會(huì)調(diào)用API獲取相應(yīng)的文檔基本信息,然后再發(fā)一次API請(qǐng)求獲取文檔的具體內(nèi)容堪夭,最后就可以實(shí)現(xiàn)文檔的在線查看和編輯愕把,并且把結(jié)果通過(guò)WOPI的服務(wù)進(jìn)行保存拣凹。
WOPI服務(wù)端接口如下:
@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
里面包含了具體對(duì)接口的實(shí)現(xiàn):
@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;
}
}
具體實(shí)現(xiàn)細(xì)節(jié),請(qǐng)參加如下代碼庫(kù):
WOPI架構(gòu)特點(diǎn)
- 數(shù)據(jù)存放在內(nèi)部存儲(chǔ)系統(tǒng)(私有云或者內(nèi)部數(shù)據(jù)中心)恨豁,信息更加安全嚣镜。
- 自建WOPI服務(wù),服務(wù)化橘蜜,易于重用菊匿,且穩(wěn)定可控。
- 實(shí)現(xiàn)了WOPI協(xié)議计福,理論上可以集成所有Office在線應(yīng)用捧请,支持在線協(xié)作,擴(kuò)展性好棒搜。
- 解決方案成熟疹蛉,微軟官方推薦和提供支持。
WOPI開發(fā)依賴
- 需要購(gòu)買Office的開發(fā)者賬號(hào)(個(gè)人的話力麸,可以申請(qǐng)一年期的免費(fèi)賬號(hào):https://developer.microsoft.com/en-us/office/profile/
)可款。 - WOPI服務(wù)測(cè)試、上線需要等待微軟團(tuán)隊(duì)將URL加入白名單(測(cè)試環(huán)境大約需要1到3周的時(shí)間克蚂,才能完成白名單)闺鲸。
- 上線流程需要通過(guò)微軟安全、性能等測(cè)試流程埃叭。
具體流程請(qǐng)參加:https://wopi.readthedocs.io/en/latest/build_test_ship/settings.html