前端閘道器踩坑實踐

野林發表於2021-11-22

專案背景

在後端微服務中,常見的通常會通過暴露一個統一的閘道器入口給外界,從而使得整個系統服務有一個統一的入口和出口,收斂服務;然而,在前端這種統一提供閘道器出入口的服務比較少見,常常是各個應用獨立提供出去服務,目前業界也有采用微前端應用來進行應用的排程和通訊,其中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過程提供腳手架,方便應用開發者接入構建部署,從而實現工程化的效果,對於能夠成倍數複製的操作,我們都應該想到利用工程化的手段來進行解決,而不是一味的投入人工,畢竟機器更擅長處理單一不變的批量且穩定產出的工作,共勉!!!

參考

相關文章