软件开发定制springcloud gateway的使用 + nacos动态路由

一、简介

1、什么是gateway?

  • SpringCloud Gateway是spring官方基于Spring 5.0、Spring Boot2.0和Project Reactor软件开发定制等技术开发的网关,软件开发定制旨在为微服务架构提供简单、软件开发定制有效和统一的API软件开发定制路由管理方式
  • SpringCloud Gateway作为SpringCloud软件开发定制生态系统中的网关,软件开发定制目标是替代Netflix Zuul,在SpringCloud 2.0软件开发定制以上版本中,软件开发定制没有对新版本的Zuul 2.0软件开发定制以上最新高性能版本进行集成,软件开发定制仍然还是使用Zuul 1.x非Reactor软件开发定制模式的老版本。软件开发定制二为了提高网关的性能,SpringCloud Gateway是基于WebFlux软件开发定制框架实现的,而WebFlux软件开发定制框架底层则使用了高性能的Reactor软件开发定制模式通信框架Netty
  • SpringCloud Gateway软件开发定制不仅提供统一的路由方式,软件开发定制并且还基于Filer软件开发定制链的方式提供了网关基本的功能,例如:鉴权、监控/指标、流量控制、软件开发定制熔断限流等

2、没有gateway的弊端

  • 客户端多次请求不同的微服务,会增加客户端代码和配置的复杂性,维护成本比价高
  • 认证复杂,每个微服务可能存在不同的认证方式,客户端去调用,要去适配不同的认证
  • 存在跨域的请求,调用链有一定的相对复杂性(防火墙 / 浏览器不友好的协议)
  • 难以重构,随着项目的迭代,可能需要重新划分微服务

3、gateway解决了什么?

为了解决上面的问题,微服务引入了 的概念,网关为微服务架构的系统提供简单、有效且统一的API路由管理,作为系统的统一入口,提供内部服务的路由中转,给客户端提供统一的服务,可以实现一些和业务没有耦合的公用逻辑,主要功能包含认证、鉴权、路由转发、安全策略、防刷、流量控制、监控日志等

4、gateway和zuul的区别

  • Zuul构建于 Servlet 2.5,兼容 3.x,使用的是阻塞式的 API,不支持长连接,比如 websockets
  • Spring Cloud Gateway构建于 Spring 5+,基于 Spring Boot 2.x 响应式的、非阻塞式的 API。同时,它支持 websockets,和 Spring 框架紧密集成,开发体验相对来说十分不错

5、gateway核心概念

  • Route(路由): 路由是网关最基础的部分,路由信息有一个ID、一个目的URL、一组断言和一组Filter组成。如果断言路由为真,则说明请求的URL和配置匹配
  • Predicate(断言): 参考的是java8的java.util.function.Predicate,开发人员可以匹配Http请求中的所有内容(例如请求头或请求参数),如果请求与断言相匹配则进行路由
  • Filter(过滤器): 一个标准的Spring webFilter。SpringCloud Gateway中的Filter分为两种类型的Filter,分别是Gateway Filter和Global Filter。使用过滤器,可以在请求被路由前或者之后对请求进行修改

6、gateway是如何工作的


官方解释:

客户端SpringCloud Gateway发出请求,然后在Gateway Handler Mapping中找到与之请求相匹配的路由,将其发送到Gateway Web Handler,Handler再通过指定的过滤器链来将请求发送到我们实际的服务执行业务逻辑,然后返回。
过滤器之间用虚线分开是因为过滤器可能会发在代理请求之前(“pre”)或之后(“post”)执行业务逻辑,这样,Filter在“pre”类型的过滤器可以做参数校验,权限校验,流量监控,日志输出,协议转换等;在“post”类型的过滤器可以做响应内容,响应头的修改,日志的输出,流量监控等有着非常重要的作用

二、构建一个springcloud Gateway服务

1、新建一个微服务

1.1、新建gateway子模块


注册中心和配置中心以及服务服搭建可以参考之前的文章,这里基于之前的项目构建gateway服务

1.2、引入依赖
gateway服务依赖

		<dependencies>        <dependency>            <groupId>org.springframework.cloud</groupId>            <artifactId>spring-cloud-starter-gateway</artifactId>            <version>3.0.7</version>        </dependency>        <dependency>            <groupId>com.mdx</groupId>            <artifactId>mdx-shop-common</artifactId>            <version>1.0.0</version>        </dependency>    </dependencies>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

下面是全局的关于springcloud的依赖
spring-cloud.version:2021.0.1
spring-cloud-alibaba.version: 2021.0.1.0

		<dependency>            <groupId>org.springframework.cloud</groupId>            <artifactId>spring-cloud-dependencies</artifactId>            <version>${spring-cloud.version}</version>            <type>pom</type>            <scope>import</scope>        </dependency>        <dependency>            <groupId>com.alibaba.cloud</groupId>            <artifactId>spring-cloud-alibaba-dependencies</artifactId>            <version>${spring-cloud-alibaba.version}</version>            <type>pom</type>            <scope>import</scope>        </dependency>        <dependency>            <groupId>com.alibaba.cloud</groupId>            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>            <version>${spring-cloud-alibaba.version}</version>        </dependency>        <dependency>            <groupId>com.alibaba.cloud</groupId>            <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>            <version>${spring-cloud-alibaba.version}</version>        </dependency>        <dependency>            <groupId>org.springframework.cloud</groupId>            <artifactId>spring-cloud-starter-bootstrap</artifactId>            <version>3.0.2</version>        </dependency>        <dependency>            <groupId>org.springframework.cloud</groupId>            <artifactId>spring-cloud-starter-openfeign</artifactId>            <version>3.1.1</version>        </dependency>        <dependency>            <groupId>org.springframework.cloud</groupId>            <artifactId>spring-cloud-loadbalancer</artifactId>            <version>3.1.1</version>        </dependency>        <dependency>            <groupId>com.alibaba.cloud</groupId>            <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>            <version>${spring-cloud-alibaba.version}</version>        </dependency>
  • 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

2、配置服务

2.1、创建启动类

@SpringBootApplication@EnableFeignClientspublic class MdxShopGateWayApplication {    public static void main(String[] args) {        SpringApplication.run(MdxShopGateWayApplication.class, args);    }}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

2.2、创建application.yml配置文件

使用ip路由的方式:

server:  port: 9010spring:  application:    name: mdx-shop-gateway  cloud:    nacos:      discovery:        server-addr: localhost:8848        namespace: mdx        group: mdx    gateway:      routes:        - id: mdx-shop-user             #路由的ID,没有固定规则但要求唯一,建议配合服务名          uri: http://localhost:9090    #匹配后提供服务的路由地址          predicates:            - Path=/user/**    #断言,路径相匹配的进行路由        - id: mdx-shop-order          uri: http://localhost:9091          predicates:            - Path=/order/**
  • 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

2.3、启动并访问Gateway服务

发现报错了…
大致意思是在springboot整合gateway时, gateway组件中的 【spring-boot-starter-webflux】 和 springboot作为web项目启动必不可少的 【spring-boot-starter-web】 出现冲突

我们按照提示: Please set spring.main.web-application-type=reactive or remove spring-boot-starter-web dependency.
在配置文件配置下 spring.main.web-application-type=reactive 就好了

  main:    web-application-type: reactive
  • 1
  • 2

接着在重新启动项目,成功启动

然后我们再依次启动order服务和user服务

通过gateway访问user服务:
http://localhost:9010/user/getOrderNo?userId=mdx123456
其中9010端口为网关服务

通过gateway访问order服务:
http://localhost:9010/order/getOrderNo?userId=mdx123456
其中9010端口为网关服务

可见以上gateway均已成功路由到两个服务

2.4、通过微服务名称的方式来路由服务

把 gateway配置文件中的 uri: http://localhost:9090 改为 uri: lb://mdx-shop-user 这种服务名的形式

server:  port: 9010spring:  application:    name: mdx-shop-gateway  cloud:    nacos:      discovery:        server-addr: localhost:8848        namespace: mdx        group: mdx    gateway:      routes:        - id: mdx-shop-user             #路由的ID,没有固定规则但要求唯一,建议配合服务名          uri: lb://mdx-shop-user    #匹配后提供服务的路由地址          predicates:            - Path=/user/**      #断言,路径相匹配的进行路由        - id: mdx-shop-order          uri: lb://mdx-shop-order          predicates:            - Path=/order/**  main:    web-application-type: reactive
  • 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

再来测试一下user服务
http://localhost:9010/user/getOrderNo?userId=mdx123456
其中9010端口为网关服务

成功返回

2.5、路由websocket服务
将 uri: lb://mdx-shop-user 改为 uri: lb:ws://mdx-shop-user

	  routes:        - id: mdx-shop-user             #路由的ID,没有固定规则但要求唯一,建议配合服务名          uri: lb:ws://mdx-shop-user    #匹配后提供服务的路由地址          predicates:            - Path=/user/**      #断言,路径相匹配的进行路由
  • 1
  • 2
  • 3
  • 4
  • 5

2.6、测试负载均衡

采用这种路由方式 uri: lb://mdx-shop-user
在gateway添加配置:
开启通过服务中心的自动根据 serviceId 创建路由的功能

	gateway:      discovery:        locator:          enabled: true
  • 1
  • 2
  • 3
  • 4

我们在order服务中写一个测试类,如下

/**     * 测试负载均衡     * @return     */    @GetMapping("lb")    public String lb(){        System.out.println("test lb");        return "lb";    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

分别启动两个order服务(启动一个order服务之后,修改下端口号再启动一个)

在idea中启动同一个服务的多个端口操作如下:

成功启动了两个order服务

nacos状态如下(启动了两个实例)

我们再来通过网关访问下order服务
http://localhost:9010/order/lb
其中 9010 为网关端口
首先访问一次
我们看到order1服务打印了日志,order2服务没有日志


再访问一次接口
这个时候order2打印了日志,order1没有打印日志

如此实现了简单的负载均衡

三、通过nacos实现动态路由

微服务都是互相独立的,假如我们的网关和其他服务都在线上已经运行了好久,这个时候增加了一个微服务,这个时候要通过网关访问的话需要通过修改配置文件来增加路由规则,并且需要重启项目,所以我们需要实现动态路由

1、创建路由配置接口

新建路由发布接口

/** * 路由配置服务 * @author : jiagang * @date : Created in 2022/7/20 11:07 */public interface RouteService {    /**     * 更新路由配置     *     * @param routeDefinition     */    void update(RouteDefinition routeDefinition);    /**     * 添加路由配置     *     * @param routeDefinition     */    void add(RouteDefinition routeDefinition);}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

实现类如下

package com.mdx.gateway.service.impl;import com.mdx.gateway.service.RouteService;import lombok.extern.slf4j.Slf4j;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.cloud.gateway.event.RefreshRoutesEvent;import org.springframework.cloud.gateway.route.RouteDefinition;import org.springframework.cloud.gateway.route.RouteDefinitionWriter;import org.springframework.context.ApplicationEventPublisher;import org.springframework.context.ApplicationEventPublisherAware;import org.springframework.stereotype.Service;import reactor.core.publisher.Mono;/** * @author : jiagang * @date : Created in 2022/7/20 11:10 */@Service@Slf4jpublic class RouteServiceImpl implements RouteService, ApplicationEventPublisherAware {    @Autowired    private RouteDefinitionWriter routeDefinitionWriter;    /**     * 事件发布者     */    private ApplicationEventPublisher publisher;    @Override    public void update(RouteDefinition routeDefinition) {        log.info("更新路由配置项:{}", routeDefinition);        this.routeDefinitionWriter.delete(Mono.just(routeDefinition.getId()));        routeDefinitionWriter.save(Mono.just(routeDefinition)).subscribe();        this.publisher.publishEvent(new RefreshRoutesEvent(this));    }    @Override    public void add(RouteDefinition routeDefinition) {        log.info("新增路由配置项:{}", routeDefinition);        routeDefinitionWriter.save(Mono.just(routeDefinition)).subscribe();        this.publisher.publishEvent(new RefreshRoutesEvent(this));    }    @Override    public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) {        this.publisher = applicationEventPublisher;    }}
  • 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

其中:
RouteDefinitionWriter:提供了对路由的增加删除等操作
ApplicationEventPublisher: 是ApplicationContext的父接口之一,他的功能就是发布事件,也就是把某个事件告诉所有与这个事件相关的监听器

2、在nacos创建gateway-routes配置文件

将路由信息放到nacos的配置文件下

新建配置文件,并将order服务的路由添加到配置文件

配置路由如下:

[    {        "predicates":[            {                "args":{                    "pattern":"/order/**"                },                "name":"Path"            }        ],        "id":"mdx-shop-order",        "filters":[            {                "args":{                    "parts":1                },                "name":"StripPrefix"            }        ],        "uri":"lb://mdx-shop-order",        "order":1    }]
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23

这个路由配置对应的就是gateway中的RouteDefinition类

3、在本地配置文件下配置路由的data-id和group和命名空间

gateway:  routes:    config:      data-id: gateway-routes  #动态路由      group: shop      namespace: mdx
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

完整配置文件(删除或者注释掉之前配置在本地文件的路由)

server:  port: 9010spring:  application:    name: mdx-shop-gateway  cloud:    nacos:      discovery:        server-addr: localhost:8848        namespace: mdx        group: mdx    gateway:      discovery:        locator:          enabled: true  #开启通过服务中心的自动根据 serviceId 创建路由的功能  main:    web-application-type: reactivegateway:  routes:    config:      data-id: gateway-routes  #动态路由      group: shop      namespace: mdx
  • 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

4、创建路由相关配置类

创建配置类引入配置

/** * @author : jiagang * @date : Created in 2022/7/20 14:44 */@ConfigurationProperties(prefix = "gateway.routes.config")@Component@Datapublic class GatewayRouteConfigProperties {    private String dataId;    private String group;		private String namespace;}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

5、实例化nacos的ConfigService,交由springbean管理

ConfigService 这个类是nacos的分布式配置接口,主要是用来获取配置和添加监听器

由NacosFactory来创建ConfigService

/** * 将configService交由spring管理 * @author : jiagang * @date : Created in 2022/7/20 15:27 */@Configurationpublic class GatewayConfigServiceConfig {    @Autowired    private GatewayRouteConfigProperties configProperties;    @Autowired    private NacosConfigProperties nacosConfigProperties;    @Bean    public ConfigService configService() throws NacosException {        Properties properties = new Properties();        properties.setProperty(PropertyKeyConst.SERVER_ADDR, nacosConfigProperties.getServerAddr());        properties.setProperty(PropertyKeyConst.NAMESPACE, configProperties.getNamespace());        return NacosFactory.createConfigService(properties);    }}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

6、动态路由主要实现

项目启动时会加载这个类
@PostConstruc 注解的作用,在spring bean的生命周期依赖注入完成后被调用的方法

package com.mdx.gateway.route;import com.alibaba.cloud.nacos.NacosConfigProperties;import com.alibaba.nacos.api.config.ConfigService;import com.alibaba.nacos.api.config.listener.Listener;import com.fasterxml.jackson.core.JsonProcessingException;import com.fasterxml.jackson.core.type.TypeReference;import com.fasterxml.jackson.databind.ObjectMapper;import com.mdx.common.utils.StringUtils;import com.mdx.gateway.service.RouteService;import lombok.extern.slf4j.Slf4j;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.cloud.context.config.annotation.RefreshScope;import org.springframework.cloud.gateway.route.RouteDefinition;import org.springframework.stereotype.Component;import javax.annotation.PostConstruct;import java.util.List;import java.util.Objects;import java.util.concurrent.Executor;/** * @author : jiagang * @date : Created in 2022/7/20 15:04 */@Component@Slf4j@RefreshScopepublic class GatewayRouteInitConfig {    @Autowired    private GatewayRouteConfigProperties configProperties;    @Autowired    private NacosConfigProperties nacosConfigProperties;    @Autowired    private RouteService routeService;    /**     * nacos 配置服务     */    @Autowired    private ConfigService configService;    /**     * JSON 转换对象     */    private final ObjectMapper objectMapper = new ObjectMapper();    @PostConstruct    public void init() {        log.info("开始网关动态路由初始化...");        try {            // getConfigAndSignListener()方法 发起长轮询和对dataId数据变更注册监听的操作            // getConfig 只是发送普通的HTTP请求            String initConfigInfo = configService.getConfigAndSignListener(configProperties.getDataId(), configProperties.getGroup(), nacosConfigProperties.getTimeout(), new Listener() {                @Override                public Executor getExecutor() {                    return null;                }                @Override                public void receiveConfigInfo(String configInfo) {                    if (StringUtils.isNotEmpty(configInfo)) {                        log.info("接收到网关路由更新配置:\r{}", configInfo);                        List<RouteDefinition> routeDefinitions = null;                        try {                            routeDefinitions = objectMapper.readValue(configInfo, new TypeReference<List<RouteDefinition>>() {                            });                        } catch (JsonProcessingException e) {                            log.error("解析路由配置出错," + e.getMessage(), e);                        }                        for (RouteDefinition definition : Objects.requireNonNull(routeDefinitions)) {                            routeService.update(definition);                        }                    } else {                        log.warn("当前网关无动态路由相关配置");                    }                }            });            log.info("获取网关当前动态路由配置:\r{}", initConfigInfo);            if (StringUtils.isNotEmpty(initConfigInfo)) {                List<RouteDefinition> routeDefinitions = objectMapper.readValue(initConfigInfo, new TypeReference<List<RouteDefinition>>() {                });                for (RouteDefinition definition : routeDefinitions) {                    routeService.add(definition);                }            } else {                log.warn("当前网关无动态路由相关配置");            }            log.info("结束网关动态路由初始化...");        } catch (Exception e) {            log.error("初始化网关路由时发生错误", e);        }    }}
  • 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

如果项目启动时,在发布路由的时候卡在 this.publisher.publishEvent(new RefreshRoutesEvent(this)); 这个地方走不下去
请在GatewayRouteInitConfig这个类加@RefreshScope注解

5、测试动态路由

前面我们已经把本地的yml中的路由注释掉了,现在我们来通过gateway服务来掉一个order服务的接口
接口地址:http://localhost:9010/mdx-shop-order/order/lb
其中9010是网关端口
可以看到路由成功

然后我们再在nacos配置中心加一个user服务的路由

[    {        "predicates":[            {                "args":{                    "pattern":"/mdx-shop-order/**"                },                "name":"Path"            }        ],        "id":"mdx-shop-order",        "filters":[            {                "args":{                    "parts":1                },                "name":"StripPrefix"            }        ],        "uri":"lb://mdx-shop-order",        "order":1    },    {        "predicates":[            {                "args":{                    "pattern":"/mdx-shop-user/**"                },                "name":"Path"            }        ],        "id":"mdx-shop-user",        "filters":[            {                "args":{                    "parts":1                },                "name":"StripPrefix"            }        ],        "uri":"lb://mdx-shop-user",        "order":2    }]
  • 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

然后点发布
可以看到gateway的监听器已经监听到配置的改动


不重新启动gateway的情况下再来通过网关访问下user服务
接口地址:http://localhost:9010/mdx-shop-user/user/getOrderNo?userId=mdx123456
其中9010是网关端口
可以看到成功路由

到这里gateway的使用和动态路由就结束了。

nacos的基础使用可以看这个

项目地址

网站建设定制开发 软件系统开发定制 定制软件开发 软件开发定制 定制app开发 app开发定制 app开发定制公司 电商商城定制开发 定制小程序开发 定制开发小程序 客户管理系统开发定制 定制网站 定制开发 crm开发定制 开发公司 小程序开发定制 定制软件 收款定制开发 企业网站定制开发 定制化开发 android系统定制开发 定制小程序开发费用 定制设计 专注app软件定制开发 软件开发定制定制 知名网站建设定制 软件定制开发供应商 应用系统定制开发 软件系统定制开发 企业管理系统定制开发 系统定制开发