SpringBoot2实践系列(五十九):集成CXF实现WebService详解

star2017 1年前 ⋅ 343 阅读

前言:很早之前有接触和开发过Web Service 服务,但近些年在互联网行业几乎看不到 Web Service 服务了,互联网行业几乎都采用 HTTP + JSON 对外提供数据服务。

但并不意味着 Web Service 已消失(迟早的事),一些传统垂直行业的系统仍然使用 Web Service。

  • 一是这类系统一经部署就很难更改和替换,因为更换的成本和风险非常高,高到难以接受,就导致市场固化,新兴企业更好的技术更优的产品就难以打入该行业市场;

  • 二是提供该类系统的服务商和甲方并不会主动也没有意愿去更换系统,只要能满足业务需要,更多的是在上面堆叠新的功能。

    与开发新系统相比,收入固定的,但付出的成本是最低,也就没有内在的驱动力去研发新技术和新产品了。

    提供垂直行业信息系统的服务商实际是不多的,行业领域内可能就那么几家,采用的技术和提供的功能都可能是相似的(可能来自某一头部企业离职人员创业开发)。

随着业务的发展,这类系统必然存在局限性的。在技术层面是没有跟上行业技术发展的,在业务层面是也难以满足新形态的业务需求。

那更换该类系统的驱动可能需要来自顶层设计,例如,提出行业新的概念,制定准入规则;或大型IT企业切入该行业市场,对行业提出新的解读并提供整套更优的解决方案,从外部提供更多的赋加值,提供全方位的支持和补贴等。

例如,医院的 HIS(医院信息系),10年前的系统大把的,大量外围业务系统和服务商依赖于它。

近期项目需要对接HIS,花了点时间重新研究了下 Web Service 的应用,在此做个记录

Web Service

Web Service 相关概念不做过多描述,可参考 百度百科-Web Service

Web Service 体系架构有三个角色:

  • 服务提供者(Provide):发布服务,服务提供方,提供服务和服务描述,向UDDI注册中心注册;响应消费者的服务请求。
  • 服务消费者(Consumer):消费服务,服务的请求方,向UDDI注册中心查询服务,拿到返回的描述信息生成相应的 SOAP 消息,发送给服务提供者,实现服务调用。
  • 服务注册中心(Register):服务注册中心(UDDI)。

Axis2与CXF

基于 Java 开发 Web Service 的框架主要有 Axis2CXF。如果需要多语言的支持,应该选择 Axis2;如果实现侧重于 Java 并希望与 Spring 集成,特别是把 WebService 嵌入到其他程序中,CXF 是更好的选择,CXF 官方为集成 Spring 提供了方便。

Apache CXF 官网:http://cxf.apache.org/

Axis2 与 CXF 区别:

区别 Axis2 CXF
简述 Web Service 引擎 轻量级的SOA框架,可以作为ESB(企业服力总线)
Sprign 集成 不支持 支持
应用集成 困难 简单
跨语言
部署方式 Web应用 嵌入式
服务的管控与治理 支持 不支持

Web Service 四个注解

JDK 为 Web Service 提供了四个注解:@WebService,@WebMethod,@WebParam,@WebResult,位于 JDK 的 javax.jws 包下。

@WebService

作用在接口或实现类上

  • 作用在类上是声明一个 Web Service 服务实现。
  • 作用在类上是定义一个 Web Service 端点接口。

原码

package javax.jws;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE})
public @interface WebService {
    String name() default "";

    String targetNamespace() default "";

    String serviceName() default "";

    String portName() default "";

    String wsdlLocation() default "";

    String endpointInterface() default "";
}

属性

  • name:String,指定 Web Service 的名称,映射到 WSDL 1.1 的 wsdl:portTypename,默认值为 Java 类或接口的非限定名称。

    <wsdl:portType name="HelloMessageServer">....</wsdl:portType>
    
  • serviceName: String,指定 Web Server 的服务名(对外发布的服务名),映射到 WSDL 1.1 的 wsdl:servicename。在端点接口上不允许有此成员值。

    默认值为端点接口实现类的非限定类名 + Service。例如, HelloServiceImplService。

    <wsdl:service name="HelloServiceImplService">...</wsdl:service>
    
  • portName:String,指定 Web Service 的端口名,映射到 WSDL 1.1 的 wsdl:portname。在端点接口上不允许有此成员值。默认值为 WebService.name+Port。例如,HelloMessageServerPort。

    <wsdl:service name="HelloServiceImplService">
        <wsdl:port binding="tns:HelloServiceImplServiceSoapBinding" name="HelloMessageServerPort">
            <soap:address location="http://localhost:8080/services/ws/hello"/>
        </wsdl:port>
    </wsdl:service>
    
  • targetNamespace:String,指定名称空间,默认是 http:// + 端点接口的包名倒序,映身到 wsdl 的 wsdl:definitionsxs:schema标签的targetNamespacexmlns:tns

    <wsdl:definitions xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/" xmlns:tns="http://tempuri.org" xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/" xmlns:ns1="http://schemas.xmlsoap.org/soap/http" name="HelloServiceImplService" targetNamespace="http://tempuri.org">
        <wsdl:types>
            <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:tns="http://tempuri.org" attributeFormDefault="unqualified" elementFormDefault="qualified" targetNamespace="http://tempuri.org" version="1.0">
                ...
            </xs:schema>
            ...
        </wsdl:types>
        ...
    </wsdl:definitions>
    
  • endpointInterface: String,指定端点接口的全限定名。如果是没有接口,直接写实现类的,该属性不用配置。

    此属性允许开发人员将接口实现分离。如果存在此属性,则服务端点接口将用于确定抽象 WSDL 约定(portType 和 bindings)。

    服务端点接口可以包括 JSR-181 注解 ,以定制从 Java 到 WSDL 的映射。

    服务实现 bean 可以实现服务端点接口,但不是必须。

    如果此成员值不存在,则从服务实现 bean 上的注释生成 Web Service 约定。如果目标环境需要服务端点接口,则将其生成到一个实现定义的包中,并具有一个实现定义的名称。

    在端点接口上不允许此成员值。

  • wsdlLocation:String,指定 Web Service 的 WSDL 描术文档的 Web 地址(URL),可以是相对路径或绝对路径。

    wsdlLocation 值的存在指示 服务实现 Bean 正在实现预定义的 WSDL 约定。 如果服务实现 bean 与此WSDL 中声明的 portType 和 bindings 不一致,则 JSR-181 工具必须提供反馈。

    请注意,单个 WSDL 文件可能包含多个 portType 和多个 bindings。 服务实现 bean 上的注释确定特定的portType 和与 Web Service 对应的 bindings。

注意:实现类上可以不添加 Webservice 注解。另 @WebService.targetNamespace 注解的使用官方还有如下说明,待深入理解:

  • 如果 @WebService.targetNamespace 注解作用在服务端点接口上,targetNamespace 被 wsdl:portTypenamespace使用(并关联 XML 元素)。

  • 如果 @WebService.targetNamespace 注解作用在服务实现的 Bean 上,没有引用服务端点接口(通过 endpointInterface 属性),targetNamespace 被 wsdl:portTypewsdl:service使用(并关联 XML 元素)。

  • 如果 @WebService.targetNamespace 注解作用在服务实现的 Bean 上,引用了服务端点接口(通过 endpointInterface 属性),targetNamespace 只被 wsdl:service使用(并关联 XML 元素)。

@WebMethod

作用在使用了 @WebService 注解的接口实现类中的方法上。

定义一个暴露为 Web Service 的操作方法。方法必须是 public,其参数返回值,异常必须遵循JAX-RPC 1.1 第5 节中定义的规则,方法不需要抛出 java.rmi.RemoteException 异常。

原码

package javax.jws;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface WebMethod {
    String operationName() default "";

    String action() default "";

    boolean exclude() default false;
}

属性

  • action:String,此操作的行为。

    对于 SOAP 绑定,这决定了 soapAction 的值。

    <soap:operation soapAction="" style="document"/>
    
  • exclude:boolean,标记方法不暴露为 Web Service 的方法。默认值是 false,即不排除。

    用于停止继承的方法作为 Web Service 的一部分暴露公开。如果指定了此属性,则不能为 @WebMethod 指定其他元素。

    该成员属性不允许用在端口方法上。

  • operationName:String,匹配 wsdl:operationname,默认为方法名。

    <wsdl:portType name="HelloMessageServer">
        <wsdl:operation name="helloMessageServer">
            <wsdl:input message="tns:helloMessageServer" name="helloMessageServer"> </wsdl:input>
            <wsdl:output message="tns:helloMessageServerResponse" name="helloMessageServerResponse"> </wsdl:output>
            <wsdl:fault message="tns:Exception" name="Exception"> </wsdl:fault>
        </wsdl:operation>
    </wsdl:portType>
    

@WebResult

定制返回值到 WSDL part 和 XML 元素的映射。

<wsdl:message name="Exception">
    <wsdl:part element="tns:Exception" name="Exception"> </wsdl:part>
</wsdl:message>
<wsdl:message name="helloMessageServerResponse">
    <wsdl:part element="tns:helloMessageServerResponse" name="parameters"> </wsdl:part>
</wsdl:message>
<wsdl:message name="helloMessageServer">
    <wsdl:part element="tns:helloMessageServer" name="parameters"> </wsdl:part>
</wsdl:message>

原码

package javax.jws;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface WebResult {
    String name() default "";

    String partName() default "";

    String targetNamespace() default "";

    boolean header() default false;
}

属性

  • name:String,返回值的名称。

    如果 operation 是 rpc 风格,且 @WebResult.partName 未指定,此返回值则代表 wsdl:partname 值。

    如果 operation 是 document 风格,或者返回值映射到 header,则代表 XML 元素的 local name 值。

  • partName:String,此返回值表示的 wsdl:partname
    仅当 operation 为 rpc 风格,或 operation 为 document 风格且参数风格为 BARE 时才使用此选项。

  • targetNamespace:String,该返回值的 XML 名称空间(namespace)。

    仅当 operation 为 document 风格 或 返回值映射到 header 时。当 target namespace 设置为空字符串( ""),该值表示为空名称空间(empty namespace)。

  • header:boolean,默认 false。如果为 true,则从消息头而不是消息正文中提取结果。

@WebParam

自定义 @WebMethod注解作用的方法的一个参数到 Web Service message part 和 XML element 的映射。

原码

package javax.jws;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.PARAMETER})
public @interface WebParam {
    String name() default "";

    String partName() default "";

    String targetNamespace() default "";

    WebParam.Mode mode() default WebParam.Mode.IN;

    boolean header() default false;

    public static enum Mode {
        IN,
        OUT,
        INOUT;

        private Mode() {
        }
    }
}

属性

  • header:boolean,默认 false。如果为 true,则从消息头而不是消息正文中提取结果。

  • mode:WebParam.Mode,参数的流动方向,枚举值有:IN,OUT,INOUT。

    只能为符合 Holder 类型定义的参数类型指定 OUT 和 INOUT(JAX-WS 2.0 [5], section 2.3.3) 。

    Holder 类型的参数必须指定为 OUT 或 INOUT。

  • name:String,参数名

    如果 operation 是 rpc 风格的,且未指定 @WebParam.partName,则表示wsdl:partname
    如果 operation 是 document 风格,或者参数映射到 header,则表示 XML element 的 local name。

    如果 operation 是 document 风格,参数风格是 BARE,并且模式是 OUT 或 INOUT,则必须指定名称。

  • partName:String,wsdl:partname代表此参数。

    仅当 operationrpc 风格 或 operation 为 document 风格,且参数风格为 BARE 时才使用此选项。

  • targetNamespace:String,参数的 XML 名称空间(namespace)。

    仅当 operation 是 document 风格,或参数映射到 header 时才使用。

    如果 target namespace 设置为 "",则表示空名称空间。

Web Service 配置

Bus 配置

Apache CXF 核心架构是以 BUS 为核心,整合其他组件。为共享资源提供一个可配置的场所,作用类似于 Spring 的 ApplicationContext,这些共享资源包括 WSDL管理器、绑定工厂等。通过对BUS进行扩展,可以方便地容纳自己的资源,或者替换现有的资源。

默认 Bus 实现基于 Spring 架构,通过依赖注入,在运行时将组件串联起来。BusFactory 负责 Bus 的创建。默认的BusFactory 是 SpringBusFactory,对应于默认的 Bus 实现。在构造过程中,SpringBusFactory 会搜索 META-INF/cxf(包含在 CXF 的jar中)下的所有bean配置文件。根据这些配置文件构建一个 ApplicationContext。开发者也可以提供自己的配置文件来定制 Bus。

Bus 是 CXF 的主干。它管理扩展并充当拦截器提供者。Bus 的拦截器将被添加到在 Bus上(在其上下文中)创建的所有客户端和服务器端点的相应入站出站消息和错误拦截器链中。

默认情况下,它不会向这些拦截器链类型中的任何一个提供拦截器,但是可以通过 配置文件 或 Java 代码添加拦截器。

CXF 基于 Spring Boot 的 starter (cxf-spring-boot-starter-jaxws)包的自动配置默认注册了 SpringBus Bean,所以手动注册 Bus 可以省略。

自动配置SpringBus Bean

@Configuration
@ConditionalOnMissingBean(SpringBus.class)
@ImportResource("classpath:META-INF/cxf/cxf.xml")
protected static class SpringBusConfiguration {

}

classpath:META-INF/cxf/cxf.xml,文件位于 org.apache.cxf:cxf-core:version/META-INF/cxf/cxf.xm

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:context="http://www.springframework.org/schema/context"
    xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd">
    <!--  For Testing using the Spring commons processor, uncomment one of:-->
    <!-- 
                <bean class="org.springframework.context.annotation.CommonAnnotationBeanPostProcessor"/>
                <context:annotation-config/>
        -->
    <bean id="cxf" class="org.apache.cxf.bus.spring.SpringBus" destroy-method="shutdown"/>
    <bean id="org.apache.cxf.bus.spring.BusWiringBeanFactoryPostProcessor" class="org.apache.cxf.bus.spring.BusWiringBeanFactoryPostProcessor"/>
    <bean id="org.apache.cxf.bus.spring.Jsr250BeanPostProcessor" class="org.apache.cxf.bus.spring.Jsr250BeanPostProcessor"/>
    <bean id="org.apache.cxf.bus.spring.BusExtensionPostProcessor" class="org.apache.cxf.bus.spring.BusExtensionPostProcessor"/>
</beans>

注册 SpringBus Bean

// 可省略
@Bean(name = Bus.DEFAULT_BUS_ID)
public SpringBus springBus() {
    return new SpringBus();
}

Java 配置拦截器,如下

import javax.xml.ws.Endpoint;
import org.apache.cxf.interceptor.LoggingInInterceptor;
import org.apache.cxf.interceptor.LoggingOutInterceptor;
import org.apache.cxf.jaxws.EndpointImpl;

Object implementor = new GreeterImpl();
EndpointImpl ep = (EndpointImpl) Endpoint.publish("http://localhost/service", implementor);

ep.getInInterceptors().add(new LoggingInInterceptor());
ep.getOutInterceptors().add(new LoggingOutInterceptor());

Endpoint 配置

创建端点,通过端点发布服务。

@Bean
public Endpoint endpoint() {
    EndpointImpl endpoint = new EndpointImpl(springBus(), helloService);
    endpoint.publish("/ws/hello");;
    return endpoint;
}

Endpoint 是个抽象类,表示一个 Web Service 端点。

Endpoints 通过内部定义的静态方式来创建,端点始终与一个 Binding 和 一个实现绑定,两者在创建端点时设置。

端点状态要么是 已发布未发布publish方法可用于开始发布端点,此时端点开始接受传入的请求。

可以在端点上设置 Executor,以便更好地控制用于调度(dispatch)的传入请求的线程。例如,可以通过 ThreadPoolExecutor 并将其注册到端点来启用具有某些参数的线程池。

可以通过端点获取 Binding 来设置处理链(handler chain)。

端点可以有一个元数据文档列表,比如 WSDL 和 XMLSchema 文档,绑定到它。在发布时,JAX-WS 实现将尽可能多地重用元数据,而不是基于实现者上的注释生成新的元数据。

EndpointImpl

是 Endpoint 端点的默认实现,端点的很多属性都靠 EndpointImpl 来设置。

注册CXF Servlet Bean

CXF 基于 Spring Boot 的 starter (cxf-spring-boot-starter-jaxws)包的自动配置默认注册了 ServletRegistrationBean,所以手动注册可以省略。

ServletRegistrationBean 作用

  • 用于在 Servlet 3.0+ 容器中注册一个 CXFServlet

  • 设置请求URL前缀映射,默认是 /services,可以在 application.properties 配置文件中自定义

    #wsdl访问地址为 http://127.0.0.1:8080/services/xxxx?wsdl
    cxf.path=/services
    

    也可以关闭前缀映射,ServletRegistrationBean 构造方法提供了控制前缀开关的参数 alwaysMapUrl,默认为 true,即使用前缀映射;可以手动配置注册 ServletRegistrationBean Bean,设置 alwaysMapUrl=false 关闭前缀映射。

    public ServletRegistrationBean(T servlet, boolean alwaysMapUrl, String... urlMappings) {
        Assert.notNull(servlet, "Servlet must not be null");
        Assert.notNull(urlMappings, "UrlMappings must not be null");
        this.servlet = servlet;
        this.alwaysMapUrl = alwaysMapUrl;
        this.urlMappings.addAll(Arrays.asList(urlMappings));
    }
    
  • 设置容器加载 Servlet 的优先顺序,必须在调用 onStartup 之前指定 Servlet。

    cxf.servlet.load-on-startup=-1
    

ServletRegistrationBean 自动配置:

@Bean
@ConditionalOnMissingBean(name = "cxfServletRegistration")
public ServletRegistrationBean<CXFServlet> cxfServletRegistration() {
    String path = this.properties.getPath();
    String urlMapping = path.endsWith("/") ? path + "*" : path + "/*";
    ServletRegistrationBean<CXFServlet> registration = new ServletRegistrationBean<>(
        new CXFServlet(), urlMapping);
    CxfProperties.Servlet servletProperties = this.properties.getServlet();
    registration.setLoadOnStartup(servletProperties.getLoadOnStartup());
    for (Map.Entry<String, String> entry : servletProperties.getInit().entrySet()) {
        registration.addInitParameter(entry.getKey(), entry.getValue());
    }
    return registration;
}

手动注册 ServletRegistrationBean:

@Bean
public ServletRegistrationBean dispatcherServlet() {
    return new ServletRegistrationBean(new CXFServlet(), "/soap/*");
}

默认前缀是 /services,wsdl 访问地址为 http://127.0.0.1:8080/services/ws/api?wsdl,注意 /ws/api是服务暴露的访问端点。

上面改为手动注册后,wsdl 的访问地址为 http://127.0.0.1:8080/soap/ws/api?wsdl,列出服务列表:http://127.0.0.1:8080/soap/

如果是自定义 Servlet 实现,需要在启用类添加注解:@ServletComponentScan。如果启动时出现错误:

not loaded because DispatcherServlet Registration found non dispatcher servlet dispatcherServlet

可能是 springboot 与 cfx 版本不兼容。

Web Service Server

Web Service 服务端的本质是接收符合 SOAP 规范的 XML消息,解析 XML 进行业务处理,返回符合 SOAP 规范的 XML。

基于 Spring Boot + CXF 实现 Web Service 服务提供者。

添加依赖

基于 Spring Boot,在 pom.xml 添加依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web-services</artifactId>
</dependency>
<dependency>
    <groupId>org.apache.cxf</groupId>
    <artifactId>cxf-spring-boot-starter-jaxws</artifactId>
    <version>3.4.2</version>
</dependency>
<dependency>
    <groupId>org.apache.cxf</groupId>
    <artifactId>cxf-rt-transports-http</artifactId>
    <version>3.4.2</version>
</dependency>
<!--common utils start-->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.67</version>
</dependency>
<dependency>
    <groupId>commons-io</groupId>
    <artifactId>commons-io</artifactId>
    <version>2.8.0</version>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
    <version>3.10</version>
</dependency>
<dependency>
    <groupId>commons-beanutils</groupId>
    <artifactId>commons-beanutils</artifactId>
    <version>1.9.4</version>
</dependency>
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.5.1</version>
</dependency>
<!--common utils end-->

服务接口

/**
 * @desc 服务端点接口
 */
@WebService(name = "HelloMessageServer", targetNamespace = "http://tempuri.org")
public interface HelloService {
    @WebMethod
    Object helloMessageServer(@WebParam(name = "input1") String input1,
                              @WebParam(name = "input2") String input2)
            throws Exception;
}

接口实现

/**
 * @desc 服务实现
 */
@Service
@WebService(name = "HelloMessageServer", targetNamespace = "http://tempuri.org", 
        endpointInterface = "com.webservice.service.HelloService")
public class HelloServiceImpl implements HelloService {

    @Override
    public Object helloMessageServer(String input1, String input2) throws Exception {
        return input1 + "," + input2;
    }
}

发布服务

package com.webservice.config;

import com.webservice.service.HelloService;
import org.apache.cxf.Bus;
import org.apache.cxf.bus.spring.SpringBus;
import org.apache.cxf.jaxws.EndpointImpl;
import org.apache.cxf.transport.servlet.CXFServlet;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.servlet.Servlet;
import javax.xml.ws.Binding;
import javax.xml.ws.Endpoint;

/**
 * @author gxing
 * @desc Web Service Config
 * @date 2021/2/26
 */
@Configuration
public class WebServiceConfig {

    // 服务端点
    @Autowired
    private HelloService helloService;

    /**
     * 自定义 Spring Bus, 可省略, 自动配置已注册
     * @return SpringBus
     */
    @Bean(name = Bus.DEFAULT_BUS_ID)
    public SpringBus springBus() {
        return new SpringBus();
    }

    /**
     * 自定义CXF Servlet,设置前缀, 默认是 /services,
     * 可省略,直接在 application.properties中设置 cxf.path=/services
     * @return ServletRegistrationBean
     */
/*    @Bean
    public ServletRegistrationBean<Servlet> dispatcherServlet() {
        return new ServletRegistrationBean<>(new CXFServlet(), "/soap/*");
    }*/

    @Bean
    public Endpoint endpoint() {
        EndpointImpl endpoint = new EndpointImpl(springBus(), helloService);
        // 发布服务
        endpoint.publish("/ws/hello");;
        return endpoint;
    }
}

查看 wsdl

WSDL:http://localhost:8080/services/ws/hello?wsdl

<wsdl:definitions xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/" xmlns:tns="http://tempuri.org" xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/" xmlns:ns1="http://schemas.xmlsoap.org/soap/http" name="HelloServiceImplService" targetNamespace="http://tempuri.org">
    <wsdl:types>
        <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:tns="http://tempuri.org" attributeFormDefault="unqualified" elementFormDefault="qualified" targetNamespace="http://tempuri.org" version="1.0">
            <xs:element name="helloMessageServer" type="tns:helloMessageServer"/>
            <xs:element name="helloMessageServerResponse" type="tns:helloMessageServerResponse"/>
            <xs:complexType name="helloMessageServer">
                <xs:sequence>
                    <xs:element minOccurs="0" name="input1" type="xs:string"/>
                    <xs:element minOccurs="0" name="input2" type="xs:string"/>
                </xs:sequence>
            </xs:complexType>
            <xs:complexType name="helloMessageServerResponse">
                <xs:sequence>
                    <xs:element minOccurs="0" name="return" type="xs:anyType"/>
                </xs:sequence>
            </xs:complexType>
            <xs:element name="Exception" type="tns:Exception"/>
            <xs:complexType name="Exception">
                <xs:sequence>
                    <xs:element minOccurs="0" name="message" type="xs:string"/>
                </xs:sequence>
            </xs:complexType>
        </xs:schema>
    </wsdl:types>
    <wsdl:message name="Exception">
        <wsdl:part element="tns:Exception" name="Exception"> </wsdl:part>
    </wsdl:message>
    <wsdl:message name="helloMessageServerResponse">
        <wsdl:part element="tns:helloMessageServerResponse" name="parameters"> </wsdl:part>
    </wsdl:message>
    <wsdl:message name="helloMessageServer">
        <wsdl:part element="tns:helloMessageServer" name="parameters"> </wsdl:part>
    </wsdl:message>
    <wsdl:portType name="HelloMessageServer">
        <wsdl:operation name="helloMessageServer">
            <wsdl:input message="tns:helloMessageServer" name="helloMessageServer"> </wsdl:input>
            <wsdl:output message="tns:helloMessageServerResponse" name="helloMessageServerResponse"> </wsdl:output>
            <wsdl:fault message="tns:Exception" name="Exception"> </wsdl:fault>
        </wsdl:operation>
    </wsdl:portType>
    <wsdl:binding name="HelloServiceImplServiceSoapBinding" type="tns:HelloMessageServer">
        <soap:binding style="document" transport="http://schemas.xmlsoap.org/soap/http"/>
        <wsdl:operation name="helloMessageServer">
            <soap:operation soapAction="" style="document"/>
            <wsdl:input name="helloMessageServer">
                <soap:body use="literal"/>
            </wsdl:input>
            <wsdl:output name="helloMessageServerResponse">
                <soap:body use="literal"/>
            </wsdl:output>
            <wsdl:fault name="Exception">
                <soap:fault name="Exception" use="literal"/>
            </wsdl:fault>
        </wsdl:operation>
    </wsdl:binding>
    <wsdl:service name="HelloServiceImplService">
        <wsdl:port binding="tns:HelloServiceImplServiceSoapBinding" name="HelloMessageServerPort">
            <soap:address location="http://localhost:8080/services/ws/hello"/>
        </wsdl:port>
    </wsdl:service>
</wsdl:definitions>

elementFormDefault

WSDL 的 schema 中的 elementFormDefault 有两种值,默认是 unqualified的,表示入参报文是不受限的。

elementFormDefault="qualified"时,表示入参的 XML 报文是受限的,即入参子标签是需要带前缀。

如下,入参 input 标签 tem就是前缀。

<SOAP-ENV:Envelope
    xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"
    xmlns:tem="http://tempuri.org">
    <SOAP-ENV:Body>
        <tem:helloMessageServer>
            <tem:input2>Hello</tem:input2>
            <tem:input1>World</tem:input1>
        </tem:helloMessageServer>
    </SOAP-ENV:Body>
</SOAP-ENV:Envelope>

Spring Boot 集成的 Web Service 服务,要将 WSDL 中的 elementFormDefault 设置为 qualified的实现方式是比较另类的。

暴露端点接口的包中创建一个普通文件,文件名必须是 package-info.java,注意不是 java 文件,然后编辑文件内容如下:

@javax.xml.bind.annotation.XmlSchema(namespace = "http://tempuri.org",
        attributeFormDefault = javax.xml.bind.annotation.XmlNsForm.QUALIFIED,
        elementFormDefault = javax.xml.bind.annotation.XmlNsForm.QUALIFIED)
package com.webservice.service;

attributeFormDefault 设置好像不起作用。

请求接口

使用 postman 发送请求 Web Service 暴露的端口接口:

请求方式:POST;请求体 Body:选择 raw 类型,XML 格式。

URL:http://localhost:8080/services/ws/hello

<SOAP-ENV:Envelope
    xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"
    xmlns:tem="http://tempuri.org">
    <SOAP-ENV:Body>
        <tem:helloMessageServer>
            <tem:input2>Hello</tem:input2>
            <tem:input1>World</tem:input1>
        </tem:helloMessageServer>
    </SOAP-ENV:Body>
</SOAP-ENV:Envelope>

响应结果:

<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
    <soap:Body>
        <helloMessageServerResponse xmlns="http://tempuri.org">
            <return xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xs="http://www.w3.org/2001/XMLSchema" xsi:type="xs:string">Hello,World</return>
        </helloMessageServerResponse>
    </soap:Body>
</soap:Envelope>

Web Service Client

添加依赖

与 Web Service Server 中的依赖一致。

入参实体

/**
 * @desc Service 接口请求体
 */
@Data
public class ServiceRequest {

    /**
     * service 接口地址
     */
    @NotEmpty(message = "wsdUrl 不能为空!")
    private String wsdUrl;
    /**
     * 接口方法名
     */
    @NotEmpty(message = "operationName 不能为空!")
    private String operationName;
    /**
     * XML 格式入参
     */
    private HashMap<String, String> paramsMap;
    /**
     * 方法入参
     * 注意:入参顺序与 service 接口的参数顺序必须一致
     */
    private List<Object> paramList;

    /**
     * 名称空间uri
     */
    private String namespaceURI = "http://tempuri.org";

    /**
     * 名称空间前缀
     * 示例:<tem:helloMessageServer> tem 就是 prefix
     */
    private String prefix = "tem";

    /**
     * 子标签是否有前缀
     */
    private boolean qualified = true;
}

调用实现

Web Service 服务端的本质是接收符合 SOAP 规范的 XML消息,解析 XML 进行业务处理,返回符合 SOAP 规范的 XML。

Java 端调用 Web Servcie 可以有多种方式:

  • 基于 CXF 的客户端调用

    CXF 客户端供了调用 Web Service 的多个 invoke 方法。

    Object[] invoke(QName operationName, Object... params) throws Exception;
    

    注意:invoke 的参数入参是数组,是不含参数名的,所以数组入参的顺序必须与服务端的接口入参一致。

  • 基于原生的 SOAP 调用【推荐此方式调用】

    其步骤主要有:获取连接工厂,通过工厂创建连接;获取报文工厂,通过工厂创建报文消息,在消息报文里添加设置报文头,正文,组装XML节点等;最后使用连接(入参消息报文和目标wsdUrl)调用远程服务。返回结果的数据是 SOAP 原生的 XML 格式数据。

    SOAP 调用的底层是组装 xml 请求报文发送 POST 请求。与上面 Web Service Server 章节的【请求接口】处理一致。

  • 使用代理类工厂的方式

    该方式需要 Web Service 服务端提供接口的 jar 包供客户端引入,客户端通过代理工厂对目标接口创建一个代理实现,通过代理来接口。

    不建议此方式,Web Service 服务端的实现可能是跨语言跨平台的,非 Java 语言就不支持,或服务商并不会提供这样的 jar 包。

Controller 接口:

/**
 * @desc 接口入口
 */
@RestController
public class WebServiceController {

    @Autowired
    private IService service;

    /**
     * CXF客户端动态调用
     *
     * @param serviceRequest
     * @return
     */
    @PostMapping("/cxfInvoke")
    public Object service(@Validated @RequestBody ServiceRequest serviceRequest) {
        return service.cxfInvoke(serviceRequest);
    }

    /**
     * SOAP连接调Web Service接口
     *
     * @param serviceRequest
     * @return
     */
    @PostMapping("/soapInvoke")
    public Object soapInvoke(@Validated @RequestBody ServiceRequest serviceRequest) {
        return service.soapInvoke(serviceRequest);
    }
}

业务接口:

/**
 * @desc 业务接口
 */
public interface IService {

    /**
     * CXF客户端动态调用
     *
     * @param serviceRequest
     * @return
     */
    Object cxfInvoke(ServiceRequest serviceRequest);

    /**
     * SOAP连接调Web Service接口
     *
     * @param serviceRequest
     * @return
     */
    Object soapInvoke(ServiceRequest serviceRequest);
}

业务接口实现:

package com.webservice.service.impl;

import com.alibaba.fastjson.JSON;
import com.webservice.common.utils.MapXmlUtil;
import com.webservice.common.utils.WebServiceExecutor;
import com.webservice.entity.ServiceRequest;
import com.webservice.service.IService;
import org.apache.commons.io.output.ByteArrayOutputStream;
import org.apache.cxf.endpoint.Client;
import org.apache.cxf.jaxws.endpoint.dynamic.JaxWsDynamicClientFactory;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils;

import javax.xml.namespace.QName;
import javax.xml.soap.*;
import java.io.IOException;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * @desc 调用 WebService
 */
@Service
public class ServiceImpl implements IService {
    private static final Logger logger = LogManager.getLogger(ServiceImpl.class);

    /**
     * CXF客户端动态调用
     *
     * @param serviceRequest
     * @return
     */
    public Object cxfInvoke(ServiceRequest serviceRequest) {
        try {
            List<Object> paramList = serviceRequest.getParamList();
            Object[] params = CollectionUtils.isEmpty(paramList) ? new Object[0] : paramList.toArray();

            String wsdUrl = serviceRequest.getWsdUrl();
            String operationName = serviceRequest.getOperationName();
            //请求
            logger.info("Web Service Request, wsdUrl:{}, operationName:{}, params:{}", wsdUrl, operationName, JSON.toJSONString(params));
            String result = WebServiceExecutor.invokeRemoteMethod(wsdUrl, operationName, params)[0].toString();
            logger.info("Web Service Response:{}", result);
            return result;
        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException(e);
        }
    }

    /**
     * SOAP连接调Web Service接口
     *
     * @param serviceRequest
     */
    public Object soapInvoke(ServiceRequest serviceRequest) {
        String wsdUrl = serviceRequest.getWsdUrl();
        String namespaceURI = serviceRequest.getNamespaceURI();
        String operationName = serviceRequest.getOperationName();
        String prefix = serviceRequest.getPrefix();
        HashMap<String, String> paramsMap = serviceRequest.getParamsMap();

        ByteArrayOutputStream outputStream = null;
        try {
            SOAPConnectionFactory factory = SOAPConnectionFactory.newInstance();
            SOAPConnection connection = factory.createConnection();
            MessageFactory messageFactory = MessageFactory.newInstance();
            //消息体
            SOAPMessage message = messageFactory.createMessage();
            SOAPPart part = message.getSOAPPart();
            SOAPEnvelope envelope = part.getEnvelope();
            envelope.addNamespaceDeclaration(prefix, namespaceURI);

            SOAPHeader header = message.getSOAPHeader();
            header.detachNode();
            SOAPBody body = message.getSOAPBody();

            QName bodyName = new QName(namespaceURI, operationName, prefix);
            SOAPBodyElement element = body.addBodyElement(bodyName);

            // 组装请求参数
            for (Map.Entry<String, String> entry : paramsMap.entrySet()) {
                QName sub = null;
                if (serviceRequest.isQualified()) {
                    sub = new QName(namespaceURI, entry.getKey(), prefix);
                } else {
                    sub = new QName(entry.getKey());
                }
                SOAPElement childElement = element.addChildElement(sub);
                childElement.addTextNode(entry.getValue());
            }

            //请求体
            outputStream = new ByteArrayOutputStream();
            message.writeTo(outputStream);
            String requestBody = outputStream.toString(Charset.defaultCharset());
            logger.info("Web Service Request Body:{}", requestBody);

            //执行请求
            SOAPMessage result = connection.call(message, wsdUrl);
            logger.info("Request Web Service End.");
            //响应体
            outputStream.reset();
            result.writeTo(outputStream);
            String responseBody = outputStream.toString(Charset.defaultCharset());
            logger.info("Web Service Response:{}", responseBody);

            //响应文本
            org.w3c.dom.Node firstChild = result.getSOAPBody().getFirstChild();
            org.w3c.dom.Node subChild = firstChild.getFirstChild();
            String textContent = subChild.getTextContent();
            logger.info("Web Service Response textContent:{}", textContent);
            return textContent;
        } catch (SOAPException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (!ObjectUtils.isEmpty(outputStream)) {
                try {
                    outputStream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
        throw new RuntimeException("请求 Web Service 异常");
    }

//    /**
//     * 代理类工厂的方式
//     * Web Service 服务端需要提供接口的jar包供客户端引入
//     * 需要拿到对方的接口地址, 同时需要引入接口
//     */
//    public Object proxyFactoryInvoke(ServiceRequest serviceRequest) {
//        try {
//            // 接口地址
//            String address = serviceRequest.getWsdUrl();
//            // 代理工厂
//            JaxWsProxyFactoryBean jaxWsProxyFactoryBean = new JaxWsProxyFactoryBean();
//            // 设置代理地址
//            jaxWsProxyFactoryBean.setAddress(address);
//            // 设置接口类型
//            jaxWsProxyFactoryBean.setServiceClass(HelloService.class);
//            // 创建一个代理接口实现
//            HelloService us = (HelloService) jaxWsProxyFactoryBean.create();
//            // 调用代理接口的方法调用并返回结果
//            String result = (String) us.helloMessageServer("Hello", "World");
//            System.out.println(result);
//            return result;
//        } catch (Exception e) {
//            e.printStackTrace();
//        }
//        throw new RuntimeException("请求 Web Service 异常");
//    }

    /**
     * CXF客户端动态调用
     */
    public Object cxfClientInvoke() {
        // 创建动态客户端
        JaxWsDynamicClientFactory clientFactory = JaxWsDynamicClientFactory.newInstance();
        Client client = clientFactory.createClient("http://localhost:8080/services/ws/hello?wsdl");

        // 需要密码的情况需要加上用户名和密码
        // client.getOutInterceptors().add(new ClientLoginInterceptor(USER_NAME, PASS_WORD));
        Object[] objects = new Object[0];

        List<Object> paramList = new ArrayList<>();
        paramList.add("Hello");
        paramList.add("World");

        Object[] params = paramList.toArray();

        try {
            // invoke("方法名",参数1,参数2,参数3....);
            //这里注意如果是复杂参数的话,要保证复杂参数可以序列化
            objects = client.invoke("helloMessageServer", params);
            System.out.println("Service Response:" + JSON.toJSONString(objects[0]));
        } catch (Exception e) {
            e.printStackTrace();
        }
        return objects[0];
    }

}

客户端请求

  • CXF 客户端请求,使用 postman 工具发送 POST 请求,请求体是 JSON:

    请求地址:http://localhost:8090/cxfInvoke

    {
        "wsdUrl": "http://localhost:8080/services/ws/hello?wsdl",
        "operationName": "helloMessageServer",
        "paramList": ["Hello","World"]
    }
    
  • SOAP 原生调用,使用 postman 工具发送 POST 请求,请求体是 JSON:

    请求地址:http://localhost:8090/soapInvoke

    {
        "wsdUrl": "http://localhost:8080/services/ws/hello?wsdl",
        "operationName": "helloMessageServer",
        "prefix":"tem",
        "qualified":true,
        "namespaceURI":"http://tempuri.org",
        "paramsMap": {
            "input1": "Hello",
            "input2": "World"
        }
    }
    
  • 响应结果都为:Hello,World

WebServiceExecutor工具类

在 CXF 客户端调用中用到

import com.webservice.common.constants.SysConstants;
import org.apache.commons.lang3.StringUtils;
import org.apache.cxf.endpoint.Endpoint;
import org.apache.cxf.jaxws.endpoint.dynamic.JaxWsDynamicClientFactory;
import org.apache.cxf.service.model.BindingInfo;
import org.apache.cxf.service.model.BindingOperationInfo;
import org.apache.cxf.transport.http.HTTPConduit;
import org.apache.cxf.transports.http.configuration.HTTPClientPolicy;
import org.springframework.util.CollectionUtils;
import org.w3c.dom.Document;
import org.w3c.dom.Element;

import javax.xml.namespace.QName;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import java.io.ByteArrayOutputStream;
import java.io.StringWriter;
import java.util.HashMap;
import java.util.List;

/**
 * WebService操作类
 */
public class WebServiceExecutor {

    /**
     * 请求 WebService 服务
     *
     * @param operationName 接口方法名
     * @param wsdUrl        接口地址
     * @param xmlMap        参数是XML格式时用
     * @param list          参数列表
     * @return String
     * @throws Exception
     */
    public static String requestWebService(String operationName, String wsdUrl, HashMap<String, String> xmlMap, List<Object> list) throws Exception {
        if (StringUtils.isBlank(operationName)) {
            throw new Exception("异常:operationName必填参数为null!");
        }
        if (StringUtils.isBlank(wsdUrl)) {
            throw new Exception("异常:wsdUrl必填参数为null!");
        }
        String result = "";
        //构造参数
        String xmlParam = createParamList(xmlMap);
        list.add(xmlParam);
        Object[] params = list.toArray();
        //请求
        result = invokeRemoteMethod(wsdUrl, operationName, params)[0].toString();
        return result;
    }

    /**
     * 组装XML参数
     *
     * @param paramMap XML参数
     * @return String
     * @throws Exception
     */
    public static String createParamList(HashMap<String, String> paramMap) throws Exception {
        String result = "";
        if (!CollectionUtils.isEmpty(paramMap)) {
            // 创建解析器工厂
            DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
            DocumentBuilder db = factory.newDocumentBuilder();
            Document document = db.newDocument();
            // 2、创建根节点request
            String xmlRoot = paramMap.get(SysConstants.XML_ROOT);
            Element xml = document.createElement(xmlRoot);
            //3、拼接各个子节点
            for (String key : paramMap.keySet()) {
                if (!key.equals(SysConstants.XML_ROOT)) {
                    Element paramElement = document.createElement(key);
                    paramElement.setTextContent(paramMap.get(key));
                    xml.appendChild(paramElement);
                }
            }
            document.appendChild(xml);
            //将xml转换成string
            result = createXmlToString(document);
        }
        return result;
    }

    private static String createParamList(String[] params) {
        String result = "";
        try {
            // 创建解析器工厂
            DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
            DocumentBuilder db = factory.newDocumentBuilder();
            Document document = db.newDocument();
            // 2、创建根节点rss
            Element request = document.createElement("request");
            for (String param :
                    params) {
                Element paramElement = document.createElement(param);
                paramElement.setTextContent("?");
                request.appendChild(paramElement);
            }
            document.appendChild(request);
            //获取一个新的实例
            TransformerFactory transformerFactory = TransformerFactory.newInstance();
            //创建一个 Transformer,可以将 XML 文档转换成其他格式
            //有异常抛出,用 try catch 捕获
            Transformer transformer = transformerFactory.newTransformer();
            StringWriter writer = new StringWriter();
            transformer.transform(new DOMSource(document), new StreamResult(writer));
            //最后将 StringWriter 转换为 字符串
            //输出只有一行,是纯粹的XML内容,丢失了换行符、缩进符
//            System.out.println(writer.toString());
//            System.out.println(createXmlToString(document));
            result = createXmlToString(document);
            //截取只返回需要的部分
            result = result.substring(result.indexOf("<request>"), result.length());
        } catch (Exception e) {

        }
        return result;
    }

    /**
     * XML文档转XML字符串
     *
     * @param document XML文档对象
     * @return String
     */
    private static String createXmlToString(Document document) {
        String xmlString = null;
        try {
            // 创建TransformerFactory工厂对象
            TransformerFactory transFactory = TransformerFactory.newInstance();
            // 通过工厂对象, 创建Transformer对象
            Transformer transformer = transFactory.newTransformer();
            transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
            //使Xml自动换行, 并自动缩进
            transformer.setOutputProperty(OutputKeys.DOCTYPE_PUBLIC, "");
            transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "4");  //中间的参数网址固定写法(这里还没搞懂)
            transformer.setOutputProperty(OutputKeys.INDENT, "yes");                          //是否设置缩进(indent: yes|no)
            // 创建DOMSource对象并将Document加载到其中
            DOMSource domSource = new DOMSource(document);
            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            // 使用Transformer的transform()方法将DOM树转换成XML
            transformer.transform(domSource, new StreamResult(bos));
            xmlString = bos.toString();
        } catch (TransformerException e) {
            e.printStackTrace();
        }
        return xmlString;
    }

    /**
     * 调用远程WebService接口
     *
     * @param url           接口地址
     * @param operationName 接口方法名
     * @param parameters    接口参数列表
     * @return Object[]
     * @throws Exception
     */
    public static Object[] invokeRemoteMethod(String url, String operationName, Object[] parameters) throws Exception {
        JaxWsDynamicClientFactory dcf = JaxWsDynamicClientFactory.newInstance();
        if (!url.endsWith("wsdl")) {
            url += "?wsdl";
        }
        org.apache.cxf.endpoint.Client client = dcf.createClient(url);
        HTTPConduit conduit = (HTTPConduit) client.getConduit();
        HTTPClientPolicy policy = new HTTPClientPolicy();
        policy.setConnectionTimeout(10 * 1000); //连接超时时间
        policy.setReceiveTimeout(12 * 1000);//请求超时时间.
        conduit.setClient(policy);
        //处理webService接口和实现类namespace不同的情况,CXF动态客户端在处理此问题时,会报No operationName was found with the name的异常
        Endpoint endpoint = client.getEndpoint();
        QName opName = new QName(endpoint.getService().getName().getNamespaceURI(), operationName);
        BindingInfo bindingInfo = endpoint.getEndpointInfo().getBinding();
        if (bindingInfo.getOperation(opName) == null) {
            for (BindingOperationInfo operationInfo : bindingInfo.getOperations()) {
                if (operationName.equals(operationInfo.getName().getLocalPart())) {
                    opName = operationInfo.getName();
                    break;
                }
            }
        }
        Object[] res = null;
        res = client.invoke(opName, parameters);
        return res;
    }
}

【Demo 源码 > https://gitee.com/gxing19/spring-boot-web-service】

Web Service WSDL

相关参考

  1. CXF 用户指南
  2. Web Service工作原理及实例
  3. W3school > WSDL 教程
  4. Spring boot webService使用
更多内容请访问:IT源点

相关文章推荐

全部评论: 0

    我有话说: