一个基于spring-cloud的全站构建案例

想法&实践

最近有把一个站点的构建过程记录下来的想法,于是把所需要的技术关键点全部都一一罗列出来,当做技术笔记。我在自己的私人代码库里面,做成了脚手架,方便自己。更重要的是,在这个梳理过程,让我对以前的一些系统设计有了新的体会。重新搭建的过程,其实是愉悦、流畅的。

技术栈

  • 语言:java
  • 框架:spring-cloud
  • 组件:zuul,eureka,spring-cloud feign,spring-cloud ribbon,spring-cloud zipkin ,spring-cloud zipkin,sleuth,ctrip-apollo
  • 中间件: 接入层 open-resty,消息中间件 kafka, 缓存 redis
  • 文件存储:OSS
  • 邮件服务:Ali Mail or Mail Gun
  • API 日志:采集数据,存到Hive
  • 搜索 elastic search
  • 日志检索系统: elk相关的 Elasticsearch、Logstash、Kibana
  • 监控:简单的监控可以自己实现(接口为粒度),微服务监控:Prometheus,硬件监控 Zabbix:CPU、内存、硬盘、网络、端口、日志等
  • CDN 内容分发
  • 负载均衡: LVS + keepalive 、F5 、DNS轮询

划分&业务分层

  • 公网
  • 接入层
  • 业务层
  • 数据层
  • 缓存
  • CI/CD
  • 自动化测试与运维
  • 容器化
  • 弹性扩容、缩容
  • 其他

关键代码

nginx 配置

user       www www;  ## Default: nobody
worker_processes  1;  ## Default: 1
error_log  /Users/lucas/logs/error.log;
pid        /Users/lucas/logs/nginx.pid;
worker_rlimit_nofile 8192;

events {
  worker_connections  1024;  ## Default: 1024
}

http {
  include     /Users/lucas/Desktop/local-mime.types;
  #include    /etc/nginx/proxy.conf;
  #include    /etc/nginx/fastcgi.conf;
  index    index.html index.htm index.php;

  default_type application/octet-stream;
  log_format   main '$remote_addr - $remote_user [$time_local]  response_status=$status '
    '"$request" $body_bytes_sent "$http_referer" '
    '"request_time=$request_time " uct="$upstream_connect_time" uht="$upstream_header_time" urt="$upstream_response_time"'
    '"$http_user_agent" "$http_x_forwarded_for"';
  access_log   /Users/lucas/logs/access.log  main;
  sendfile     on;
  tcp_nopush   on;
  server_names_hash_bucket_size 128; # this seems to be required for some vhosts

  upstream spacex-fox-qiong {
    server 127.0.0.1:8082;
    server 127.0.0.1:8083;
    server 127.0.0.1:8084;
  }

  upstream spacex-fox-qiong-api {
    server 127.0.0.1:8085;
  }

  server { # simple reverse-proxy
    listen       80;
    server_name  localhost;
    access_log   /Users/lucas/logs/localhost.access.log  main;

    # serve static files
    location ~ ^/(images|javascript|js|css|flash|media|static)/  {
      root    /var/www/virtual/spacex.server.com/htdocs;
      expires 30d;
    }

    # pass requests for dynamic content to http://spacex-fox-qiong, et al
    location / {
      proxy_pass      http://spacex-fox-qiong;
    }

    # pass requests for dynamic content to http://spacex-fox-qiong-api, et al
    location /api {
      proxy_pass      http://spacex-fox-qiong-api;
    }
  }

  # 看这里
  upstream spacex-qiong-zuul-gateway {
    server 127.0.0.1:8765;
  }

  server { # spacex zuul gateway
    listen       80;
    server_name  api.spacex.com;
    access_log   /Users/lucas/logs/localhost.access.log  main;

    # serve static files
    location ~ ^/(images|javascript|js|css|flash|media|static)/  {
      root    /var/www/virtual/spacex.server.com/htdocs;
      expires 30d;
    }

    # pass requests to zuul gateway
    location / {
      proxy_pass      http://spacex-qiong-zuul-gateway;
    }
  }

  upstream spacex-server.com {
    server 127.0.0.3:8000 weight=5;
    server 127.0.0.3:8001 weight=5;
    server 192.168.0.1:8000;
    server 192.168.0.1:8001;
  }

  server { # simple load balancing
    listen          80;
    server_name     big.server.com;
    access_log      /Users/lucas/logs/spacex.server.access.log main;

    location / {
      proxy_pass      http://spacex-server.com;
    }
  }
}

修改本地hosts文件,我的电脑为例,地址是:/etc/hosts
这是修改后的配置hosts文件

##
# Host Database
#
# localhost is used to configure the loopback interface
# when the system is booting.  Do not change this entry.
##
127.0.0.1   localhost   Lucass-MacBook-Pro.local api.spacex.com
255.255.255.255 broadcasthost
::1             localhost   Lucass-MacBook-Pro.local
# Added by Docker Desktop
# To allow the same kube context to work on the host and the container:
127.0.0.1 kubernetes.docker.internal
# End of section

注意这一句:127.0.0.1 localhost Lucass-MacBook-Pro.local api.spacex.com
这句把api.spacex.com 绑定到本地

curl 就可以这样调用:

curl api.spacex.com/community/12334456
curl http://api.spacex.com/community/123456

整个调用链就变成了:

api.spacex.com/community/12334456 -> nginx -> gateway -> community-service -> user-service

接入层拓展

如果nginx上面再加一层LVS+Keepalive,那么就可以实现软负载和高可用了。
VIP + keepalived的方式只有50%的利用率,怎么提高利用率呢?
答案就是双虚IP+dns轮询的方式。

阿里云的SLB服务(可以认为是一个增强版lvs+keepalived的高可用负载均衡服务)

接入层扩容

虽然现在实现了高可用,负载均衡,但是还是受限于一台LVS或者一台F5的性能。如果有更多的请求进来怎么办,答案就是用DNS轮询实现水平扩展。
到了这一步,那就是各大厂的级别,要做的事情很多,需要更多的工程师来配合。

接入层组件

  • 1 nginx:一个高性能的web-server和实施反向代理的软件
  • 2 lvs:Linux Virtual Server,使用集群技术,实现在linux操作系统层面的一个高性能、高可用、负载均衡服务器
  • 3 keepalived:一款用来检测服务状态存活性的软件,常用来做高可用
  • 4 f5:一个高性能、高可用、负载均衡的硬件设备(看上去和lvs功能差不多,软负载和硬件负载)
  • 5 DNS轮询:通过在DNS-server上对一个域名设置多个ip解析,来扩充web-server性能及实施负载均衡的技术

(架构图待补充)

spring cloud相关的代码如下

spring could eureka 代码

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;

@SpringBootApplication
@EnableEurekaServer // Eureka server
public class SpaceXQiongEurekaApplication {
    public static void main(String[] args) {
        SpringApplication.run(SpaceXQiongEurekaApplication.class, args);
    }
}

eureka application.properties 配置文件

# eureka config
# application unique name
spring.application.name=spacex-eureka-naming-server

# server port
server.port=8761

# don't register
eureka.client.register-with-eureka=false
eureka.client.fetch-registry=false

spring could zuul 代码

这个只是简单版

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.netflix.zuul.EnableZuulProxy;

@EnableZuulProxy
@EnableDiscoveryClient
@SpringBootApplication
public class SpaceXQiongGatewayApplication {
    public static void main(String[] args) {
        SpringApplication.run(SpaceXQiongGatewayApplication.class, args);
    }
}

若干网关过滤器 filter

import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import com.netflix.zuul.exception.ZuulException;
import com.spacex.qiong.gateway.util.WebUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;

@Component
public class ZuulPreFilter extends ZuulFilter {

    private Logger logger = LoggerFactory.getLogger(this.getClass());

    @Override
    public String filterType() {
        return "pre";
        // filter before request is executed
        // return "post"; filter after request is executed
        // return "error"; upon request error
    }

    @Override
    public int filterOrder() {
        return 1;
    }

    @Override
    public boolean shouldFilter() {
        return true;
    }

    @Override
    public Object run() throws ZuulException {//这里可以做接口签名校验,鉴权,url重写,api原始数据采集等,这里只做简单展示
        logger.info("request is filtered!");
        HttpServletRequest httpServletRequest = RequestContext.getCurrentContext().getRequest();

        String ipAddress = WebUtil.getRemortIP(httpServletRequest);
        logger.info("request remote address:{}, request uri:{}", ipAddress, httpServletRequest.getRequestURI());
        return null;
    }
}

zuul 网关 application.properties 配置文件

spring.application.name=spacex-gateway-zuul
server.port=8765
eureka.client.service-url.default-zone=http://localhost:8761/eureka

# Zuul routers

zuul.routes.spacex-community-service.path= /community/**
zuul.routes.spacex-community-service.url= http://localhost:5000/community
zuul.routes.spacex-user-service.path= /user/**
zuul.routes.spacex-user-service.url= http://localhost:4000/user

这样的zuul网关的缺点是,每增加一个服务,都需要修改网关的配置文件,不方便。
这里是可以重写成为支持动态路由的,生产环境建议重写。

重写的关键是:重写服务路由的类DiscoveryClientRouteLocator,

public class CustomRouteLocator extends DiscoveryClientRouteLocator {
    private ZuulProperties properties;
    private DiscoveryClient discovery;

    public CustomRouteLocator(String servletPath, DiscoveryClient discovery, ZuulProperties properties,
            ServiceRouteMapper serviceRouteMapper) {
        super(servletPath, discovery, properties, serviceRouteMapper);
        this.properties = properties;
        this.discovery = discovery;
    }

    // Get All URL from DB (这里不难,略)

    // support Restful的URL (这里不难,略)

    //极其重要:刷新逻辑极其重要
    @Override
    public void refresh() {
        refreshRoute();
        super.refresh();
    }

    // 极其重要:新的路由匹配
    @Override
    public Route getMatchingRoute(String path) {
        RequestContext context = RequestContext.getCurrentContext();
        RouteDTO route = (RouteDTO) context.get(RequestContextConstants.ROUTE);
        if(route == null){
            return null;
        }
        return new Route(route.getSourcePath(), route.getRedirectPath(), route.getServiceId(), "", true, properties.getSensitiveHeaders());
    }

    // RouteDTO 是个自定义的POJO类,包含:SourcePath、RedirectPath、ServiceId、其他等(略)
}

配置类

import javax.annotation.Resource;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.web.ServerProperties;
import org.springframework.cloud.client.discovery.DiscoveryClient;
import org.springframework.cloud.netflix.zuul.filters.ZuulProperties;
import org.springframework.cloud.netflix.zuul.filters.discovery.ServiceRouteMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import com.spacex.gateway.route.CustomRouteLocator;

@Configuration
public class CustomZuulConfiguration {

    @Resource
    private ZuulProperties zuulProperties;

    @Autowired
    private DiscoveryClient discovery;

    @Autowired
    private ServiceRouteMapper serviceRouteMapper;

    @Autowired
    protected ServerProperties serverProperties;

    @Bean
    public RouteLocator routeLocator() {
        CustomRouteLocator routeLocator = new CustomRouteLocator(serverProperties.getServletPath(), discovery, zuulProperties,serviceRouteMapper);
        return routeLocator;
    }
}

网关最核心的重写逻辑都在这两个类里,如果需要添加其他服务,可以自定义相关的模块(如:url重写、接口签名校验、鉴权、监控、API调用日志以及历史记录:请求参数、返回状态、结果、响应时间等、多端适配(web/ios/android/小程序)、限流、黑白名单、熔断、权限控制、AB 测试等等)。

两个简单的微服务:user-service和commuinty-service

user-service是服务提供方,commuinty-service是调用方,commuinty-service调用user-service服务的接口。

user-service的关键代码

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;

@EnableDiscoveryClient
@SpringBootApplication
public class SpaceXQiongUserApplication {
    public static void main(String[] args) {
        SpringApplication.run(SpaceXQiongUserApplication.class, args);
    }
}

endpoint controller 代码

import javax.annotation.Resource;

@RestController
@RequestMapping("user")
public class UserController {

    @Resource
    private Environment environment;

    @GetMapping("/{userId}")
    public UserInfoDTO userInfo(@PathVariable("userId") Long userId) {
        String[] profiles = environment.getDefaultProfiles();
        UserInfoDTO userInfoDTO = new UserInfoDTO();
        userInfoDTO.setUid(userId);
        userInfoDTO.setName("user-" + System.currentTimeMillis());
        userInfoDTO.setEmail(userInfoDTO.getName() + "@user.com");
        userInfoDTO.setLabel(profiles[0]);

        return userInfoDTO;
    }
}

user-service服务的 application.properties配置文件

spring.application.name=spacex-user-service
server.port=4000

eureka.client.service-url.default-zone=http://localhost:8761/eureka

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.spacex</groupId>
    <artifactId>spacex-qiong-user-service</artifactId>
    <version>1.0-SNAPSHOT</version>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.0.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    <name>spacex-qiong-user-service-server</name>
    <description>spacex project for user service server</description>

    <properties>
        <java.version>1.8</java.version>
        <spring-cloud.version>Hoxton.SR4</spring-cloud.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-config</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
    </dependencies>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>


</project>

community-service 服务

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.openfeign.EnableFeignClients;

@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients("com.spacex.community.remote")
public class SpaceXQiongCommunityApplication {
    public static void main(String[] args) {
        SpringApplication.run(SpaceXQiongCommunityApplication.class, args);
    }
}

community-service 调用 user-service的接口示例:

import com.spacex.community.dto.UserInfoDTO;
import com.spacex.community.remote.UserService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;

@RestController
public class CommunityController {

    @Resource
    private UserService userService;

    @GetMapping("community/{userId}")
    public UserInfoDTO get(@PathVariable("userId") Long userId) {
        UserInfoDTO userInfoDTO = userService.get(userId);
        return userInfoDTO;
    }
}

community-service 通过feign初始化 user-service的接口示例:

import com.spacex.community.dto.UserInfoDTO;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

@FeignClient(name = "spacex-user-service")
public interface UserService {

    @GetMapping("user/{userId}")
    UserInfoDTO get(@PathVariable("userId") Long userId);
}

community-service application.properties配置文件

spring.application.name=spacex-community-service
server.port=5000

#eureka.client.fetchRegistry=true
eureka.client.service-url.default-zone=http://localhost:8761/eureka

有些中间件配置,暂时不罗列。

拓展&问题

  • 给定相关的PV (日活DAU、MAU月活、PCU(同时最高在线人数) ),网关、应用服务等应该部署多少台
  • 压测怎么做
  • 数据冷热备份、核心数据的N备份(冷热备份)
  • 灾备、异地多活
  • 技术优化、降低成本
  • 通用代码组件化
  • 规划 & 技术road map

(完)

发表评论

邮箱地址不会被公开。 必填项已用*标注