项目背景

在后端微服务中,常见的通常会通过暴露一个统一的网关入口给外界,从而使得整个系统服务有一个统一的入口和出口,收敛服务;然而,在前端这种统一提供网关出入口的服务比较少见,常常是各个应用独立提供出去服务,目前业界也有采用微前端应用来进行应用的调度和通信,其中nginx做转发便是其中的一种方案,这里为了收敛前端应用的出入口,项目需要在内网去做相关的部署,公网端口有限,因而为了更好接入更多的应用,这里借鉴了后端的网关的思路,实现了一个前端网关代理转发方案,本文旨在对本次前端网关实践过程中的一些思考和踩坑进行归纳和总结,也希望能给有相关场景应用的同学提供一些解决方面的思路

架构设计

网关层用来承载前端流量,作为统一入口可以使用前端路由或后端路由来承载,主要作用是流量切分,也可以将单一应用布置于此处,作为路由与调度的混合
应用层用来部署各个前端应用,不限于框架,各个应用之间通信可以通过http或者向网关派发,前提是网关层有接收调度的功能存在不限于前端框架及版本,每个应用已经单独部署完成,相互之间通信需要通过http之间的通信,也可以借助k8s等容器化部署之间的通信
接口层用来从后端获取数据,由于后端部署的不同形式,可能有不同的微服务网关,也有可能有单独的第三方接口,也可能是node.js等BFF接口形式对于统一共用的接口形式可将其上承至网关层进行代理转发

方案选择

目前项目应用系统业务逻辑较为复杂,不太便于统一落载在以类SingleSPA形式的微前端形式,因而选择了以nginx为主要技术形态的微前端网关切分的形式进行构建,另外后续需要接入多个第三方的应用,做成iframe形式又会涉及网络打通之间的问题。由于业务形态,公网端口有限,需要设计出一套能够1:n的虚拟端口的形态出来,因而这里最终选择了以nginx作为主网关转发来做流量及应用切分的方案。

网关层使用一个nginx作为公网流量入口,利用路径对不同子应用进行切分父nginx应用作为前端应用入口,需要作一个负载均衡处理,这里利用k8s的负载均衡来做,配置3个副本,如果某一个pod挂掉,可以利用k8的机制进行拉起
应用层多个不同的nginx应用,这里由于做了路径的切分,因而需要对资源定向做一个处理,具体详见下一部分踩坑案例这里利用docker挂载目录进行处理
接口层多个不同的nginx应用对接口做了反向代理后,接口由于是浏览器正向发送,因而这里无法进行转发,这里需要对前端代码做一个处理,具体详见踩坑案例后续会配置ci、cd构建脚手架以及一些配置一些常见前端脚手架如:vue-cli、cra、umi的接入插件包

踩坑案例

静态资源404错误

[案例描述] 我们发现在代理完路径后正常的html资源是可以定位到的,但是对于js、css资源等会出现找不到的404错误

[案例分析] 由于目前应用多为单页应用,而单页应用的主要都是由js去操作dom的,对于mv*框架而言通常又会在前端路由及对一些数据进行拦截操作,因而在对应模板引擎处理过程中需要对资源查找进行相对路径查找

[解决方案] 我们项目构建主要是通过docker+k8s进行部署的,因而这里我们想到将资源路径统一放在一个路径目录下,而这个目录路径需要和父nginx应用转发路径的名称相一致,也就是说子应用需要在父应用中需要注册一个路由信息,后续就可以通过服务注册方式进行定位变更等

父应用nginx配置

{
    "rj": {
        "name": "xxx应用",
        "path: "/rj/"
    }
}
server {
    location /rj/ {
        proxy_pass http://ip:port/rj/;
    }
}

子应用

FROM xxx/nginx:1.20.1
COPY ./dist /usr/share/nginx/html/rj/

接口代理404错误

[案例描述] 在处理完静态资源之后,我们父应用中请求接口,发现接口居然也出现了404的查询错误

[案例分析] 由于目前都是前后端分离的项目,因而后端接口通常也是通过子应用的nginx进行方向代理实现的,这样通过父应用的nginx转发过来后由于父应用的nginx中没有代理接口地址,因而会出现没有资源的情况

[解决方案] 有两种解决方案,一种是通过父应用去代理后端的接口地址来进行,这样的话会出现一个问题就是子应用代理的名称如果相同,并且接口并不只是来自一个微服务,或者会有不同的静态代理以及BFF形式,那样对父应用的构建就会出现复杂度不可控的情形;另一种则是通过改变子应用中的前端请求路径为约定好的一种路径,比如加上约定好的服务注册中的路径进行隔离。这里我们兼而有之,对于我们自研项目的接入,会在复应用中进行统一的网关及静态资源转发代理等配置,与子应用约定好路径名,比如后端网关统一以/api/进行转发,对于非自研项目的接入,我们目前需要接入应用进行接口的魔改,后续我们会提供一个插件库进行常见脚手架的api魔改方案,比如vue-cli/cra/umi等,对于第三方团队自研的脚手架构建应用需要自行手动更改,但一般来说自定义脚手架团队通常会有一个统一配置前端请求的路径,对于老应用如以jq等构建的项目,则需要各自手动更改

这里我以vue-cli3构建的方案进行一个示范:

// config
export const config = {
    data_url: '/rj/api'
};
// 具体接口
// 通常这里会做一些axios的路由拦截处理等
import request from '@/xxx';
// 这里对baseUrl做了统一入口,只需更改这里的baseurl入口即可
import { config } from '@/config';

// 具体接口
export const xxx = (params) =>
    request({
        url: config.data_url + '/xxx'
    })

源码浅析

nginx作为一个轻量的高性能web服务器,其架构及设计是极具借鉴意义的,对node.js或其他web框架的设计具有一定的指导思路

nginx是用C语言书写的,因而其将整个架构通过模块进行组合,其中包含了常见的诸如:HTTP模块、事件模块、配置模块以及核心模块等,通过核心模块来调度和加载其它模块,从而实现了模块之间的相互作用

这里我们主要是需要通过location中的proxy_pass对应用进行转发,因而,我们来看一下proxy模块中对proxy_pass的处理

ngx_http_proxy_module

static char *ngx_http_proxy_pass(ngx_conf_t *cf, ngx_command_t *cmd, void *conf);

static ngx_command_t ngx_http_proxy_commands[] = {
    {
        ngx_string("proxy_pass"),
        NGX_HTTP_LOC_CONF | NGX_HTTP_LIF_CONF | NGX_HTTP_LMT_CONF | NGX_CONF_TAKE1,
        ngx_http_proxy_pass,
        NGX_HTTP_LOC_CONF_OFFSET,
        0,
        NULL
    }
};


static char *
ngx_http_proxy_pass(ngx_conf_t *cf, ngx_command_t *cmd, void *conf)
{
    ngx_http_proxy_loc_conf_t *plcf = conf;

    size_t                      add;
    u_short                     port;
    ngx_str_t                  *value, *url;
    ngx_url_t                   u;
    ngx_uint_t                  n;
    ngx_http_core_loc_conf_t   *clcf;
    ngx_http_script_compile_t   sc;

    if (plcf->upstream.upstream || plcf->proxy_lengths) {
        return "is duplicate";
    }

    clcf = ngx_http_conf_get_module_loc_conf(cf, ngx_http_core_module);

    clcf->handler = ngx_http_proxy_handler;

    if (clcf->name.len && clcf->name.data[clcf->name.len - 1] == '/') {
        clcf->auto_redirect = 1;
    }

    value = cf->args->elts;

    url = &value[1];

    n = ngx_http_script_variables_count(url);

    if (n) {

        ngx_memzero(&sc, sizeof(ngx_http_script_compile_t));

        sc.cf = cf;
        sc.source = url;
        sc.lengths = &plcf->proxy_lengths;
        sc.values = &plcf->proxy_values;
        sc.variables = n;
        sc.complete_lengths = 1;
        sc.complete_values = 1;

        if (ngx_http_script_compile(&sc) != NGX_OK) {
            return NGX_CONF_ERROR;
        }

#if (NGX_HTTP_SSL)
        plcf->ssl = 1;
#endif

        return NGX_CONF_OK;
    }

    if (ngx_strncasecmp(url->data, (u_char *) "http://", 7) == 0) {
        add = 7;
        port = 80;

    } else if (ngx_strncasecmp(url->data, (u_char *) "https://", 8) == 0) {

#if (NGX_HTTP_SSL)
        plcf->ssl = 1;

        add = 8;
        port = 443;
#else
        ngx_conf_log_error(NGX_LOG_EMERG, cf, 0,
                           "https protocol requires SSL support");
        return NGX_CONF_ERROR;
#endif

    } else {
        ngx_conf_log_error(NGX_LOG_EMERG, cf, 0, "invalid URL prefix");
        return NGX_CONF_ERROR;
    }

    ngx_memzero(&u, sizeof(ngx_url_t));

    u.url.len = url->len - add;
    u.url.data = url->data + add;
    u.default_port = port;
    u.uri_part = 1;
    u.no_resolve = 1;

    plcf->upstream.upstream = ngx_http_upstream_add(cf, &u, 0);
    if (plcf->upstream.upstream == NULL) {
        return NGX_CONF_ERROR;
    }

    plcf->vars.schema.len = add;
    plcf->vars.schema.data = url->data;
    plcf->vars.key_start = plcf->vars.schema;

    ngx_http_proxy_set_vars(&u, &plcf->vars);

    plcf->location = clcf->name;

    if (clcf->named
#if (NGX_PCRE)
        || clcf->regex
#endif
        || clcf->noname)
    {
        if (plcf->vars.uri.len) {
            ngx_conf_log_error(NGX_LOG_EMERG, cf, 0,
                               "\"proxy_pass\" cannot have URI part in "
                               "location given by regular expression, "
                               "or inside named location, "
                               "or inside \"if\" statement, "
                               "or inside \"limit_except\" block");
            return NGX_CONF_ERROR;
        }

        plcf->location.len = 0;
    }

    plcf->url = *url;

    return NGX_CONF_OK;
}

ngx_http

static ngx_int_t
ngx_http_add_addresses(ngx_conf_t *cf, ngx_http_core_srv_conf_t *cscf,
    ngx_http_conf_port_t *port, ngx_http_listen_opt_t *lsopt)
{
    ngx_uint_t             i, default_server, proxy_protocol;
    ngx_http_conf_addr_t  *addr;
#if (NGX_HTTP_SSL)
    ngx_uint_t             ssl;
#endif
#if (NGX_HTTP_V2)
    ngx_uint_t             http2;
#endif

    /*
     * we cannot compare whole sockaddr struct's as kernel
     * may fill some fields in inherited sockaddr struct's
     */

    addr = port->addrs.elts;

    for (i = 0; i < port->addrs.nelts; i++) {

        if (ngx_cmp_sockaddr(lsopt->sockaddr, lsopt->socklen,
                             addr[i].opt.sockaddr,
                             addr[i].opt.socklen, 0)
            != NGX_OK)
        {
            continue;
        }

        /* the address is already in the address list */

        if (ngx_http_add_server(cf, cscf, &addr[i]) != NGX_OK) {
            return NGX_ERROR;
        }

        /* preserve default_server bit during listen options overwriting */
        default_server = addr[i].opt.default_server;

        proxy_protocol = lsopt->proxy_protocol || addr[i].opt.proxy_protocol;

#if (NGX_HTTP_SSL)
        ssl = lsopt->ssl || addr[i].opt.ssl;
#endif
#if (NGX_HTTP_V2)
        http2 = lsopt->http2 || addr[i].opt.http2;
#endif

        if (lsopt->set) {

            if (addr[i].opt.set) {
                ngx_conf_log_error(NGX_LOG_EMERG, cf, 0,
                                   "duplicate listen options for %V",
                                   &addr[i].opt.addr_text);
                return NGX_ERROR;
            }

            addr[i].opt = *lsopt;
        }

        /* check the duplicate "default" server for this address:port */

        if (lsopt->default_server) {

            if (default_server) {
                ngx_conf_log_error(NGX_LOG_EMERG, cf, 0,
                                   "a duplicate default server for %V",
                                   &addr[i].opt.addr_text);
                return NGX_ERROR;
            }

            default_server = 1;
            addr[i].default_server = cscf;
        }

        addr[i].opt.default_server = default_server;
        addr[i].opt.proxy_protocol = proxy_protocol;
#if (NGX_HTTP_SSL)
        addr[i].opt.ssl = ssl;
#endif
#if (NGX_HTTP_V2)
        addr[i].opt.http2 = http2;
#endif

        return NGX_OK;
    }

    /* add the address to the addresses list that bound to this port */

    return ngx_http_add_address(cf, cscf, port, lsopt);
}

static ngx_int_t
ngx_http_add_addrs(ngx_conf_t *cf, ngx_http_port_t *hport,
    ngx_http_conf_addr_t *addr)
{
    ngx_uint_t                 i;
    ngx_http_in_addr_t        *addrs;
    struct sockaddr_in        *sin;
    ngx_http_virtual_names_t  *vn;

    hport->addrs = ngx_pcalloc(cf->pool,
                               hport->naddrs * sizeof(ngx_http_in_addr_t));
    if (hport->addrs == NULL) {
        return NGX_ERROR;
    }

    addrs = hport->addrs;

    for (i = 0; i < hport->naddrs; i++) {

        sin = (struct sockaddr_in *) addr[i].opt.sockaddr;
        addrs[i].addr = sin->sin_addr.s_addr;
        addrs[i].conf.default_server = addr[i].default_server;
#if (NGX_HTTP_SSL)
        addrs[i].conf.ssl = addr[i].opt.ssl;
#endif
#if (NGX_HTTP_V2)
        addrs[i].conf.http2 = addr[i].opt.http2;
#endif
        addrs[i].conf.proxy_protocol = addr[i].opt.proxy_protocol;

        if (addr[i].hash.buckets == NULL
            && (addr[i].wc_head == NULL
                || addr[i].wc_head->hash.buckets == NULL)
            && (addr[i].wc_tail == NULL
                || addr[i].wc_tail->hash.buckets == NULL)
#if (NGX_PCRE)
            && addr[i].nregex == 0
#endif
            )
        {
            continue;
        }

        vn = ngx_palloc(cf->pool, sizeof(ngx_http_virtual_names_t));
        if (vn == NULL) {
            return NGX_ERROR;
        }

        addrs[i].conf.virtual_names = vn;

        vn->names.hash = addr[i].hash;
        vn->names.wc_head = addr[i].wc_head;
        vn->names.wc_tail = addr[i].wc_tail;
#if (NGX_PCRE)
        vn->nregex = addr[i].nregex;
        vn->regex = addr[i].regex;
#endif
    }

    return NGX_OK;
}

总结

对于前端网关而言,不只可以将网关单独独立出来进行分层,也可以采用类SingleSPA的方案利用前端路由进行网关的处理和应用调起,从而实现实现还是单页应用的控制,只是单独拆分出了各个子应用,这样做的好处是各个子应用之间可以通过父应用或者总线进行相互间的通信,以及公共资源的共享和各自私有资源的隔离,对于本项目而言,目前业态更适合使用单独网关层的方式来实现,而使用nginx则可以实现更小的配置来接入各个应用,实现前端入口的收敛,这里后续会为构建ci、cd过程提供脚手架,方便应用开发者接入构建部署,从而实现工程化的效果,对于能够成倍数复制的操作,我们都应该想到利用工程化的手段来进行解决,而不是一味的投入人工,毕竟机器更擅长处理单一不变的批量且稳定产出的工作,共勉!!!

参考

03-05 15:37