Spring Cloud OpenFeign源码分析,你是否遇到过不导入Ribbon应用会启动不起来?

因为我们想像 dubbo 调用远程服务一样,节省构建请求 body 并发http 请求,还要手动反序列化响应结果的步骤。使用 feign 能够让我们像同进程的接口方法调用一样调用远程进程的接口。

feignspring cloud 组件中的一个轻量级 restfulhttp 服务客户端,内置了 ribbon (因此使用 feign 也需要引入 ribbon 的依赖)。 openfeignspring cloudfeign 的基础上支持了 spring mvc 的注解,如 @RequesMapping@GetMapping@PostMapping 等。

使用 openfeign 声明接口的例子:

@FeignClient(name = 'sck-demo-provider',
        path = "/v1",
        url = "http://sck-demo-provider",
        primary = false)
public interface DemoService {

    @GetMapping("/hello")
    GenericResponse<String> sayHello();

}
复制代码

feign 用于服务消费端,即接口调用端,因此需要将服务提供端暴露的接口提取出来创建一个 Module 。当然,服务提供端也会依赖这个 Module ,因为数据传输对象 DTO 需要共用,也将 DTO 类跟接口放在一起,但不推荐服务提供者强制使用 implements 去实现接口。

源码分析

使用 openfegin 我们可以不用在 yaml 文件添加任何关于 openfegin配置,而只需要在一个被 @Configuration 注释的配置类上或者 Application 启动类上添加 @EnableFeignClients 注解。例如:

@EnableFeignClients(basePackages = {"com.wujiuye.sck.consumer"})
public class SckDemoConsumerApplication {
}
复制代码

basePackages 属性用于指定被 @FeignClient 注解注释的接口所在的包的包名,或者也可以直接指定 clients 属性, clients 属性可以直接指定一个或多个被 @FeignClient 注释的类。 basePackages 是一个数组,如果被 @FeignClient 注解注释的接口比较分散,可以指定多个包名,而不使用一个大的包名,这样可以减少包扫描耗费的时间,不拖慢应用的启动速度。

@Import(FeignClientsRegistrar.class)
public @interface EnableFeignClients {
}
复制代码

@EnableFeignClients 注解使用 @Import 导入 FeignClientsRegistrar 类,这是一个 ImportBeanDefinitionRegistrar ,因此我们重点关注它的 registerBeanDefinitions 方法。(关于 Spring 的知识点,默认大家都懂了)。

class FeignClientsRegistrar
		implements ImportBeanDefinitionRegistrar, ResourceLoaderAware, EnvironmentAware {
@Override
	public void registerBeanDefinitions(AnnotationMetadata metadata,
			BeanDefinitionRegistry registry) {
		registerDefaultConfiguration(metadata, registry);
		registerFeignClients(metadata, registry);
	}
}
复制代码

重点关注 registerFeignClients 方法,该方法负责读取 @EnableFeignClients 的属性,获取需要扫描的包名,然后扫描指定的所有包名下的被 @FeignClient 注解注释的接口,将扫描出来的接口调用 registerFeignClient 方法注册到 spring 容器。

class FeignClientsRegistrar
		implements ImportBeanDefinitionRegistrar, ResourceLoaderAware, EnvironmentAware {
    
    private void registerFeignClient(BeanDefinitionRegistry registry,
			AnnotationMetadata annotationMetadata, Map<String, Object> attributes) {
		String className = annotationMetadata.getClassName();
		BeanDefinitionBuilder definition = BeanDefinitionBuilder
				.genericBeanDefinition(FeignClientFactoryBean.class);
		definition.addPropertyValue("url", getUrl(attributes));
		definition.addPropertyValue("path", getPath(attributes));
		String name = getName(attributes);
		definition.addPropertyValue("name", name);
		String contextId = getContextId(attributes);
		definition.addPropertyValue("contextId", contextId);
		// ......
		definition.setAutowireMode(AbstractBeanDefinition.AUTOWIRE_BY_TYPE);

		String alias = contextId + "FeignClient";
		AbstractBeanDefinition beanDefinition = definition.getBeanDefinition();
		beanDefinition.setAttribute(FactoryBean.OBJECT_TYPE_ATTRIBUTE, className);
	
		boolean primary = (Boolean) attributes.get("primary");
		beanDefinition.setPrimary(primary);

		String qualifier = getQualifier(attributes);
		if (StringUtils.hasText(qualifier)) {
			alias = qualifier;
		}
		BeanDefinitionHolder holder = new BeanDefinitionHolder(beanDefinition, className,
				new String[] { alias });
		BeanDefinitionReaderUtils.registerBeanDefinition(holder, registry);
	}
}
复制代码

registerFeignClient 源码所示,该方法根据读取 @FeignClient 注解的属性配置,以及该接口的类名信息,向 Spring bean 工厂注册一个 FeignClientFactoryBean ,从名称可以看出这是一个 FactoryBean ,因此接下来我们主要看这个 FactoryBeangetObject 方法、 getObjectType 方法。 getObjectType 方法不用说,肯定是返回当前的被 @FeignClient 注解注释的那个接口的类名。

class FeignClientFactoryBean
		implements FactoryBean<Object>, InitializingBean, ApplicationContextAware {
    @Override
    public Object getObject() throws Exception {
        return getTarget();
    }
}
复制代码

getObject 方法调用 getTarget 方法,但由于 getTarget 方法太长,只截取部分。

class FeignClientFactoryBean
		implements FactoryBean<Object>, InitializingBean, ApplicationContextAware {
    
    <T> T getTarget() {
		FeignContext context = this.applicationContext.getBean(FeignContext.class);
		Feign.Builder builder = feign(context);
		// .......
		String url = this.url + cleanPath();
		Client client = getOptional(context, Client.class);
		if (client != null) {
			if (client instanceof LoadBalancerFeignClient) {
				client = ((LoadBalancerFeignClient) client).getDelegate();
			}
			if (client instanceof FeignBlockingLoadBalancerClient) {
				client = ((FeignBlockingLoadBalancerClient) client).getDelegate();
			}
			builder.client(client);
		}
		Targeter targeter = get(context, Targeter.class);
		return (T) targeter.target(this, builder, context,
				new HardCodedTarget<>(this.type, this.name, url));
	}
}
复制代码

Clienthttp 协议接口调用的实现,其定义如下:

public interface Client {

  Response execute(Request request, Options options) throws IOException;

}
复制代码

正常情况下, getTarget 方法中调用 getOptional 方法获取到的 ClientNULL 。不正常情况就是添加了 ribbonstarter 包,这时拿到的 ClientLoadBalancerFeignClient ,我们后面分析。

不管怎样, FeignClientFactoryBeangetTarget 方法最后都是调用 Targettarget 方法来获取实现该接口的实例Target 的实现类有两个: DefaultTargeterHystrixTargeter 。在不使用 Hystrix 的情况下,我们只分析 DefaultTargeter 的实现。

class DefaultTargeter implements Targeter {

	@Override
	public <T> T target(FeignClientFactoryBean factory, Feign.Builder feign,
			FeignContext context, Target.HardCodedTarget<T> target) {
		return feign.target(target);
	}

}
复制代码

如上源码所示, DefaultTargeter 调用 Feign.Builder 实例的 target 方法生成接口的实例,我们继续跟踪 target 方法的调用链,直到找到创建接口实例的方法。

Spring Cloud OpenFeign源码分析,你是否遇到过不导入Ribbon应用会启动不起来?

如图所示, Feign.BuildernewInstance 方法正是创建接口实例的方法。有两种实现,一种是支持接口方法异步调用的,一种是普通的同步调用实现。不过这里用的还是 ReflectiveFeign

public class ReflectiveFeign extends Feign {

  @Override
  public <T> T newInstance(Target<T> target) {
    Map<String, MethodHandler> nameToHandler = targetToHandlersByName.apply(target);
    // ......
    Map<Method, MethodHandler> methodToHandler = new LinkedHashMap<Method, MethodHandler>();
    // ......
    InvocationHandler handler = factory.create(target, methodToHandler);
    T proxy = (T) Proxy.newProxyInstance(target.type().getClassLoader(),
        new Class<?>[] {target.type()}, handler);
    // .......
    return proxy;
  }
}
复制代码

很熟悉的 JDK 动态代理。因此, Feign 并不会为接口生成实现类,而是生成一个动态代理对象。 factory.create 这句创建的 InvocationHandler 正是 FeignInvocationHandler ,此 InvocationHandlerJDK 实现动态代理的 InvocationHandler ,而 MethodHandlerfeign 定义的 MethodHandlermethodToHandler 存储的是接口中定义的方法与 feign 生成的 MethodHandler 映射关系。

static class FeignInvocationHandler implements InvocationHandler {

    private final Target target;
    private final Map<Method, MethodHandler> dispatch;

    FeignInvocationHandler(Target target, Map<Method, MethodHandler> dispatch) {
      this.target = checkNotNull(target, "target");
      this.dispatch = checkNotNull(dispatch, "dispatch for %s", target);
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
      // ....
      return dispatch.get(method).invoke(args);
    }

}
复制代码

当我们调用接口的方法时,都会走到 FeignInvocationHandlerinvoke 方法, invoker 方法根据 method 获取对应的 MethodHandler ,并调用 MethodHandlerinvoke 方法。往后 MethodHandler 要做的事情我们也基本能猜测得出来了。

想要找出这些 MethodHandler 在哪创建的,就需要回头从 FeignClientFactoryBeangetTarget 调用 DefaultTargetertarget 方法开始, DefaultTargetertarget 方法直接调用 Feign.Buildertarget 方法, Feign.Buildertarget 方法在调用 newInstance 方法之前调用了自身的 build 方法。

public class Feign{
 public static class Builder{
    public <T> T target(Target<T> target) {
      return build().newInstance(target);
    }
    public Feign build() {
      //......
      ParseHandlersByName handlersByName =
          new ParseHandlersByName(contract, options, encoder, decoder, queryMapEncoder,
              errorDecoder, synchronousMethodHandlerFactory);
      return new ReflectiveFeign(handlersByName, invocationHandlerFactory, queryMapEncoder);
    }
  }
}
复制代码

build 生成的 Feign 实例是 ReflectiveFeign ,因此, ReflectiveFeignnewInstance 方式的这句:

Map<String, MethodHandler> nameToHandler = targetToHandlersByName.apply(target);
复制代码

targetToHandlersByName 就是 ParseHandlersByName 的实例,我们来看下 ParseHandlersByNameapply 方法。

static final class ParseHandlersByName{
    public Map<String, MethodHandler> apply(Target target) {
      // 解析接口的方法,生成MethodMetadata实例
      List<MethodMetadata> metadata = contract.parseAndValidateMetadata(target.type());
      Map<String, MethodHandler> result = new LinkedHashMap<String, MethodHandler>();
      // 遍历接口方法
      for (MethodMetadata md : metadata) {
        // ......
        if (md.isIgnored()) {
          result.put(md.configKey(), args -> {
            throw new IllegalStateException(md.configKey() + " is not a method handled by feign");
          });
        } else {
           // 调用Factory实例的create方法来创建MethodHandler
          result.put(md.configKey(),
              factory.create(target, md, buildTemplate, options, decoder, errorDecoder));
        }
      }
      return result;
    }
}
复制代码

apply 方法负责解析接口的方法,并为每个接口方法调用 Factory 实例的 create 方法创建 MethodHandler

static class Factory {

    public MethodHandler create(Target<?> target,
                                MethodMetadata md,
                                RequestTemplate.Factory buildTemplateFromArgs,
                                Options options,
                                Decoder decoder,
                                ErrorDecoder errorDecoder) {
      return new SynchronousMethodHandler(target, client, retryer, requestInterceptors, logger,
          logLevel, md, buildTemplateFromArgs, options, decoder,
          errorDecoder, decode404, closeAfterDecode, propagationPolicy, forceDecoding);
    }
}
复制代码

创建的 MethodHandler 的类型是 SynchronousMethodHandler 。因此,当我们调用接口的方法时,最终调用的是 SynchronousMethodHandlerinvoke 方法。

public class SynchronousMethodHandler implements MethodHandler{
  @Override
  public Object invoke(Object[] argv) throws Throwable {
    RequestTemplate template = buildTemplateFromArgs.create(argv);
    Options options = findOptions(argv);
    Retryer retryer = this.retryer.clone();
    while (true) {
      try {
        return executeAndDecode(template, options);
      } catch (RetryableException e) {
        try {
          retryer.continueOrPropagate(e);
        } catch (RetryableException th) {
          Throwable cause = th.getCause();
          if (propagationPolicy == UNWRAP && cause != null) {
            throw cause;
          } else {
            throw th;
          }
        }
        if (logLevel != Logger.Level.NONE) {
          logger.logRetry(metadata.configKey(), logLevel);
        }
        continue;
      }
    }
  }
}
复制代码

invoke 方法做的事情就是包装请求参数调用接口,如果配置了重试次数,失败会重试。还记得 FeignClientFactoryBeangetTarget 方法调用 Targettarget 方法时传递的一个 HardCodedTarget 实例吗?这个就是用来生成请求参数 Request 的。

executeAndDecode 方法:

public class SynchronousMethodHandler implements MethodHandler{

  Object executeAndDecode(RequestTemplate template, Options options) throws Throwable {
    Request request = targetRequest(template);

    Response response;
    long start = System.nanoTime();
    try {
      response = client.execute(request, options);
      response = response.toBuilder()
          .request(request)
          .requestTemplate(template)
          .build();
    } catch (IOException e) {
      throw errorExecuting(request, e);
    }
    long elapsedTime = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
    if (decoder != null)
      return decoder.decode(response, metadata.returnType());
    // .......
  }
}
复制代码

Client 我们前面提到过,就是实现发送 http 协议请求的,那 SynchronousMethodHandler 实例的这个 client 是哪个 Client 的实现类呢?答案在 Fegin 的内部类 Builder

public class Fegin{
    public static class Build{
        private Client client = new Client.Default(null, null);
    }
}
复制代码

其实上面分析的源码过程,到 FeginBuilder 开始,就是 Fegin 的代码,而不是 openfeign文章开头说 openfeign 支持 spring mvc 的注解,但是我们好像跳过了,这里提供一个调用链,大家可以根据这个调用链去找源码。

feign.ReflectiveFeign.ParseHandlersByName.apply
    > feign.Contract.BaseContract.parseAndValidateMetadata(java.lang.Class<?>)
        > feign.Contract.BaseContract.parseAndValidateMetadata(java.lang.Class<?>, java.lang.reflect.Method)
            > org.springframework.cloud.openfeign.support.SpringMvcContract.processAnnotationOnMethod
复制代码

SpringMvcContractprocessAnnotationOnMethod 方法源码如下:

public class SpringMvcContract extends Contract.BaseContract
		implements ResourceLoaderAware {
    @Override
	protected void processAnnotationOnMethod(MethodMetadata data,
			Annotation methodAnnotation, Method method) {
		if (!RequestMapping.class.isInstance(methodAnnotation) && !methodAnnotation
				.annotationType().isAnnotationPresent(RequestMapping.class)) {
			return;
		}
		RequestMapping methodMapping = findMergedAnnotation(method, RequestMapping.class);
		// HTTP Method
		RequestMethod[] methods = methodMapping.method();
		if (methods.length == 0) {
			methods = new RequestMethod[] { RequestMethod.GET };
		}
		checkOne(method, methods, "method");
		data.template().method(Request.HttpMethod.valueOf(methods[0].name()));
		// path
		checkAtMostOne(method, methodMapping.value(), "value");
		if (methodMapping.value().length > 0) {
			String pathValue = emptyToNull(methodMapping.value()[0]);
			if (pathValue != null) {
				pathValue = resolve(pathValue);
				if (!pathValue.equals("/")) {
					data.template().uri(pathValue, true);
				}
			}
		}
		// produces
		parseProduces(data, method, methodMapping);
		// consumes
		parseConsumes(data, method, methodMapping);
		// headers
		parseHeaders(data, method, methodMapping);
		data.indexToExpander(new LinkedHashMap<Integer, Param.Expander>());
	}
}
复制代码

通过分析 openfeign 的源码,我们已经了解了 openfeign 是怎样与 Spring 整合的,以及 feign 到底做了什么,在源码分析的过程中,我们忽略了一些细节,而这些留到我们遇到问题时再去深挖。

疑问一: openfeign 是怎么拿到url的?

你不好奇 openfeign 是怎么拿到注册中心的服务 url 的吗?

@FeignClient(name = YcpayConstant.SERVICE_NAME,
        path = "/v1",
        primary = false)
复制代码

当我们未配置 @FeignClienturl 属性时, name 就起作用了。 FeignClientFactoryBeangetTarget 方法被我们忽略的代码:

class FeignClientFactoryBean
		implements FactoryBean<Object>, InitializingBean, ApplicationContextAware {
    <T> T getTarget() {
		// .....
		if (!StringUtils.hasText(this.url)) {
			if (!this.name.startsWith("http")) {
			    // 就是这句
				this.url = "http://" + this.name;
			}
			else {
				this.url = this.name;
			}
			this.url += cleanPath();
			return (T) loadBalance(builder, context,
					new HardCodedTarget<>(this.type, this.name, this.url));
		}
		if (StringUtils.hasText(this.url) && !this.url.startsWith("http")) {
			this.url = "http://" + this.url;
		}
		// .......
    }
}
复制代码

假设我们配置的 namesck-demo-provider ,那么生成的 url 就是:

http://sck-demo-provider
复制代码

疑问二:

你不好奇吗?为什么使用 openfeign 时,不配置 url ,且不导入 ribbon 的依赖会报错?

异常信息如下:

No Feign Client for loadBalancing defined. Did you forget to include spring-cloud-starter-netflix-ribbon?
复制代码

在分析 FeignClientFactoryBeangetTarget 方法源码时,我们漏掉了一些代码:

class FeignClientFactoryBean
		implements FactoryBean<Object>, InitializingBean, ApplicationContextAware {
    <T> T getTarget() {
		// ......
		if (!StringUtils.hasText(this.url)) {
			if (!this.name.startsWith("http")) {
				this.url = "http://" + this.name;
			}
			else {
				this.url = this.name;
			}
			this.url += cleanPath();
		    // loadBalance
			return (T) loadBalance(builder, context,
					new HardCodedTarget<>(this.type, this.name, this.url));
		}
		if (StringUtils.hasText(this.url) && !this.url.startsWith("http")) {
		    this.url = "http://" + this.url;
		}
		String url = this.url + cleanPath();
		Client client = getOptional(context, Client.class);
        if (client != null) {
			if (client instanceof LoadBalancerFeignClient) {
				// not load balancing because we have a url,
				// but ribbon is on the classpath, so unwrap
				client = ((LoadBalancerFeignClient) client).getDelegate();
			}
			if (client instanceof FeignBlockingLoadBalancerClient) {
				// not load balancing because we have a url,
				// but Spring Cloud LoadBalancer is on the classpath, so unwrap
				client = ((FeignBlockingLoadBalancerClient) client).getDelegate();
			}
			builder.client(client);
		}
        //......
    }
}
复制代码

两种情况:

  • 1、如果指定了 URL ,那么 getOptional 方法不会返回 null ,且返回的 ClientLoadBalancerFeignClient ,但不会抛出异常。
  • 2、如果不指定 URL ,则走负载均衡逻辑,走的是 loadBalance 方法,且抛出异常。
class FeignClientFactoryBean
		implements FactoryBean<Object>, InitializingBean, ApplicationContextAware {
    protected <T> T loadBalance(Feign.Builder builder, FeignContext context,
			HardCodedTarget<T> target) {
            // getOptional的最终调用:        
            // public <T> T getInstance(String name, Class<T> type) {
        	//	AnnotationConfigApplicationContext context = getContext(name);
        	//	if (BeanFactoryUtils.beanNamesForTypeIncludingAncestors(context,
        	//			type).length > 0) {
        	//		return context.getBean(type);
        	//	}
        	//	return null;
        	// }   
		Client client = getOptional(context, Client.class);
		if (client != null) {
			builder.client(client);
			Targeter targeter = get(context, Targeter.class);
			return targeter.target(this, builder, context, target);
		}
		throw new IllegalStateException(
				"No Feign Client for loadBalancing defined. Did you forget to include spring-cloud-starter-netflix-ribbon?");
	}
}
复制代码

根据前面的分析,正常情况下 getOptional 方法返回的 Client 绝对是 NULL ,所以就执行到了 loadBalance 方法的最后一行代码,抛出 IllegalStateException 异常。

现在我们可以猜测,难道添加 ribbon 之后, getOptional 就不返回 NULL 了吗?自动创建了一个 Client 实例并交由 Spring 管理?这个 Client 又是什么?

我们看下 spring-cloud-starter-openfeign 依赖导入的 spring-cloud-openfeign-core ,查看 自动配置文件 spring.factories

Spring Cloud OpenFeign源码分析,你是否遇到过不导入Ribbon应用会启动不起来?

spring.factories 文件的内容如下:

org.springframework.boot.autoconfigure.EnableAutoConfiguration=/
org.springframework.cloud.openfeign.ribbon.FeignRibbonClientAutoConfiguration
// 其它省略
复制代码

其中 FeignRibbonClientAutoConfiguration 应该就是我们要找的。

@ConditionalOnClass({ ILoadBalancer.class, Feign.class })
@ConditionalOnProperty(value = "spring.cloud.loadbalancer.ribbon.enabled",
		matchIfMissing = true)
@Configuration(proxyBeanMethods = false)
@AutoConfigureBefore(FeignAutoConfiguration.class)
@EnableConfigurationProperties({ FeignHttpClientProperties.class })
@Import({ HttpClientFeignLoadBalancedConfiguration.class,
		OkHttpFeignLoadBalancedConfiguration.class,
		DefaultFeignLoadBalancedConfiguration.class })
public class FeignRibbonClientAutoConfiguration {
    // ......
}
复制代码

spring.cloud.loadbalancer.ribbon.enabled 配置为 true 或者未配置时, @ConditionalOnProperty 自动配置条件都会成立。但是,当不导入 ribbonstarter 时, ILoadBalancer 是不存在的, @ConditionalOnClass 不满足条件,只有导入 ribbonstarter 包时,才会导入 HttpClientFeignLoadBalancedConfigurationOkHttpFeignLoadBalancedConfigurationDefaultFeignLoadBalancedConfiguration 这几个配置类。

  • HttpClientFeignLoadBalancedConfiguration 生效的条件是我们项目中添加 fegin-httpclient 的依赖;
  • OkHttpFeignLoadBalancedConfiguration 生效的条件是我们项目中添加了 okhttp 的依赖,且配置了 feign.okhttp.enabledtrue
  • DefaultFeignLoadBalancedConfiguration 生效的条件是前两者都不生效。
@Configuration(proxyBeanMethods = false)
class DefaultFeignLoadBalancedConfiguration {

	@Bean
	@ConditionalOnMissingBean
	public Client feignClient(CachingSpringLoadBalancerFactory cachingFactory,
			SpringClientFactory clientFactory) {
		return new LoadBalancerFeignClient(new Client.Default(null, null),cachingFactory,clientFactory);
	}

}
复制代码

所以,当不导入 ribbonstarter 时, ILoadBalancer 不存在, FeignRibbonClientAutoConfiguration 自动配置不会起作用,没有注入 Client ,但是因为没有配置 url ,所以走了 loadBalanceloadBalance 方法中拿不到 Client ,最终抛出异常。

那么怎么解决这个问题? 两种方法:

  • 1、 添加 ribbonstarter 依赖 如 sck-demo项目 种添加 ribbonstarter
<dependency>
     <groupId>org.springframework.cloud</groupId>
     <artifactId>spring-cloud-starter-kubernetes-ribbon</artifactId>
 </dependency>
复制代码
  • 2、不想添加 ribbonstarter 依赖,因为用不到,那么就需要显示配置 url , 你可以将 url 配置 http://${spring.application.name}

END

笔者主要通过阅读源码解决自己的一些疑问,也希望通过本篇的分析能够帮助到大家。

原文 

https://juejin.im/post/5ef18edae51d45740d1ce455

本站部分文章源于互联网,本着传播知识、有益学习和研究的目的进行的转载,为网友免费提供。如有著作权人或出版方提出异议,本站将立即删除。如果您对文章转载有任何疑问请告之我们,以便我们及时纠正。

PS:推荐一个微信公众号: askHarries 或者qq群:474807195,里面会分享一些资深架构师录制的视频录像:有Spring,MyBatis,Netty源码分析,高并发、高性能、分布式、微服务架构的原理,JVM性能优化这些成为架构师必备的知识体系。还能领取免费的学习资源,目前受益良多

转载请注明原文出处:Harries Blog™ » Spring Cloud OpenFeign源码分析,你是否遇到过不导入Ribbon应用会启动不起来?

赞 (0)
分享到:更多 ()

评论 0

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址