使用 JBoss ESB 和 JBPM 实现垂直市场解决方案(VMS)

阅读数:8033 2009 年 7 月 26 日 23:09

垂直市场解决方案(VMS)是 NAVTEQ 公司中的一个机构,负责为客户提供定制的解决方案,包括移动门户和导航系统。这些解决方案中包含了 NAVTEQ 公司提供的服务以及第三方服务,以客户要求的方式交付组合服务和内容,这些方式包括 Web services,WAP,portals 等。

VMS 在响应庞大的销售机会和预见未来客户需求方面面临着一些挑战。为了解决这些挑战就需要开发复合服务(composable services),这些服务可以快速地、方便地集成到整个客户解决方案中。用于服务集成的中间件基本上能解决这些问题。

在本文中,我将讨论如何使用 JBoss 中间件平台来构建这样的系统,尤其是使用 JBoss ESB 和 jBPM(JBoss Business Process Management)。

总体解决方案

在深入了解 JBoss 中间件产品的细节之前,我将快速地描述构建 VMS 解决方案的基本方式。

目前, NAVTEQ 公司提供了这些解决方案所需的所有功能,包括功能强大的基于位置的服务,比如地理编码 (Geocoding)和路由,交通相关服务,比如交通事故信息,交通堵塞因子计算和所指定线路的交通报警,以及一般的电子商务功能,包括采购,授权和权限管理。

给客户直接暴露这些服务存在多种问题:

  • 每个服务只提供特定的功能,而 NAVTEQ 公司的客户希望有一个组合服务。例如,客户可能需要相关路线的交通信息和事件信息。这可以通过几个步骤实现—— 首先是地理编码,接着是标点,再通过标点计算路线,最后获得该线路的事件信息以及交通信息。根据客户的需求,可以通过这一系列的步骤构建新的组合服务。
  • 现有功能正在不断的涌现,结果是它们使用不同的数据模型 / 定义,这导致服务的使用和编排更加复杂。
  • 不同的客户可能需要通过不同的机制传递内容 / 服务。有些客户可能希望能够使用 SOAP 或 REST 直接访问服务,有些客户可能希望将 HTML/JavaScript 片段传输给他们的网页 / 门户网站,另外一些客户可能需要 WAP 接口连接到服务等等。
  • 现有实现有自己的安全,用户管理,监控和管理架构 / 实现。

最后,我们采用如图 1 所示的总体架构来实现。

图 1 总体解决方案架构图

架构图的中间部分是 VMS 平台,它通过编排现有的后端功能实现了特定客户所需的服务。这个平台主要由三层组成:

  • 后端适配器(Back end adapters)—— 这一层负责访问现有的后端(和 / 或者第三方)功能,并将执行的结果(以标准域模型的形式)返回到平台中。
  • 服务实现层(Service implementation layer)—— 负责将现有的(可通过后端适配器访问的)服务编排成客户所需的服务。另外,还要实现一些尚未实现的定制功能。
  • 定制适配器(Consumer adapters)—— 这一层负责通过不同的协议 / 传输将服务执行的结果传输到不同类型的客户端。

下面我将介绍如何使用 JBoss 中间件平台实现这个架构。

JBoss ESB

JBoss ESB 是一个开源的 ESB,它基于 RosettaNet ESB,支持服务的创建、部署和整合。

从架构上而言,可以将 Jboss ESB 中的一切都看作是服务。这些服务并非 Web Services,而是 ESB 服务,这些 ESB 服务可以通过多种传输暴露出来。所有的 ESB 服务都有一个方法(doWork),可以通过下面的接口(由所有的服务共享)描述:

Message output = service.doWork(Message input)

在 JBoss ESB 中,ESB 消息和 SOAP 消息类似,都由几个部分组成,包括标头(header),消息体(body),错误(fault),附件(attachments),等等。每个部分包括一个可序列化的 java 对象集合(map),通过集合中定义的 name 进行访问。这就意味着 JBoss ESB 消息并不是强类型的,在访问消息时需要注意(类型转换)。

服务定义的形式:

  • 服务目录 / 名称(Service Category/Name)——Jboss ESB 有一个内部的服务注册机制(部署服务时,服务会自动注册),其内部为访问服务提供优化机制。
  • 输入消息组件 —— 包括名称(names)(在入站消息体中)和类型(type)
  • 输出消息组件 —— 包括名称(names)(在出站消息体中)和类型(type)

传统 ESB 是使用一系列拦截器(interceptor)构建服务,允许在服务调用管道(pipeline)中注入额外处理。不同的是,JBoss ESB 将服务作为一个显式的管道(pipeline)来构建,管道包含一系列的动作(action),如图 2 所示,每个 action 实现了(潜在部分的)服务业务功能或者基础功能,比如在消息存储中存储服务消息。

图 2 JBoss ESB 服务

与基于拦截器的传统 ESB 不同的是,JBoss ESB 可以很好地隔离“业务”(服务实现)和相关基础设施(拦截器)。JBoss ESB 在可执行的管道中将二者连接起来。然而这并非毫无价值,在一个服务管道中包含所有 action 都可以简化服务处理的理解。

JBoss ESB 中的服务 action 是一个 java 类,它必须实现接口类org.jboss.soa.esb.actions.ActionLifecycle。服务的实现往往将服务功能分解成一系列的 action,然后分别实现每个 action。这类服务实现组织为 JBoss ESB 开发者提供了两个级别的复用——服务本身和可以在多个服务中复用的 action。JBoss 同样也提供了一个可复用 action 库。该库中包括如下类型的 action 1

  • 转换器(Transformers & Converters)
  • 支持 JBPM
  • 脚本(Scripting)
  • 路由(Routing)
  • 支持 Web Service
  • Misc

服务请求可通过多种传输传递。服务定义包括一系列传输 / 端点的引用。服务在端点(每个端点都有一个唯一指定的服务)上监听消息。服务定义中也可以通过指定(在传输上)处理服务请求的线程数来配置服务的吞吐量。

服务 action 调用的设置和顺序以及服务监听的端点都是在 jboss-esb.xml 文件中配置。可以给指定的服务配置一系列 action(它们由 JBoss 或者特定的公司或者服务所提供)。这种支持 action 的外部 XML 配置,极大地提高了 actions 的复用。

JBoss ESB 中的服务调用

正如上文中提到, JBoss ESB 中的一切都服务,异步 2 调用是默认的调用模式。JBoss ESB 同样也支持同步调用,同步调用可以使用相关性实现。ESB 服务的同步和异步调用可以通过下面两种方式进行控制:

  • 服务实现通过使用消息交换模式(MEP)参数定义服务行为(service behavior)。Request-Response 是默认的 MEP,也就是说服务必须返回一个响应(response)。如果将 MEP 设置成为 one-way,那么服务无需返回响应信息。
  • 服务消费者可以自己定义它是否需要回复。如果需要,request-response MEP 服务将返回一个响应信息 3

为了简化服务调用,Jboss ESB 提供了 org.jboss.soa.esb.client.ServiceInvoker 类,该类为服务调用提供了一个非常简单的接口,调用时只需要提供一个目录 / 服务名称作为参数。

JBoss ESB 的本地服务调用

除了远程传输外,JBoss 还提供了高效的本地(在同一个 JVM 中)调用机制,它是通过一个内存队列来实现的。当在服务调用中使用 org.jboss.soa.esb.client.ServiceInvoker 时,如果该服务采用本地调用且采用本地部署,那么内存传输总会被选择用来优化性能。

支持 Web Services

如今,当人们谈论 ESB 时,经常会强调 Web Services 的处理。技术上而言,JBoss ESB 中包含很多基于 Web Services 的组件,这些组件可以暴露和调用 Web Services 端点(比如,总线上 SOAP 的开启和关闭):

SOAPProcessor通过 JBossESB 托管的监听器支持调用 JBossWS 托管的 Web Service 端点。这意味着可以通过 ESB 为其它的 ESB 服务暴露 Web service 端点。它是基于一个轻量级的服务包装器(Service Wrapper)Web service(比如,JSR 181 规范 4 的实现)而实现的,通过它可以调用目标服务(target Service)。这也意味着这些服务可以通过 ESB 所支持的传输通道(http, ftp, jms 等)进行调用。在 Web Service 端点(使用 JAXWS 注解的 java 类)上配置的 SOAP 处理器(processor),实际上由 Web Service 端点处理 SOAP 请求。

SOAPClient使用 WISE 5 客户端服务生成 JAXWS 客户端代码,并调用目标服务。通常是使用 Web Service 的 WSDL 的 URL 动态生成 JAXWS 客户端,以调用 web service。

在开发的过程中,我们发现采用这种方式使用 web service 是很笨重的,所以需要采用如下策略:

  • 要是消费 web service,我们直接通过可用的 WSDL 生成 web service 客户端(可以采用 Axis 1,Axis 2 或者 JBoss WS)。
  • 要是暴露 ESB 服务,我们可以为 SOAP 采用 JBoss WS ,为 REST 采用 RestEasy 来暴露org.jboss.soa.esb.client.ServiceInvoker,并调用所需的服务。这在架构上和使用 SOAPProcessor 是一样的,但更通用一些,这允许服务功能可以暴露成 SOAP、REST 或者其它所需接口。

JBoss ESB 工具

通过 XML 文件可以配置服务监听器和服务执行管道。该 XML 配置文件由 3 个文件组成,这些是配置 ESB 服务 6 不可缺少的文件:

  • jbm-queue-service.xml 中定义了服务 7 所用的每个传输(通常是队列)相关的 MBean。
  • deployment.xml 定义了服务依赖的所有传输。部署 ESB 服务时会验证该文件中的依赖关系是否正确。
  • jboss-esb.xml 中定义了监听器和服务管道。

虽然 jbm-queue-service.xml 和 deployment.xml 文件可以直接通过 XML 进行编辑,但 JBoss 提供了工具简化 ESB 项目 8 的创建、部署和维护。图 3 显示了 jboss-esb.xml 的编辑器。

图 3 JBoss ESB 工具

该工具以图形化的方式添加和配置监听器、服务和动作(包括动作的参数)。

使用 Smooks 实现数据转换

最重要的服务动作是数据转换。JBoss ESB 使用 Smooks 9 实现数据转换。Smooks 的基本原理是使用多种类型的数据源,并从数据源中生成事件流。然后对事件流采用访问者模式(Visitor pattern)生成不同类型的数据。 10 它支持多种不同的数据源和数据类型,这意味着支持多种转换类型,包括(但不仅限这些):

  • XML to XML
  • XML to Java
  • Java to XML
  • Java to Java
  • EDI to XML
  • EDI to Java
  • Java to EDI
  • CSV to XML
  • CSV to ...

为了简化 Smooks 的使用,JBoss ESB 提供了特定的 action 11 ,可以通过配置直接调用 Smooks——并将转换作为服务管道的一部分。

为什么是 Smooks?

技术上而言,可以直接通过 java 代码实现数据转换,那么为什么要使用 Smooks 呢?(记住:Smooks 是基于 XML 配置文件的,编写 XML 文件往往比直接通过 Java 代码实现还要慢 12 ……)

数据转换中使用 Smooks 的好处:

  • 与 Java 实现不同的是,Smooks 不需要处理任何 if-then 逻辑。可以为数据源中的所有元素定义映射,如果数据源中不存在某个元素,那么将不会生成映射。
  • Smooks 的文件结构对应组件的映射。
  • Smooks 可以使用外部的转换定义,因此提高了整体维护服务的效率。
  • JBoss/Smooks 为 Smooks 提供了一个图形化的编辑器,通过该编辑器可以设计数据转换。

Smooks 工具

JBoss ESB 工具为可视化定义 Smooks 转换提供了图形化编辑器。

图 4 Smooks 图形编辑器

数据转换创建完后,可以通过 Smooks 的执行报告(图 5)以视图的形式展现该过程中的所有执行步骤。该视图极大程度上简化了 Smooks 转换的 debug 过程。

图 5 Smooks 执行报告

常见的 Smooks 转换设计错误

设计 Smooks 转换时,最重要的是要记住转换不是上下文敏感的。也就是说,比如,有如下一个 XML 文档:

<a>
 <b>
	<c>12345</c>
 </b>
 <d>
	<c>12345</c>
 </d>
</a>

当使用“c”作为选择器时,它会被调用两次。第一次是“b”元素里面的“c”,第二次是“d”元素中的“c”,即使是使用选择器"b"也会调用两次。

为了避免出现这些问题,需要使用源文档中具有唯一名称的选择器。

JBPM

JBoss jBPM 是一个灵活的,可扩展的流程语言框架 13 。jPDL 是建立在该公共框架上的一种流程语言,它将业务流程图形化的表示成任务(tasks),异步通信的等待状态(wait states),计时器(timers),自动化动作(automated actions)和其它组件。

jPDL 最大程度地减少了对其它 lib 库的依赖,可以像使用 java 库一样简单地使用。另外,也可以用在对吞吐量非常重要的 J2EE 集群应用服务器的环境中。它还支持多种数据库,可部署在任何应用服务器上。

与 BPEL 不同的是,BPEL 与 Web services 是紧耦合的(在 BPEL 中,每个活动都必须作为 Web service 14 而实现),而 JBPM 更像是一个组件框架 15 ,允许直接调用 Java 处理器(类似于 ESB 服务调用管道)。

如果已经存在一个服务执行管道,那为什么还需要额外的服务编排机制呢?

看起来,服务管道和服务编排之间存在很多重叠。本质上,服务编排是一个有序的 action 编排。但它不支持最常见的编排功能,比如,决定(decisions),条件转换(conditional transitions)和并行执行(parallel execution)。虽然在技术上可以实现这些功能,并作为管道定义中的一部分,但实现起来并不容易。我们更倾向于将基本的业务功能和额外的基础设施(包括,数据转换,执行监控等)结合在一起时使用服务管道,而编排服务时使用 jBPM。

部署所需要考虑的问题。如果在很多用例中都用到了同一个动作,那么可以将该动作分离出来,作为一个单独的服务,因此部署时只用部署一次,并可以用于 jBPM 的服务编排中。

JPDL 主要由节点和执行上下文组成。JPDL 定义了如下类型的节点:

  • Start Node —— 启动流程
  • Task —— 人工活动
  • State —— 等待状态
  • Node —— 自定义的执行代码
  • Decision —— 基于流程变量的决定
  • Fork —— 在多个路径中分离执行
  • Join —— 汇合多个执行路径
  • Transitions —— 节点之间的连线
  • SuperState —— 聚合多个节点
  • End Node —— 流程结束

执行上下文(execution context)和 HTTP 的 session 有些类似,它包含一系列的命名对象——它与先前介绍的 ESB 消息体有些类似。任何节点都可以访问执行上下文,可以读、写上下文的变量。执行状态被持久化到数据库中——当 JPDL 流程处于等待状态时,它的上下文(上下文变量)被存储到数据库中。当流程再次被激活时,从数据库中读取上下文变量的值,重新创建上下文。

节点中装载 JPDL 流程(在服务编排中)。节点只是执行一个动作处理器——一个实现了 org.jbpm.graph.def.ActionHandler 接口的类。节点动作处理器和服务动作处理器相似,都是可配置的,只是使用不同的配置方法。任何定义在动作处理器中的 public/private 变量都可以通过流程定义进行配置。

异步执行

通过配置节点的节点类型来指定异步执行。注意,JPDL 中的异步执行并不是使用线程实现的,而采用的是队列机制。如果节点执行采用异步调用,那么流程的当前状态会被持久化到数据库中。通过 org.jbpm.job.executor.JobExecutor 类可以继续执行流程。运行这个类的标准配置方法是在单独的 WAR 中配置 org.jbpm.job.executor.JobExecutorServlet servlet。在本文中,我们不使用这种方法。而是通过从 JPDL 中调用 ESB 服务来实现异步执行(详细内容如下)。

Decision 节点是另一种节点,它支持自定义实现。只需要实现接口 org.jbpm.graph.node.DecisionHandler 即可。当实现 Node 功能时,可以像配置 Action 处理器一样配置 Decision 处理器。

在 JPDL 中使用 Fork/Join 节点可以实现并行执行。与 Fork 和 Join 节点相连接的路径可以并行实现。

Fork/Join 执行

JPDL 中的 Fork 并不是基于线程实现的。也就是说当 transitions 离开 Fork 节点时,Fork 的实现并不为它们创建线程。只为每个 transition 创建令牌。因为流程执行是单线程的,所有的令牌将顺序执行。实现 Fork/Join 并行执行的一种方法是通过从 JPDL 中调用 ESB 服务(详细内容如下)。

循环在服务编排中使用得非常广泛,但 JPDL 却不支持它。顺序的循环可以通过使用 Decision 节点和计算循环变量实现,更为复杂(在服务编排中非常普遍)的场景是并行循环——运行时计算 transition 路径数量的 Fork/Join。在 JPDL 中并不支持这种模式,但是定义两个自定义的处理器就可以很简单地实现这个功能(见附录中的列表 1,列表 2)。

这些处理器的实现都是基于 JPDL Fork/Join 而实现的。Start 处理器创建多个令牌,并启动它们。End 处理器等待所有的子令牌完成,然后 transition 到下个节点。

异常处理

流程执行时会产生异常。JPDL 中可以为每个流程节点定义异常处理器。异常处理器也是一种 action 处理器,当节点执行时出现异常,它将会被调用。附录中列举了一个简单的异常处理器的例子。该异常处理器输出异常信息,然后在目的变量中将异常信息过渡到特定的节点。

部署流程

JPDL 的流程定义并不是作为 JBoss 应用部署的,而是保存在流程数据库中。

JBPM/ESB 整合

JBoss ESB 和 JBPM 都是非常强大的软件平台,但是如果将 ESB/JBPM 整合起来,那么功能更强大 16

两种 ESB/JBPM 整合类型:

  • 将业务流程暴露为服务
  • 从业务流程中调用服务

JBoss/JBPM 整合提供了一个特殊的 action 处理器——BpmProcessor,它通过调用 jBPM 的命令 API 与 jBPM 交互。它以一个流程定义名称作为参数,就可以创建和启动一个流程实例。此时,流程在单线程中异步执行,也就是说当流程运行时,服务会返回一个应答给服务调用者。

这种整合也实现了两个 jBPM action 处理器类——EsbActionHandler 和 EsbNotifier。EsbActionHandler 是一种 request- reply 类型的 action,它将消息发送到服务总线上,然后等待响应。其架构如图 6 所示。EsbActionHandler 将请求消息发送给用户服务,并将流程变为等待状态。当用户服务执行完后,它会调用一个特殊的 JBPM 服务,该服务通知等待流程继续执行。

图 6 JBPM/ESB 整合架构

每种集成类型都为流程中的多个提供了异步执行(比如,fork/join 或者并行循环的执行见上文)。

而 EsbNotifier 只需要将消息发送给服务,可以继续自己的流程。它与 JBossESB 的交互本质上是异步的,当服务执行时,不会阻塞流程实例的运行。

流程的同步调用

如上所述,BpmProcessor 异步的调用业务流程,这在服务编排时,并不总是可取的;流程执行生成的内容会用于服务响应。另外,ESB/JBPM 集成还支持如下场景 17

BpmProcessor 支持一个额外的配置参数——reply-to-originator。当这个参数为 true 时,BPMProcessor 会将服务调用的 ReplyTo 信息保存在新创建的流程实例的 JBPM 执行上下文中。 EsbNotifier 将 reply-to-originator 作为 notifier 的目的地,使用 ReplyTo 传递流程执行的结果。为了达到这种效果,服务的调用流程必须定义为 MEP 的 Oneway 方式。

异常处理

当在业务流程中调用服务时,异常并不仅仅出现在节点执行(服务调用)中,而且会出现在被调用服务的执行中。这就需要一种特殊的异常处理器。该处理器作为 BpmProcessor 的一部分而定义,它可以控制服务调用的 transition——在成功或者异常时的不同 transition。

JBPM 工具

JPDL 是一种基于 XML 的语言,可以通过 XML 表示。为了简化业务流程的创建和分析,JBoss 提供了一个 Eclipse 插件,通过它可以可视化的创建和维护 JPDL 流程(如图 7 所示)。

该编辑器即支持图形化的业务流程视图,也支持 XML 格式的业务流程视图。也可以直接在 Eclipse 中将流程部署到 JBoss 服务器。

图 7 JPDL 编辑器

整体评估

JBoss ESB/jBPM 整合为基于已有企业资源创建面向服务的解决方案提供了一个非常强大的、可扩展的和灵活的平台。它为这些解决方案的实现提供了所有主要组件,包括:

  • 灵活的 ESB 平台,可以很容易地将现有功能和其他业务以及服务实现(服务管道)所需的基础设施流程相结合起来。
  • 强大的转换引擎(Smooks),简化了将私有数据模型(现有功能所使用的)转换为解决方案中所使用的语义数据模型。支持可视化转换定义的工具进一步简化数据转换。
  • 轻量级的可扩展的 jBPM 促进了构建基于现有 NAVTEQ 功能的组合服务。

本文所介绍的 JBoss 中间件是基于 NAVTEQ 构建的个别 VMS 解决方案的原型。该原型基于特定领域模型,用于管理用户、位置和路由,还包括一些 ESB 服务,这些服务都是对现有 Web service 的包装,并且服务直接在 SOA 平台中实现。它也包括数个使用 jBPM 实现的组合(还有分层次的组合 18 ) 服务。

我们正在提高 JBoss 中间件的性能和稳定性——基于解决方案和使用监控和管理 SOA 解决方案的 JBoss Operations Network 19 (JON)。

感谢

非常感谢我的 NAVTEQ 同事们,尤其是 Robert Camp,Ian Mondragon 和 Jeffrey Herr,还有 JBoss 解决方案架构师们,尤其是 Ray Ploski 和 Aaron Pestel,他们实现了本文中提到的原型并描述了他们的结果。

附录. 代码

package com.navteq.jbpm.parallel;


import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import org.jbpm.graph.def.ActionHandler;
import org.jbpm.graph.def.Node;
import org.jbpm.graph.def.Transition;
import org.jbpm.graph.exe.ExecutionContext;
import org.jbpm.graph.exe.Token;

 /**

* specifies configurable parallel execution behaviour.

*

*/

public class ParallelStart implements ActionHandler { private static final long serialVersionUID = 1L; // The name of the variable holding loop count private String loopCount; // The name of the variable current count (stored in the token variable scope) private String currentCount; // The list of the array variables. Each array has to be of the of the loop count

// size. An appropriate element is stored in the token context scope
private Map

Listing 1 Start Parallel execution handler

package com.navteq.jbpm.parallel;

import java.lang.reflect.Array;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
import org.hibernate.LockMode;
import org.hibernate.Session;
import org.jbpm.JbpmContext;
import org.jbpm.graph.def.ActionHandler;
import org.jbpm.graph.exe.ExecutionContext;
import org.jbpm.graph.exe.Token;

public class ParallelEnd implements ActionHandler {

	private static final long serialVersionUID = 1L;

	/** specifies wether what type of hibernate lock should be acquired.  null value defaults to LockMode.force */
	String parentLockMode;
	// The name of the variable holding loop count
	private String loopCount;
	// The name of the variable current count (stored in the token variable scope)
	private String currentCount;
	// The list of the array variables. Each array has to be of the of the loop count
	// size. An appropriate element is stored in the token context scope
	private Map

Listing 2 End Parallel execution handler

package com.navteq.jbpm.actionHandlers;

import org.jbpm.graph.def.ActionHandler;
import org.jbpm.graph.def.Node;
import org.jbpm.graph.exe.ExecutionContext;
import org.jbpm.graph.exe.Token;

public class ExceptionActionHandler implements ActionHandler {

	private static final long serialVersionUID = 1L;

	private String destination;

	@Override
	public void execute(ExecutionContext context) throws Exception {

		Token token = context.getToken();
		Node sourceNode = token.getNode();
		Throwable throwable = context.getException();
		System.out.println("Caught Exception " + throwable.getMessage() + " in node " + sourceNode.getName());
		Node targetNode = context.getProcessDefinition().getNode(destination);
		token.setNode(targetNode);
		token.signal();
	}

列表 3 异常处理器


查看英文原文: Using JBoss ESB and JBPM for Implementing VMS Solutions

译者简介:

陈义,计算机应用技术专业硕士研究生,一直专注于 SOA、BPM、ESB、EAI 和 MOM 的研究及应用。热衷于开源 SOA 项目,有志致力于 Mule、ServiceMix、ODE、ActiveBPEL、ActiveMQ、OpenJMS、Camel、CXF、XFire 以及 Tuscany 在中文社区的研究和推广工作。您可以通过 honnom (at) 163.com 联系到他。


感谢胡键对本文的审校。

给 InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家加入到 InfoQ 中文站用户讨论组中与我们的编辑和其他读者朋友交流。

评论

发布