说说如何使用 jBPM4 的 Service API 来控制流程

1 什么是流程定义、流程实例与流程执行?

流程定义是对业务过程步骤的描述。在 jbpm4 中,它表现为若干 "活动" 节点通过 “转移” 路径串联起来。比如下面定义的这个信贷流程:

信贷流程

流程实例表示的是流程定义在运行时特有的执行例程。比如,上周你提出了贷款买房申请,那么这个信贷流程定义就被实例化咯。

一个流程实例在其生命周期中最典型的特征就是:具有指向当前执行活动的指针,在 jbpm4 中叫做 executions。比如下面这个流程实例正执行到 “归档” 活动:

执行中的流程实例

流程实例支持并行执行,所以在同一个流程实例中的执行数量可能有多个,修改上面的贷款流程定义,让汇款和存档并行执行,那么这里的主流程实例就可能包含两个用来跟踪状态的子执行实例(execution)。

并行执行的流程实例

流程实例可以理解为一颗执行树,当一个流程实例启动时最初的执行处于这个执行树的根节点位置,之后根据定义的需要再产生子执行实例,即树枝。

2 流程引擎 API

Jbpm4 所有 Service API 都来源于流程引擎对象 org.jbpm.api.ProcessEngine,即所有的 sevice API 都可以从
ProcessEngine 中获得。ProcessEngine 是由工作流引擎根据配置生成的。

processEngine 是线程安全的,所以可以保存在静态变量中。实践中,所有的线程和请求都可以使用同一个 processEngine 对象。这样获取 processEngine 对象:

ProcessEngine processEngine = configuration.buildProcessEngine();

这里根据 classpath 根目录下的默认配置文件(jbpm.cfg.xml)创建了ProcessEngine 对象。

如果要指定其他位置的 jbpm 配置文件,可以使用 Configuration.setResource 方法:

ProcessEngine processEngine =new Configuration.setResource("other-jbpm-confguration-file.xml").buildProcessEngine();

当然也可以从其他的 setXxx() 方法中(比如 InputStream、XML 字符串等)获取 jBPM 配置内容。

可以通过 ProcessEngine 实例得到 jBPM4 封装的 6 个 Service API:

RepositoryService repositoryService = processEngine.getRepositoryService();
ExecutionService executionService = processEngine.getExecutionService();
TaskService taskService = processEngine.getTaskService();
HistoryService historyService = processEngine.getHistoryService();
ManagementService managementService = processEngine.getManagementService();
IdentityService identityService=processEngine.getIdentityService();

这些 Service API 都位于 org.jbpm.api 包中:

  • RepositoryService - 流程资源服务接口。提供对流程定义的部署、查询和删除操作。
  • ExecutionService - 流程执行服务接口。提供启动流程实例、执行对象的推进和设置流程变量等操作。
  • TaskService - 流程任务服务接口。提供对任务的创建、提交、查询、保存和删除等操作。
  • ManagementService :流程管理控制服务接口。提供对异步工作(Job)的执行和查询操作。
  • HistoryService :流程历史服务接口。提供对流程历史库(即已完成的流程实例归档数据)中历史流程实例、历史活动实例等数据的查询操作。还提供诸如某个流程定义中所有活动的平均持续时间、某个流程定义中某次转移的经过次数等数据分析服务。
  • IdentityService:身份认证服务接口。提供与流程用户、用户组以及组内成员关系的相关服务。

这些 Service API 都继承自 AbstractServiceImpl 类,这个类依赖于 CommandService。

AbstractServiceImpl 源代码:

public class AbstractServiceImpl {
  
  protected CommandService commandService;

  public CommandService getCommandService() {
    return commandService;
  }

  public void setCommandService(CommandService commandService) {
    this.commandService = commandService;
  }
}

CommandService 是 Command 模式的服务接口,它会将客户端的请求全部封装在一个调用接口中,然后由这个接口去调用org.jbpm.api.cmd.Command 接口的众多实现。

jbpm4 Sevice API 的实现广泛地采用了 Command 设计模式。


Command 模式的目的即在不同的时刻指定、排列和执行请求。一个Command 对象可以有一个与初始化请求无关的生存期。如果一个请求的接受者可用一种与地址空间无关的方式表达,那么就可以将负责该请求的命令对象传递给另一个不同的进程并在那里实现该请求。

Command 模式的优势在于:

  • 支持取消操作。Command 的 Execute 操作可以在实施操作前将状态存储起来,在取消操作时使用这个状态来消除这个操作的影响。执行的命令被存储在一个历史列表中。这样就可以通过向后或向前遍历这个列表来实现不限次数的 “取消” 与 “重做” 操作。
  • 支持修改日志。在 Command 接口中添加装载与存储操作,可以动态保持一个一致的修改日志。
  • 用构建在原语操作的的高层操作中构建一个系统。特别是支持事务的信息系统中很常见。一个事务封装了对数据的一组变动。Command 有一个公共的接口,使得可以使用同一种方法来调用所有的事务。

3 部署流程

RepositoryService 提供了发布资源的所有接口,我们可以使用它来部署 classpath 中的一个流程定义资源:

repositoryService.createDeployment().addResourceFromClasspath(filePath).addResourceFromClasspath(pngFilePath).deploy();

其中的 filePath,表示流程定义文件所在路径;pngFilePath 表示 png 文件所在路径(用于应用中展示流程图)。

也通过 addResourceFromXXX 的系列方法,从文件、Web URL、字符串、输入流或 Zip 流中获取流程定义文件。

部署的资源内容都是字节数组的形式保存。jPDL 流程定义文件以扩展名 .jpdl.xml 被识别。其他资源文件包括任务表单、Java 类和脚本等。如果不仅要部署 .jpdl.xml 流程定义文件,而且要部署一系列的流程定义资源,则可以以流程定义归档的方式部署,流程引擎会自动识别出归档中扩展名为 .jpdl.xml 文件为流程定义文件。

部署时,流程引擎会分配一个 ID 给流程定义。它的格式是 {key}-{version} ,即流程的键与流程版本号之间通过连字符拼接起来。

如果流程定义没有指定 key,那么引擎会在流程名称的基础上生成。生成的 key 会把所有不是字母或数字的字符替换成下划线。

一个流程名称只能关联一个 key。

如果没有为流程定义文件指定版本号,那么引擎会自动为其分配一个版本号(版本号为 1)。如果要部署的流程定义的 Key 已存在,那么版本号会自动递增。

举例说明,下面的这个流程定义只设置了流程名称:

<process name="workflow">
...
</process>

那么它部署后的属性是这样的:

属性名称 属性值 来源
name workflow 流程定义文件
key workflow 引擎生成
version 1 引擎生成
id workflow-1 引擎生成

我们可以通过制定流程定义的 key 来获得更简洁的 id:

<process name="workflow" key="wf">
...
</process>

它部署后的属性是这样的:

属性名称 属性值 来源
name workflow 流程定义文件
key wf 流程定义文件
version 1 引擎生成
id wf-1 引擎生成

实践中,建议主动设置流程定义文件的版本号,这样方便管理与维护哦O(∩_∩)O~

4 删除已部署的流程

可以从物理上删除已部署的流程,即会在数据库中彻底销毁这条流程定义的记录:

repositoryService.deleteDeploymentCascade(deploymentId);

如果要删除的流程定义有还未完成的流程实例,那么执行 deleteDeploymentCascade() 方法会抛出异常。

可以使用 repositoryService 的 deleteDeploymentCascade 方法级联删除一个已发布的流程定义以及其所产生的流程实例。

5 发起新的流程实例

5.1 普通方法

ProcessInstance processInstance=executionService.startProcessInstanceByKey("wf");

startProcessInstanceByKey 方法会去查找 key 为 wf 的最新版本的流程定义,然后根据最新版本的流程定义来启动流程实例。当这个流程定义部署了一个新的版本后,startProcessInstanceByKey 方法会自动切换到最新版本并已部署的流程定义对象。

如果想根据特定的流程定义版本来发起流程实例,那么可以通过流程定义的 id 来启动流程实例:

ProcessInstance processInstance=executionService.startProcessInstanceById("wf-1");

5.2 指定业务键来发起流程实例

一般情况下,一个流程实例会与一个独特的业务实例关联起来,比如一个工单流程实例必然会与一个工单号相关联,以便满足业务上的查询操作。这时我们就会为每一个新启动的流程实例分配一个业务键(processInstanceKey)。

业务键是用户执行流程时根据实际业务情况定义的。一个业务键必须在流程定义所有的版本的流程实例范围内都是唯一的。

这样指定业务键来发起流程实例:

ProcessInstance processInstance=executionService.startProcessInstanceByKey("wf",“00001”);

这里的 00001 就是业务键。

业务键会被用来创建流程实例的 ID,格式为 {processDefnintionKey}.{processInstanceKey}。比如上面的代码会创建一个 ID 为 "wf.00001" 的流程实例。

如果没有提供业务键,那么数据库就会把流程定义的主键作为 Key。


最佳实践:最好指定一个业务键来发起流程实例。这样做的好处是可以根据业务来搜索相应的流程实例(比如流转日志等等常见的业务需求)。


5.3 指定变量发起流程实例

有时候需要在启动流程实例时,传入一些初始化参数。那么我们可以把这些参数放在流程变量中,然后在发起流程时传入流程变量对象(Map<String,Object>)。

//创建流程变量
Map variables=new HashMap();
variables.put("name", "deniro");

//指定流程变量发起流程实例
ProcessInstance processInstance=executionService.startProcessInstanceByKey("wf",variables);

6 唤醒一个等待状态的执行对象

当流程执行对象进入 state 类型的活动时,执行对象会在到达 state 活动时进入等待状态(wait state),这是一个重要概念,task 等活动也会陷入等待状态(等待人工输入响应),直到触发信号 (signal) 出现,才会流转到下一个活动。ExecutionService 的 signalExecution* 方法可以用来发出signal 这个方法(执行对象作为参数)。

大多数情况下,到达 state 活动的执行对象是流程实例本身。但在定时器异步和并发的情况下,流程实例会停留在根的执行对象上,这时使用 signalExecution* 方法时就要确保作用在了正确的流程执行对象上咯。

为了正确地获取执行对象,较好的实践是为 state 活动分配一个事件监听器,定义如下:

<state name="wait">
  <on event="start">
    <event-listener class="xxx.xxx.StartWork"/>
  </on>
</state>

监听器 StartWork 中,可以执行那些需要在 state 活动中做的工作。可以在这个事件监听器中通过 execution.getId(); 获得正确的执行 id,在 state活动的工作完成后,用它来发出 signal 信号离开该活动。

executionService.signalExecutionById(executionId);

还有一种不推荐的方法来获得执行对象的 ID。当流程执行对象到达 state 活动时并且知道这个活动的名称,那么可以这样做:

//发起流程实例
ProcessInstance processInstance=executionService.startProcessInstanceById(processDefinitionId);
//或
//ProcessInstance processInstance=executionService.signalExecutionById(executionId);

//假设知道当前流程实例在 "external work" 的活动中等待
Execution execution=processInstance.findActiveExecutionIn("external work");
//获取执行对象 ID
String executionId=execution.getId();

子所以不推荐这个方法,是因为这种方式使得流程的客户端实现与业务逻辑绑定的较紧密,所以如果不是某些特殊的业务场景需要,不建议采用。


7 任务服务 API

TaskService 的主要目的是提供对任务列表的访问操作,这里的任务是的指 Jbpm4 task 活动产生的人机交互业务。

这样获取 ID 为 deniro 的用户任务列表(由用户 deniro 来办理的任务):

List<Task> taskList=taskService.findPersonalTasks("deniro");

一般来说,任务会有一个表单(显示在用户页面中)。这个表单会通过任务变量来读写与任务有关的流程数据。

long taskId=task.getId();
Set<String> variableNames=taskService.getVariableNames(taskId);

//读取任务变量
HashMap<String,Object> variables=taskService.getVariables(taskId,variableNames);
//或自行创建 variables=new HashMap<String,Object>();

//设置 "键-值" 形式的任务变量
variables.put("name", "deniro");

//将变量存入任务
taskService.setVariables(taskId, variables);

TaskService 提供了四种方式来完成任务:

//根据指定的任务 ID 完成任务
taskService.completeTask(taskId);

//根据指定的任务 ID 完成任务,同时设入变量,完成任务
taskService.completeTask(taskId,variables);

//指定 outcome,即下一步的转移路径,完成任务
taskService.completeTask(taskId,outcome);

//指定下一步的转移路径,同时设入变量,完成任务
taskService.completeTask(taskId,outcome,variableNames);

Outcome 这个参数可以用来决定任务完成后,流程流向哪一个流出 “转移” 路径。流程的流转遵循以下规则 ——

  1. 如果任务有一个没有名称的流出转移:
  • taskService.getOutcomes(taskId) 返回包含一个 null 值的集合。
  • taskService.completeTask(taskId) 会经过这个流出转移。
  • taskService.completeTask(taskId,null) 会经过这个流出转移。
  • taskService.completeTask(taskId,"anyvalue")会抛出一个异常。
  1. 如果任务有一个已命名为 “myName” 的流出转移:
  • taskService.getOutcomes(taskId) 返回包含这个流出转移的名称集合。
  • taskService.completeTask(taskId) 会经过这个流出转移。
  • taskService.completeTask(taskId,null)会抛出一个异常。因为此任务没有无名称的流出转移。
  • taskService.completeTask(taskId,"myName") 会经过这个流出转移。
  • taskService.completeTask(taskId,"other") 会抛出一个异常。
  1. 如果任务拥有多个流出转移,而其中一个没有名称,其他的都有名称(其中一个叫 myName):
  • taskService.getOutcomes(taskId) 返回包含一个 null 值和其他流出转移的名称集合。
  • taskService.completeTask(taskId) 会经过没有名称的流出转移。
  • taskService.completeTask(taskId,null) 会经过没有名称的流出转移。
  • taskService.completeTask(taskId,"myName")会经过名称为 myName 的流出转移。

4.如果任务拥有多个流出转移,且每个流出转移都拥有唯一的名称(其中一个叫 myName):

  • taskService.getOutcomes(taskId) 包含所有流出转移名称的集合。
  • taskService.completeTask(taskId) 会抛出一个异常,因为没有无名称的流出转移。
  • taskService.completeTask(taskId,null)会抛出一个异常,因为没有无名称的流出转移。
  • taskService.completeTask(taskId,"myName")会经过名称为 myName 的流出转移。
  • taskService.completeTask(taskId,"other") 会抛出一个异常。

一个任务可以拥有多个候选人,候选人可以是单个用户也可以是用户组。用户可以接受候选人是自己的任务,接受(或分配)任务指的是用户被流程引擎设置为任务的办理者。分配任务是个 ”排他“ 操作,因此在任务被分配之后,其他的用户就不能被分配并办理此任务咯。

一般情况下,除非用户被分配到这个任务上,否则不能办理这个任务。但中国特色的 “代理人” 机制是一个例外,这里先留个悬念,我们以后会说到。O(∩_∩)O~

用户接受任务后,一般需要客户端应用程序界面(网页)显示任务表单,并引导用户完成任务。对于有候选人、但还没有被分配的任务,唯一应该暴露给用户的操作是 ”接受任务“。

8 历史服务 API

在流程实例执行的过程中,会不断触发事件,通过这些事件,已完成流程实例的历史信息会被记录到流程历史数据表中。而 HistoryService API 提供了对这些历史信息的访问服务。

可以这样查找特定流程定义的所有历史流程实例:

List<HistoryProcessInstance> historyProcessInstances=historyService.createHistoryDetailQuery()
//查询 Id 为 “wf-1” 的流程定义
.processDefinitionId("“wf-1")
//返回的结果集按开始时间正序排列
.oderAsc(HistoryProcessInstanceQuery.PROPERTY_STARTTIME)
.list();

通过 HistoryActivtyInstance,可以查询历史的活动实例:

List<HistoryActivityInstance> historyActInsts=historyService
.createHistoryActivityInstanceQuery()
//查询 Id 为 “wf-1” 的流程定义
.processDefinitionId("“wf-1")
//名称为 “审核” 的活动实例
.activityName("审核")
.list();

HistoryService 还可以对流程的历史进行分析:

  • avgDurationPerActivity - 获取指定流程定义中每个活动的平均执行时间。
  • choiceDistribution - 获取指定活动定义中每个转移路径的经过次数。

9 管理服务 API

ManagementService 即管理服务,它通常用来管理 Job(异步的工作)。

ManagementService 提供以下两个方法:

//执行指定 ID 的 Job
Void execteJob(String jobId);

//获取 Job 查询接口
JobQuery createJobQurey();

JobQuery 提供的功能很多:

/** only select messages (查询所有消息型的 Job)*/
  JobQuery messages();
  
  /** only select timers (查询所有定时器的 Job)*/
  JobQuery timers();
  
  /** only select jobs related to the given process instance (查询属于指定流程实例的 Job)*/ 
  JobQuery processInstanceId(String processInstanceId);
  
  /** only select jobs that were rolled back due to an exception  (查询由于异常回滚产生的 Job)*/ 
  JobQuery exception(boolean hasException);

  /** order ascending for property {@link #PROPERTY_STATE} 
   * or {@link #PROPERTY_DUEDATE}(查询结果根据指定属性正序排列) */
  JobQuery orderAsc(String property);

  /** order descending for property {@link #PROPERTY_STATE} 
   * or {@link #PROPERTY_DUEDATE} (查询结果根据指定属性逆序排列)*/
  JobQuery orderDesc(String property);

  /** only select a specific page(查询结果分页) */ 
  JobQuery page(int firstResult, int maxResults);

  /** execute the query and get the result list (执行查询,返回 Job 列表)*/ 
  List<Job> list();

  /** execute the query and get the unique result (执行查询,返回单个 Job ) */ 
  Job uniqueResult();
  
  /** execute a count(*) query and returns number of results (执行查询,返回结果集的数量 ) */ 
  long count();

10 查询服务 API

查询服务的 API 是基于主要的 jBPM 概念实体上创建查询对象来实现的,这些实体包括流程实例、任务、流程历史等概念。

查询流程实例:

List<ProcessInstance> results = executionService
//获取流程实例查询对象
.createProcessInstanceQuery()
//指定流程定义 ID
.processDefinitionId("process_defintion_id")
//设置 “为挂起” 为条件
.notSuspended()
//分页
.page(0, 100)
//获得结果列表
.list();

上面代码会返回指定流程定义中所有未挂起的流程实例,结果集支持分页,获取前 100 条记录。

任务的查询也可以使用类似的查询对象:

List<Task> myTasks=taskService
//获取任务查询对象
.createTaskQuery()
//指定流程实例 ID
.processInstanceId(piId)
//分配给 deniro 的任务
.assignee("deniro ")
//分页
.page(100, 200)
//根据日期逆向排序
.orderDesc(TaskQuery.PROPERTY_DUEDATE)
//获得结果列表
.list();

这个查询根据指定的流程实例,获取分配给 deniro 的所有任务信息(支持分页,数据取自第 100 条开始的 200 条记录,根据日期逆向排序)。

几乎所有的服务都拥有这样的一个统一查询对象。比如查询 Job 可以通过 ManagementService 来创建 JobQuery 对象。

11 范例:使用 Service API 实现流程实例的流转

这一节将演示使用 Service API 来发起、执行、完成整个流程实例以及查询该流程实例的历史信息等功能。

假设,有这样的一个流程定义:

流程定义

对应的 jPDL 如下:

<?xml version="1.0" encoding="UTF-8"?>

<process name="process" xmlns="http://jbpm.org/4.4/jpdl">
   <start g="502,41,48,48" name="start1">
      <transition g="-56,-22" name="to state1" to="state1"/>
   </start>
   <state g="482,113,92,52" name="state1">
      <transition g="-52,-22" name="to task1" to="task1"/>
   </state>
   <end g="504,313,48,48" name="end1"/>
   <task assignee="Alex" g="481,211,92,52" name="task1">
      <transition g="-50,-22" name="to end1" to="end1"/>
   </task>
</process>

这里的 state1 是需要等待的活动,它需要一个外部执行信号才能流转通过;task1 也是需要等待的活动,它被分配给用户 Alex 办理,Alex 办理后才能流转通过。

下面的代码是基于 JbpmTestCase 的单元测试。这里列出了执行流程定义部署的 SetUp 方法与删除流程定义的 tearDown 方法:

public class ProcessTest extends JbpmTestCase {

    /**
     * 流程定义的部署 ID
     */
    String deploymentId;

    /**
     * 初始化方法中执行流程部署工作
     */
    @Override
    protected void setUp() throws Exception {
        super.setUp();

        //从 classpath 中部署流程定义
        deploymentId = repositoryService.createDeployment().addResourceFromClasspath("net/deniro/jbpm/test/process.jpdl.xml").deploy();

        //可以多次调用 addResourceFromClasspath 方法,把多个资源都部署到数据库中


    }

    /**
     *
     */
    @Override
    protected void tearDown() throws Exception {
        //物理清除 deploymentId 对应的流程定义及其所有相关资源
        repositoryService.deleteDeploymentCascade(deploymentId);
        super.tearDown();
    }

    public void test() {
        //单元测试代码

        //根据流程定义名称,发起流程实例
        ProcessInstance processInstance = executionService.startProcessInstanceByKey("process");

        //获取流程实例 ID
        String pid = processInstance.getId();

        //获取当前活动的执行对象
        Execution executionInState = processInstance.findActiveExecutionIn("state1");
        assertNotNull(executionInState);

        //发出执行信号,结束当前活动,让流程流转到下一节点
        executionService.signalExecutionById(executionInState.getId());

        //从持久化层中,获取 “最新”的流程实例对象
        processInstance = executionService.findProcessInstanceById(pid);

        //判断当前活动的执行对象
        Execution executionInTask = processInstance.findActiveExecutionIn("task1");
        assertNotNull(executionInTask);

        //获取用户 Alex 的任务,即 task 活动产生的任务
        Task task = taskService.findPersonalTasks("Alex").get(0);

        //完成任务
        taskService.completeTask(task.getId());

        //查询历史任务
        HistoryTask historyTask = historyService.createHistoryTaskQuery().taskId(task.getId()).uniqueResult();
        assertNotNull(historyTask);
        assertProcessInstanceEnded(pid);//断言:流程实例已结束

        HistoryProcessInstance historyProcInst = historyService
                .createHistoryProcessInstanceQuery().processInstanceId(pid).uniqueResult();
        assertNotNull(historyProcInst);//断言这个流程实例已成为历史,所以可以从【历史流程实例查询对象】中得到它


    }

}

使用 Service API 可以让一个流程实例走完它的整个生命周期,所以熟悉并掌握这些 API 是开发 jBPM 客户端应用的基础哦O(∩_∩)O~

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,100评论 5 474
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,862评论 2 378
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 148,993评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,309评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,303评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,421评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,830评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,501评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,689评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,506评论 2 318
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,564评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,286评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,826评论 3 305
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,875评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,114评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,705评论 2 348
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,269评论 2 341

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,575评论 18 139
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,342评论 25 707
  • 我记得木心先生有过一句俳比: 年轻人充满希望的清瘦。 ...
    措像阅读 901评论 0 0
  • 你说我们没有在一起,但是不代表我不爱你,可是我们没有在一起,就算再喜欢有什么用呢,你拿什么爱我,你最终还不是娶了别人。
    七分阅读 183评论 0 2
  • 基本用法:HTML: 参数: Tips: 多个收件人邮件地址之间用;分隔。 参考资料:http://www.rap...
    Ruby君阅读 256评论 0 0