用例
实现对空间重命名,需要考虑到以下问题:
- 一个空间可以有很多页面
- 每个页面可以有很多反向链接
- 一些页面可以有大量的内容,我们要在内容里更新相对链接
这操作可能需要大量的时间,所以我们需要显示进度。这意味着我们不能阻塞触发操作的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($taskId))
查看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($renameJobStatusJSON)
#end
{{/velocity}}
客户端控制器
在客户端,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);