1.需求
目前有些项目已经接入了Spring cloud管理,节点间通信(包括老项目)通过eureka(非boot web项目的注eureka注册与发现参照前文)提供http通信,由于我们公司内部项目间交流要求通过dubbo做服务的暴露与消费,考虑新加一个boot节点用于http与dubbo之间的相互转换
2.主要思想,方案与问题
(1)主要思想:
<1>做一个Spring Boot节点用于http与dubbo服务的代理
<2>Http调用Dubbo:
将节点接入Spring Cloud管理,注册到eureka上提供SC方面的服务,节点依赖需要接入的项目jar包,并配置扫描等将Dubbo代理Bean交由Spring Bean管理,通过通用的Controller提供http服务(通用controller后面会说)
<3>Dubbo调用Http:
这个相对简单,只需要对dubbo暴露一个通用接口,调用方在调用的时候指定要调用的链接,入参等参数,做一层转发就可以
(2)方案与问题:
<1> 依赖io.dubbo.springboot:spring-boot-starter-dubbo:1.0.0这个jar中提供了很多接入SC的配置,但在开发完成后发现一个致命问题,就是好像不支持一个消费者配置多个生产者,查看源码也没有找到很好的解决方案(个人水平有限)…此方案相对简单,如果只针对一个生产者,可以考虑此方案
<2> 消费者配置生产者仍然采用原先的xml配置,项目依赖也只是原始的dubbo依赖,其余手动配置(通过@Value从配置中心拿)
3.核心代码
扯了那么多没用的,终于轮到代码了(这里只公开了核心代码)…
启动类:HttpDubboProxyApplication:
1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* Created by Kowalski on 2017/7/11
* Updated by Kowalski on 2017/7/11
*/
@SpringBootApplication
@EnableEurekaClient
@ImportResource("classpath:dubbo-consumer.xml")
public class HttpDubboProxyApplication {
public static void main(String... args) {
// 程序启动入口
SpringApplication.run(HttpDubboProxyApplication.class,args);
}
}
项目启动时一些基本配置Bean(类xml)ConfigurationBean:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* Created by Kowalski on 2017/7/17
* Updated by Kowalski on 2017/7/17
* 配置bean
*/
@Configuration
public class ConfigurationBean {
@Bean
ProxySpringContextsUtil proxySpringContextsUtil(){
return new ProxySpringContextsUtil();
}
@Bean
AnnotationBean annotationBean(){
AnnotationBean annotationBean = new AnnotationBean();
/**启动扫描包(与正常dubbo扫描类似)*/
annotationBean.setPackage("com.kowalski.proxy.service");
return annotationBean;
}
@Bean
public RestTemplate restTemplate() {
RestTemplate restTemplate = new RestTemplate();
/**设置传输格式 避免中文乱码*/
restTemplate.getMessageConverters().set(1, new StringHttpMessageConverter(StandardCharsets.UTF_8));
return restTemplate;
}
}
我们这里http请求通过restTemplate,也可以使用feign
上段代码的proxySpringContextsUtil:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
package com.kowalski.proxy;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
/**
* Created by Kowalski on 2017/5/18
* Updated by Kowalski on 2017/5/18
*/
public class ProxySpringContextsUtil implements ApplicationContextAware {
private static ApplicationContext applicationContext; //Spring应用上下文环境
/**
* 实现ApplicationContextAware接口的回调方法,设置上下文环境
* @param applicationContext
* @throws BeansException
*/
@Override
public void setApplicationContext(ApplicationContext applicationContext) {
ProxySpringContextsUtil.applicationContext = applicationContext;
}
/**
* @return ApplicationContext
*/
public static ApplicationContext getApplicationContext() {
return applicationContext;
}
/**
* 获取对象
* @param name
* @return Object 一个以所给名字注册的bean的实例
* @throws BeansException
*/
public static Object getBean(String name) {
return applicationContext.getBean(name);
}
/**
* 获取类型为requiredType的对象
* 如果bean不能被类型转换,相应的异常将会被抛出(BeanNotOfRequiredTypeException)
* @param name bean注册名
* @param requiredType 返回对象类型
* @return Object 返回requiredType类型对象
* @throws BeansException
*/
public static Object getBean(String name, Class<?> requiredType) {
return applicationContext.getBean(name, requiredType);
}
/**
* 如果BeanFactory包含一个与所给名称匹配的bean定义,则返回true
* @param name
* @return boolean
*/
public static boolean containsBean(String name) {
return applicationContext.containsBean(name);
}
/**
* 判断以给定名字注册的bean定义是一个singleton还是一个prototype。
* 如果与给定名字相应的bean定义没有被找到,将会抛出一个异常(NoSuchBeanDefinitionException)
* @param name
* @return boolean
* @throws NoSuchBeanDefinitionException
*/
public static boolean isSingleton(String name) {
return applicationContext.isSingleton(name);
}
/**
* @param name
* @return Class 注册对象的类型
* @throws NoSuchBeanDefinitionException
*/
public static Class<?> getType(String name) {
return applicationContext.getType(name);
}
/**
* 如果给定的bean名字在bean定义中有别名,则返回这些别名
* @param name
* @return
* @throws NoSuchBeanDefinitionException
*/
public static String[] getAliases(String name) {
return applicationContext.getAliases(name);
}
}
此工具类主要用于获取Spring管理的Bean
http调用dubbo服务通用Controller DubboProxyController :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
package com.kowalski.proxy;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.type.TypeFactory;
import com.weimob.proxy.common.ProxyErrorResponse;
import com.weimob.proxy.common.ProxyErrorReturnEnum;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.lang.reflect.Type;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
/**
* Created by Kowalski on 2017/5/18
* Updated by Kowalski on 2017/5/18
*
* 使用规则:
* 1.被调用方提供的是单个对象类型入参
* 2.参数数量必须等于1(暂不支持无参与多参)
* 3.不支持泛型入参
*/
@Slf4j
@RestController
public class DubboProxyController {
public static final ObjectMapper objectMapper = new ObjectMapper();
static {
/**忽略unknow属性*/
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
}
private final ConcurrentMap<String, Method> methods = new ConcurrentHashMap<>();
@RequestMapping("/proxy/{instanceName}/{methodName}")
public Object runMethod(@PathVariable String instanceName,
@PathVariable String methodName,
HttpServletRequest request) {
Object bean;
try {
/**从bean factory获取bean实例*/
bean = ProxySpringContextsUtil.getBean(instanceName);
}catch (Exception e) {
log.error("未找到对应实例, instanceName:{} e:{}", instanceName, e);
return new ProxyErrorResponse(ProxyErrorReturnEnum.NO_INSTANCE.getReturnCode(),
String.format("未找到对应实例,instanceName:%s", instanceName));
}
/**从本地缓存拿取缓存方法*/
Method methodToDo = methods.get(instanceName + methodName);
/**如果缓存中没有 则根据方法名从实例中拿*/
if (methodToDo == null) {
Method[] declaredMethods;
try {
declaredMethods = bean.getClass().getDeclaredMethods();
}catch (Exception e) {
log.error("获取接口定义方法失败, instanceName:{} methodName:{} e:{}", instanceName, methodName, e);
return new ProxyErrorResponse(ProxyErrorReturnEnum.ERROR_GET_DECLARED_METHODS.getReturnCode(),
String.format("获取接口定义方法失败,instanceName:%s methodName:%s", instanceName, methodName));
}
/**根据方法名拿方法*/
for (Method method : declaredMethods) {
if (methodName.equals(method.getName())) {
methodToDo = method;
methods.putIfAbsent(instanceName + methodName, methodToDo);
break;
}
}
}
if (methodToDo == null) {
return new ProxyErrorResponse(ProxyErrorReturnEnum.NO_METHOD.getReturnCode(),
String.format("未找到对应方法,instanceName:%s methodName:%s", instanceName, methodName));
}
/**获取参数类型*/
Type[] types = methodToDo.getParameterTypes();
/**暂不支持无参方法*/
if(types == null || types.length == 0) {
return new ProxyErrorResponse(ProxyErrorReturnEnum.NO_PARAM_TYPE.getReturnCode(),
String.format("未获取到方法参数, instanceName:%s methodName:%s", instanceName, methodName));
}
/**暂不支持参数数量大于1*/
if(types.length > 1) {
return new ProxyErrorResponse(ProxyErrorReturnEnum.TOO_MANY_PARAM_ARGS.getReturnCode(),
String.format("方法参数过多, instanceName:%s methodName:%s", instanceName, methodName));
}
/**根据request请求内容 转换成对应形式的参数*/
ServletInputStream inputStream;
try {
inputStream = request.getInputStream();
}catch (Exception e){
log.error("获取输入流失败, instanceName:{} methodName:{} e:{}", instanceName, methodName, e);
return new ProxyErrorResponse(ProxyErrorReturnEnum.GET_INPUT_STREAM_FAILED.getReturnCode(),
String.format("获取输入流失败, instanceName:%s methodName:%s", instanceName, methodName));
}
/**获取入参类型*/
TypeFactory tf = objectMapper.getTypeFactory();
JavaType javaType = tf.constructType(types[0]);
/**将输入流转化成对应类型的参数*/
Object param;
try {
param = objectMapper.readValue(inputStream, javaType);
}catch (Exception e){
log.error("输入流转化入参失败, instanceName:{} methodName:{} e:{}", instanceName, methodName, e);
return new ProxyErrorResponse(ProxyErrorReturnEnum.INPUT_STREAM_EXCHANGE_FAILED.getReturnCode(),
String.format("输入流转化入参失败, instanceName:%s methodName:%s", instanceName, methodName));
}
/**执行方法*/
Object result;
try {
result = methodToDo.invoke(bean, param);
}catch (Exception e){
log.error("方法执行错误, instanceName:{} methodName:{} e:{}", instanceName, methodName, e);
return new ProxyErrorResponse(ProxyErrorReturnEnum.METHOD_INVOKE_ERROR.getReturnCode(),
String.format("方法执行错误, instanceName:%s methodName:%s", instanceName, methodName));
}
/**成功返回*/
return result;
}
}
由于已经将dubbo的代理Bean交由Spring Bean管理,因此通过ProxySpringContextsUtil 拿到代理,通过方法名拿到方法,通过方法拿到入参类型,再将入参转化成对应类型的参数invoke(之前有考虑过不管参数类型直接交给dubbo代理类去invoke,但好像必须要制定类型的入参,不然报错)
Dubbo调用Http的通用Service HttpProviderProxyService :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.kowalski.proxy.service;
/**
* Created by Kowalski on 2017/7/17
* Updated by Kowalski on 2017/7/17
* http代理实现类
*/
public interface HttpProviderProxyService {
/**
* 处理dubbo调用http代理请求
* @param request
* @return
*/
Object httpProxyHandle(HttpProxyRequest request);
}
Service实现 HttpProviderProxyServiceImpl:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
package com.kowalski.proxy.service.impl;
import com.alibaba.dubbo.config.annotation.Service;
import com.kowalski.proxy.Enum.HttpProxyReqTypeEnum;
import com.kowalski.proxy.common.ProxyErrorResponse;
import com.kowalski.proxy.common.ProxyErrorReturnEnum;
import com.kowalski.proxy.service.HttpProviderProxyService;
import com.kowalski.proxy.service.HttpProxyRequest;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.client.RestTemplate;
/**
* Created by Kowalski on 2017/7/17
* Updated by Kowalski on 2017/7/17
*/
@Service
@Slf4j
public class HttpProviderProxyServiceImpl implements HttpProviderProxyService{
@Value("${zuul.internal.url}")
String zuuInternalUrl;
@Autowired
RestTemplate restTemplate;
/**
* 处理dubbo调用http代理请求
* @param request
* @return
*/
@Override
public Object httpProxyHandle(HttpProxyRequest request) {
/**入参校验*/
if(request == null){
return new ProxyErrorResponse(ProxyErrorReturnEnum.PROXY_REQUEST_NULL);
}
if(request.getReqType() == null){
return new ProxyErrorResponse(ProxyErrorReturnEnum.PROXY_REQUEST_TYPE_NULL);
}
if(StringUtils.isEmpty(request.getProxyUrl())){
return new ProxyErrorResponse(ProxyErrorReturnEnum.PROXY_URL_NULL);
}
if(request.getRequest() == null){
return new ProxyErrorResponse(ProxyErrorReturnEnum.PROXY_REQUEST_NULL);
}
/**根据不同入参类型处理不同请求*/
switch (HttpProxyReqTypeEnum.getEnumByCode(request.getReqType())){
case INTERNAL_ZUUL_COMMON:
return internalZuulProxy(request);
case INTERNAL_ZUUL_CUSTOMIZED:
return internalZuulProxy(request);
case OUTSIDE_FULL_URL:
return outsideFullProxy(request);
default:
return new ProxyErrorResponse(ProxyErrorReturnEnum.PROXY_REQUEST_TYPE_UNDEFIND);
}
}
/**处理经由内网网关的代理请求*/
private Object internalZuulProxy(HttpProxyRequest request){
String url = zuuInternalUrl + request.getProxyUrl();
Object result;
try {
result = restTemplate.postForObject(url, request.getRequest(), Object.class);
}catch (Exception e){
log.error("HttpProviderProxyServiceImpl internalZuulProxy failed: reqType:{},url:{}, e:{}",
request.getReqType(), url, e);
return new ProxyErrorResponse(ProxyErrorReturnEnum.PROXY_GETRETURN_FAILED);
}
return result;
}
/**处理全路径的代理请求*/
private Object outsideFullProxy(HttpProxyRequest request){
Object result;
try {
result = restTemplate.postForObject(request.getProxyUrl(), request.getRequest(), Object.class);
}catch (Exception e){
log.error("HttpProviderProxyServiceImpl internalZuulProxy failed: reqType:{},proxyUrl:{}, e:{}",
request.getReqType(), request.getProxyUrl(), e);
return new ProxyErrorResponse(ProxyErrorReturnEnum.PROXY_GETRETURN_FAILED);
}
return result;
}
}
这里要注意一下这里的@Service注解,该注解采用的是dubbo的@Service注解而不是Spring的
通用请求 HttpProxyRequest :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package com.kowalski.proxy.service;
import lombok.Data;
/**
* Created by Kowalski on 2017/7/17
* Updated by Kowalski on 2017/7/17
* 代理Http
* 备注:只接受单个非泛型对象入参
*/
@Data
public class HttpProxyRequest {
/**请求类型 @see HttpProxyReqTypeEnum*/
private Integer reqType;
/**reqType->0:根据内网网关地址请求定制controller requestMapping地址
* reqType->1:根据内网网关地址请求commonController {serviceId}/{instanceName}/{methodName}
* reqType->2:请求proxyUrl地址*/
private String proxyUrl;
/**请求request*/
private Object request;
}
配置文件:dubbo-consumer.xml:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?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:dubbo="http://code.alibabatech.com/schema/dubbo"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd
http://code.alibabatech.com/schema/dubbo
http://code.alibabatech.com/schema/dubbo/dubbo.xsd">
<!-- 消费方应用名,用于计算依赖关系,不是匹配条件,不要与提供方一样 -->
<dubbo:application name="proxy" />
<dubbo:consumer timeout="1800000" retries="0" />
<dubbo:registry protocol="zookeeper" address="${dubbo.consumer.zookeeper.AA.address}" id="A" />
<!--<dubbo:registry protocol="zookeeper" address="${dubbo.consumer.zookeeper.BB.address}" id="B" />-->
<dubbo:registry protocol="zookeeper" address="${dubbo.consumer.zookeeper.C.address}" id="C"/>
<dubbo:reference id="aaFacade" registry="A"
interface="com.kowalski.facade.AaFacade" check="false"/>
<!--<dubbo:reference id="bbFacade" interface="com.kowalski.facade.BbFacade"-->
<!--check="false" registry="B"/>-->
<dubbo:reference id="ccFacade" registry="C"
interface="com.kowalski.facade.CcFacade" check = "false"/>
</beans>
这里的${dubbo.consumer.zookeeper.AA.address}上产者注册到的zk地址可以直接在application.yml中配置
其余枚举类等:
ProxyErrorResponse:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package com.kowalski.proxy.common;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* Created by Kowalski on 2017/7/4
* Updated by Kowalski on 2017/7/4
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ProxyErrorResponse implements Serializable{
private static final long serialVersionUID = -8379940261456006476L;
private long code;
private long status;
private String message;
public ProxyErrorResponse(long codeOrStatus, String message) {
this.code = codeOrStatus;
this.status = codeOrStatus;
this.message = message;
}
public ProxyErrorResponse(ProxyErrorReturnEnum errorReturnEnum) {
this.code = errorReturnEnum.getReturnCode();
this.status = errorReturnEnum.getReturnCode();
this.message = errorReturnEnum.getDiscribe();
}
}
ProxyErrorReturnEnum:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package com.kowalski.proxy.common;
/**
* Created by Kowalski on 2017/6/26
* Updated by Kowalski on 2017/6/26
*/
public enum ProxyErrorReturnEnum {
SUCCESS (0, "SUCCESS"),
METHOD_INVOKE_ERROR (-1, "方法执行错误"),
NO_METHOD (-2, "未找到对应方法"),
NO_INSTANCE (-3, "未找到对应实例"),
ERROR_GET_DECLARED_METHODS (-4, "获取接口定义方法失败"),
NO_INSTANCE_BY_CLASS_NAME (-5, "根据全路径获取实例失败"),
NO_PARAM_TYPE (-6, "未获取到方法参数"),
TOO_MANY_PARAM_ARGS (-7, "方法参数过多"),
GET_INPUT_STREAM_FAILED (-8, "获取输入流失败"),
INPUT_STREAM_EXCHANGE_FAILED(-9, "输入流转化入参失败"),
RETURN_JSON_TO_MY_FAILED (-10, "返回结果解析错误"),
SYSTEM_ERROR (-11, "系统错误"),
REQUEST_FAILED (-12, "请求失败"),
/**http代理错误*/
PROXY_REQUEST_TYPE_NULL (-13, "代理http类型不能为空"),
PROXY_URL_NULL (-14, "代理地址不能为空"),
PROXY_REQUEST_ARGS_NULL (-15, "请求入参为空"),
PROXY_REQUEST_TYPE_UNDEFIND (-16, "代理http类型非法"),
PROXY_GETRETURN_FAILED (-17, "请求失败"),
PROXY_REQUEST_NULL (-18, "请求不能为空");
private long returnCode;
private String discribe;
ProxyErrorReturnEnum(int returnCode, String discribe) {
this.returnCode = returnCode;
this.discribe = discribe;
}
public long getReturnCode() {
return returnCode;
}
public void setReturnCode(long returnCode) {
this.returnCode = returnCode;
}
public String getDiscribe() {
return discribe;
}
public void setDiscribe(String discribe) {
this.discribe = discribe;
}
}
HttpProxyReqTypeEnum :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
package com.kowalski.proxy.Enum;
import java.util.HashMap;
import java.util.Map;
/**
* Created by Kowalski on 2017/7/17
* Updated by Kowalski on 2017/7/17
* 传输类型枚举类
*/
public enum HttpProxyReqTypeEnum {
/**请求走内网网关 不经由外网复杂验证 定制controller处理*/
INTERNAL_ZUUL_CUSTOMIZED(0, "内网网关定制"),
/**请求走内网网关 不经由外网复杂验证 通用controller处理(instanceName methodName request)*/
INTERNAL_ZUUL_COMMON(1, "内网网关通用"),
/**全路径处理 不走网关(或者直接配置网关全路径)*/
OUTSIDE_FULL_URL(2, "全路径");
private int code;
private String description;
HttpProxyReqTypeEnum(int code, String description) {
this.code = code;
this.description = description;
}
private static final Map<Integer, HttpProxyReqTypeEnum> map = new HashMap<Integer, HttpProxyReqTypeEnum>();
static {
for (HttpProxyReqTypeEnum enums : HttpProxyReqTypeEnum.values()) {
map.put(enums.getCode(), enums);
}
}
public static HttpProxyReqTypeEnum getEnumByCode(int code){
return map.get(code);
}
public int getCode() {
return code;
}
public void setCode(int code) {
this.code = code;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
}
至此结束~有更好方案的小伙伴欢迎交流~~
最后
以上就是真实大树最近收集整理的关于Http服务与Dubbo服务相互转换的Spring Boot代理节点实现的全部内容,更多相关Http服务与Dubbo服务相互转换的Spring内容请搜索靠谱客的其他文章。
发表评论 取消回复