用例
实现对空间重命名,需要考虑到以下问题:
- 一个空间可以有很多页面
- 每个页面可以有很多反向链接
- 一些页面可以有大量的内容,我们要在内容里更新相对链接
这操作可能需要大量的时间,所以我们需要显示进度。这意味着我们不能阻塞触发操作的HTTP请求。换句话说,操作应该是异步的。
API设计
在我们开始实现之前,我们需要设计重命名API。实现异步任务的主要方法有2种:
- push: 启动任务,然后等待通知任务进度,成功或失败。为了得到通知,可以:
- 无论是传递一个回调(callback)给API
- 或者API返回一个promise,你可以使用一个注册的回调
- pull: 启动任务,然后你定时ask for updates直到任务完成(成功或失败)。在这种情况下,API需要提供一些方法来访问任务的状态
第一个选项(push)是很好的,但它需要触发任务代码和执行任务代码之间的双向连接。这是不符合(标准)的HTTP协议,服务端(通常)是不会把数据推送到客户端。客户端是可以从服务器拉数据。因此,我们要使用第二个选项。
## Start the task. #set (taskId = services.space.rename(spaceReference, newSpaceName)) ... ## Pull the task status. #set (taskStatus = services.space.getRenameStatus(
查看Job Module了解如何实现此API。
Request(请求)
request表示该任务的输入。这包括:
- 任务所需要的数据(例如空间引用和新的空间名称)
- 上下文信息(例如触发任务的用户)
- 任务配置选项。 例如:
- 是否检查访问权限
- 任务是否是交互的(在任务执行过程中可能需要用户输入)
每一个请求都有一个用来访问任务状态的标识符。
public class RenameRequest extends org.xwiki.job.AbstractRequest { private static final String PROPERTY_SPACE_REFERENCE = "spaceReference"; private static final String PROPERTY_NEW_SPACE_NAME = "newSpaceName"; private static final String PROPERTY_CHECK_RIGHTS = "checkrights"; private static final String PROPERTY_USER_REFERENCE = "user.reference"; public SpaceReference getSpaceReference() { return getProperty(PROPERTY_SPACE_REFERENCE); } public void setSpaceReference(SpaceReference spaceReference) { setProperty(PROPERTY_SPACE_REFERENCE, spaceReference); } public String getNewSpaceName() { return getProperty(PROPERTY_NEW_SPACE_NAME); } public void setNewSpaceName(String newSpaceName) { setProperty(PROPERTY_NEW_SPACE_NAME, newSpaceName); } public boolean isCheckRights() { return getProperty(PROPERTY_CHECK_RIGHTS, true); } public void setCheckRights(boolean checkRights) { setProperty(PROPERTY_CHECK_RIGHTS, checkRights); } public DocumentReference getUserReference() { return getProperty(PROPERTY_USER_REFERENCE); } public void setUserReference(DocumentReference userReference) { setProperty(PROPERTY_USER_REFERENCE, userReference); } }
Questions(询问)
正如我们所提到的,在作业执行过程中,作业可以通过asking questions进行互动。例如,如果已经有一个空间与新的空间名称重复,那么我们就必须决定是否:
- 停止重命名
- 或合并两个空间
如果我们决定合并两个空间,则有可能在两个空间有一样名字的文档,在这种情况下,我们必须决定是否覆盖目标文档。
为了让这个例子简单点,我们始终合并这两个空间,但我们会要求用户确认是否覆盖。
public class OverwriteQuestion { private final DocumentReference source; private final DocumentReference destination; private boolean overwrite = true; private boolean askAgain = true; public OverwriteQuestion(DocumentReference source, DocumentReference destination) { this.source = source; this.destination = destination; } public EntityReference getSource() { return source; } public EntityReference getDestination() { return destination; } public boolean isOverwrite() { return overwrite; } public void setOverwrite(boolean overwrite) { this.overwrite = overwrite; } public boolean isAskAgain() { return askAgain; } public void setAskAgain(boolean askAgain) { this.askAgain = askAgain; } }
Job Status(作业状态)
提供作业状态,默认情况下,访问:
- 作业状态(例如 NONE, RUNNING, WAITING, FINISHED)
- 作业请求
- 作业日志 ("INFO: Document X.Y has been renamed to A.B")
- 作业进展 (72%完成)
大多数时候,你并不需要扩展作业模块提供的DefaultJobStatus,除非你想存储:
- 更多进展的信息(例如已到目前为止改名的文件清单)
- 任务结果/输出
请注意,请求和作业状态必须是可序列化,所以要小心你在自定义作业状态存储什么样信息。例如,对于任务输出,可能在输出存储一个引用,路径或URL更好而不是存储输出本身。
作业状态也是job沟通通道:
- 如果作业发起一个询问,我们
- 从作业状态访问询问(question)
- 通过作业状态答复询问
- 如果你想取消的作业必须通过作业状态来执行
public class RenameJobStatus extends DefaultJobStatus<RenameRequest> { private boolean canceled; private List<DocumentReference> renamedDocumentReferences = new ArrayList<>(); public RenameJobStatus(RenameRequest request, ObservationManager observationManager, LoggerManager loggerManager, JobStatus parentJobStatus) { super(request, observationManager, loggerManager, parentJobStatus); } public void cancel() { this.canceled = true; } public boolean isCanceled() { return this.canceled; } public List<DocumentReference> getRenamedDocumentReferences() { return this.renamedDocumentReferences; } }
脚本服务
现在,我们需要实现一个ScriptService,使用Velocity触发重命名,并得到重命名状态。
@Component @Named(SpaceScriptService.ROLE_HINT) @Singleton public class SpaceScriptService implements ScriptService { public static final String ROLE_HINT = "space"; public static final String RENAME = "rename"; @Inject private JobExecutor jobExecutor; @Inject private JobStatusStore jobStatusStore; @Inject private DocumentAccessBridge documentAccessBridge; public String rename(SpaceReference spaceReference, String newSpaceName) { setError(null); RenameRequest renameRequest = createRenameRequest(spaceReference, newSpaceName); try { this.jobExecutor.execute(RENAME, renameRequest); List<String> renameId = renameRequest.getId(); return renameId.get(renameId.size() - 1); } catch (Exception e) { setError(e); return null; } } public RenameJobStatus getRenameStatus(String renameJobId) { return (RenameJobStatus) this.jobStatusStore.getJobStatus(getJobId(renameJobId)); } private RenameRequest createRenameRequest(SpaceReference spaceReference, String newSpaceName) { RenameRequest renameRequest = new RenameRequest(); renameRequest.setId(getNewJobId()); renameRequest.setSpaceReference(spaceReference); renameRequest.setNewSpaceName(newSpaceName); renameRequest.setInteractive(true); renameRequest.setCheckRights(true); renameRequest.setUserReference(this.documentAccessBridge.getCurrentUserReference()); return renameRequest; } private List<String> getNewJobId() { return getJobId(UUID.randomUUID().toString()); } private List<String> getJobId(String suffix) { return Arrays.asList(ROLE_HINT, RENAME, suffix); } }
Job实现
Jobs是个组件。让我们来看看我们如何能够实现它们。
@Component @Named(SpaceScriptService.RENAME) public class RenameJob extends AbstractJob<RenameRequest, RenameJobStatus> implements GroupedJob { @Inject private AuthorizationManager authorization; @Inject private DocumentAccessBridge documentAccessBridge; private Boolean overwriteAll; @Override public String getType() { return SpaceScriptService.RENAME; } @Override public JobGroupPath getGroupPath() { String wiki = this.request.getSpaceReference().getWikiReference().getName(); return new JobGroupPath(Arrays.asList(SpaceScriptService.RENAME, wiki)); } @Override protected void runInternal() throws Exception { List<DocumentReference> documentReferences = getDocumentReferences(this.request.getSpaceReference()); this.progressManager.pushLevelProgress(documentReferences.size(), this); try { for (DocumentReference documentReference : documentReferences) { if (this.status.isCanceled()) { break; } else { this.progressManager.startStep(this); if (hasAccess(Right.DELETE, documentReference)) { move(documentReference, this.request.getNewSpaceName()); this.status.getRenamedDocumentReferences().add(documentReference); this.logger.info("Document [{}] has been moved to [{}].", documentReference, this.request.getNewSpaceName()); } } } } finally { this.progressManager.popLevelProgress(this); } } private boolean hasAccess(Right right, EntityReference reference) { return !this.request.isCheckRights() || this.authorization.hasAccess(right, this.request.getUserReference(), reference); } private void move(DocumentReference documentReference, String newSpaceName) { SpaceReference newSpaceReference = new SpaceReference(newSpaceName, documentReference.getWikiReference()); DocumentReference newDocumentReference = documentReference.replaceParent(documentReference.getLastSpaceReference(), newSpaceReference); if (!this.documentAccessBridge.exists(newDocumentReference) || confirmOverwrite(documentReference, newDocumentReference)) { move(documentReference, newDocumentReference); } } private boolean confirmOverwrite(DocumentReference source, DocumentReference destination) { if (this.overwriteAll == null) { OverwriteQuestion question = new OverwriteQuestion(source, destination); try { this.status.ask(question); if (!question.isAskAgain()) { // Use the same answer for the following overwrite questions. this.overwriteAll = question.isOverwrite(); } return question.isOverwrite(); } catch (InterruptedException e) { this.logger.warn("Overwrite question has been interrupted."); return false; } } else { return this.overwriteAll; } } }
服务端控制器
我们需要从JavaScript能够触发重命名操作和远程定时获得状态更新。这意味着重命名API应该可以通过一些URL访问:
- ?action=rename -> redirects to ?data=jobStatus
- ?data=jobStatus&jobId=xyz -> return the job status serialized as JSON
- ?action=continue&jobId=xyz -> redirects to ?data=jobStatus
- ?action=cancel&jobId=xyz -> redirects to ?data=jobStatus
{{velocity}} #if (request.action == 'rename') #set (spaceReference = services.model.resolveSpace(request.spaceReference)) #set (renameJobId = services.space.rename(spaceReference, request.newSpaceName)) response.sendRedirect(doc.getURL('get', escapetool.url({ 'outputSyntax': 'plain', 'jobId': renameJobId }))) #elseif (request.action == 'continue') #set (renameJobStatus = services.space.getRenameStatus(request.jobId)) #set (overwrite = request.overwrite == 'true') #set (discard = renameJobStatus.question.setOverwrite(overwrite)) #set (discard = renameJobStatus..answered()) #elseif (request.action == 'cancel') #set (renameJobStatus = services.space.getRenameStatus(request.jobId)) #set (discard = renameJobStatus.cancel()) response.sendRedirect(doc.getURL('get', escapetool.url({ 'outputSyntax': 'plain', 'jobId': renameJobId }))) #elseif (request.data == 'jobStatus') #set (renameJobStatus = services.space.getRenameStatus(request.jobId)) #buildRenameStatusJSON(renameJobStatus) response.setContentType('application/json') jsontool.serialize(
客户端控制器
在客户端,JavaScript代码负责:
- 通过一个AJAX请求到服务端控制器触发任务
- 定时检索任务状态更新和更新显示进度
- 向用户传递job询问和传递用户的答复到服务端控制器
var onStatusUpdate = function(status) { updateProgressBar(status); if (status.state == 'WAITING') { // Display the question to the user. displayQuestion(status); } else if (status.state != 'FINISHED') { // Pull task status update. setTimeout(function() { requestStatusUpdate(status.request.id).success(onStatusUpdate); }, 1000); } }; // Trigger the rename task. rename(parameters).success(onStatusUpdate); // Continue the rename after the user answers the question. continueRename(parameters).success(onStatusUpdate); // Cancel the rename. cancelRename(parameters).success(onStatusUpdate);