实现
- 文章作者: flytreeleft - flytreeleft@crazydan.org
- 文章链接: https://duzhou.crazydan.io/docs/development/framework/server/gateway/tech
- 版权声明: 本文章采用许可协议《署名 4.0 国际 (CC BY 4.0)》。 转载或商用请注明来自渡舟平台!
Maven 模块组织
- Gateway Starter 作为网关服务启动器,其为可执行 jar, API 网关包、Web 资源网关包、Web 资源包(含站点和资源页面定义)、Web 静态资源文件包等以外部 jar 依赖方式引入到该启动器的 classpath 中,在启动时,由其扫描 classpath 中的资源和 Beans 即可。 该方式可保证启动器的独立性,并可灵活地按需引入默认的或定制的 Web 资源
API 网关
- Nop GraphQL 网关模型见
/nop/schema/gateway.xdef
- DSL 参考:
/nop/main/app.gateway.xml
- DSL 参考:
- API 端点目前只能挂载到
/graphql
上,若需要路由到不同服务, 可能需要通过变量进行区分
Web 资源网关
根据 URL 生成站点入口页面
对站点入口页面的生成需满足如下要求:
- 需在 API 网关过滤器之后路由剩余请求,对匹配到的站点请求,返回站点入口页面, 对未匹配到的非静态资源请求,返回默认的站点页面(一般为 404)
- 支持对站点设置默认的入口 HTML 页面,也可以按需为个别站点指定不同的入口 HTML 页面,并且,对已有的 HTML 页面还可以做差量定制
按照 创建最小 Quarkus 应用服务启动器
的指导步骤可以创建出网关服务启动器 gateway-starter
,其已内置对
IHttpServerFilter
过滤器的扫描和加载,故而,在 gateway-web
模块中直接实现针对站点入口页面生成的过滤器即可:
public class WebSiteHttpServerFilter implements IHttpServerFilter {
@Inject
protected WebSiteProvider provider;
@Override
public int order() {
return GatewayConstants.PRIORITY_WEB_SITE_FILTER;
}
@Override
public CompletionStage<Void> filterAsync(
IHttpServerContext context,
Supplier<CompletionStage<Void>> next
) {
return context.executeBlocking(() -> {
return doFilter(context, next);
})
.exceptionally((e) -> {
return handleError(context, e);
})
.thenApply(r -> null);
}
private CompletionStage<Void> doFilter(
IHttpServerContext context,
Supplier<CompletionStage<Void>> next
) {
String path = context.getRequestPath();
String html = this.provider.getSiteHtmlByRequestPath(path);
// 若无匹配的站点,且不是请求的静态资源,则返回默认站点页面
if (html == null && !WebStaticResourcesHelper.isFile(path)) {
html = this.provider.getSiteHtmlByRequestPath("*");
}
// 仍然无匹配的站点,则继续后续的路由,如,静态资源路由等
if (html == null) {
return next.get();
}
// 返回匹配站点的入口页面内容
context.setResponseContentType(ContentType.TEXT_HTML.getMimeType());
context.sendResponse(HttpStatus.SC_OK, html);
return null;
}
private CompletionStage<Void> handleError(
IHttpServerContext context, Throwable e
) {
context.sendResponse(500, "Server Error");
return null;
}
}
@Inject
对private
属性无效,其仅可标注在protected
或public
属性上,也可以直接标注在 setter 接口上。
在 WebSiteHttpServerFilter#doFilter
中会通过
WebSiteProvider
根据请求的路径去匹配已定义的站点,
若未匹配到站点,并且请求的也不是已存在的静态资源,则会尝试获取默认站点,
其以 *
为匹配条件,当存在 XWebSite#url
为 *
的站点时,
便返回该站点的页面。默认站点应该始终有定义,故而,在最后一步仍未得到
HTML 页面,则继续后续的静态资源路由。
通过 IHttpServerContext#executeBlocking
会以异步方式调用
WebSiteHttpServerFilter#doFilter
,以提高应用服务的并发能力。
WebSiteHttpServerFilter#order
决定了当前过滤器的优先级,其值越大,
其优先级越低。常量 GatewayConstants.PRIORITY_WEB_SITE_FILTER
是定义在 API 网关过滤器之后的一个数值。
站点页面由 WebSiteProvider
生成,其先读取默认位置的 XWeb
DSL 定义文件,再通过匹配到的站点得到其入口 HTML 页面:
public class WebSiteProvider {
public String getSiteHtmlByRequestPath(String path) {
XWeb web = (XWeb) new DslModelParser()
.parseFromVirtualPath(
"/duzhou/web/app.web.xml"
);
XWebSite site = web.getSiteByUrl(path);
XNode htmlNode = site != null ? site.getLayoutHtmlNode() : null;
return htmlNode == null
? null
: toHtml(htmlNode);
}
private String toHtml(XNode node) {
String html = node.html().replaceAll("\n\\s*", "");
html = StringHelper.unescapeXml(html);
return "<!DOCTYPE html>" + html;
}
}
对
app.web.xml
的加载可采用 自定义 DSL 模型加载器 机制,以避免写死代码,从而提供更加灵活的定制能力。
注意,WebSiteProvider
与 WebSiteHttpServerFilter
需通过
Nop IoC 机制创建实例(详细说明见
Nop Beans 的创建与加载):
/duzhou/web/beans/default.beans.xml
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:x="/nop/schema/xdsl.xdef" xmlns:ioc="ioc"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans-2.5.xsd"
x:schema="/nop/schema/beans.xdef"
>
<bean id="webSiteHttpServerFilter"
class="io.crazydan.duzhou.framework.gateway.web.filter.WebSiteHttpServerFilter"
ioc:default="true" />
<bean id="webSiteProvider"
class="io.crazydan.duzhou.framework.gateway.web.WebSiteProvider" />
</beans>
在 WebSiteProvider#getSiteHtmlByRequestPath
中,
会解析固定位置的 DSL 定义文件 /duzhou/web/app.web.xml
,
并根据请求路径得到匹配的站点对象 XWebSite
。若是匹配到站点,则调用
XWebSite#getLayoutHtmlNode
生成该站点的 HTML 页面:
public class XWebSite extends _XWebSite {
public XNode getLayoutHtmlNode() {
String path = getLayout().getHtml();
IResource resource = VirtualFileSystem.instance().getResource(path);
EvalGlobalRegistry.instance().registerVariable("$site", ...);
try {
XNode node = new DslNodeLoader().loadFromResource(resource).getNode();
node.clearComment();
node.clearLocation();
node.removeAttrsWithPrefix("xmlns:");
return node;
} finally {
EvalGlobalRegistry.instance().unregisterVariable("$site");
}
}
}
注意,
EvalGlobalRegistry
注册的变量是线程共享的,对于临时性变量,需通过ThreadLocal
暂存。但仍需尽量避免使用全局变量,若无法避免, 则应该尽可能在单个线程内完成使用,避免出现并发调用。
以上代码中 getLayout().getHtml()
返回的是在站点布局节点
site > layout
上设置的 html
属性值,该值为站点 HTML
页面 DSL 定义路径:
<site xmlns:x="/nop/schema/xdsl.xdef"
xmlns:xdef="/nop/schema/xdef.xdef"
x:schema="/nop/schema/xdef.xdef"
>
<!-- ... -->
<layout html="!v-path" ...>
<!-- ... -->
</layout>
</site>
默认的站点 HTML 页面 DSL 定义在 /duzhou/web/app.site-html.xml
中,通过 DslNodeLoader
可以解析得到该 DSL 的 XNode
对象,在对该节点进行 html 相关的清理后,便可以调用
XNode#html
得到 HTML 页面内容。
站点 HTML 页面的 DSL 定义如下:
<html xmlns:x="/nop/schema/xdsl.xdef"
xmlns:xpl="xpl" xmlns:web="web"
x:schema="/duzhou/schema/web/html.xdef"
>
<x:gen-extends>
<web:GenSiteHtml
site="${$site}"
xpl:lib="/duzhou/web/xlib/web.xlib"
/>
</x:gen-extends>
</html>
该 DSL 通过定义在 /duzhou/web/xlib/web.xlib
中的
xpl 函数 GenSiteHtml
根据全局变量 $site
展开其
HTML 页面结构。
在
x:gen-extends
中引用全局变量的解释见 编写 x:gen-extends 的运行代码。
站点 HTML 页面的 XDef 定义了一个标准 HTML 的简单子集, 仅用于支持对站点 HTML 页面的差量定制需求:
<html xmlns:x="/nop/schema/xdsl.xdef"
xmlns:xdef="/nop/schema/xdef.xdef"
x:schema="/nop/schema/xdef.xdef"
xdef:unknown-attr="string"
>
<xdef:define
name="!string"
xdef:name="HtmlNameNode"
xdef:unique-attr="name"
xdef:value="string"
xdef:unknown-attr="string" />
<head>
<meta name="!string" xdef:unique-attr="name"
xdef:unknown-attr="string" />
<title xdef:value="string" />
<!-- 在 head 中仅内嵌基础的 css,并外链站点图标 -->
<style name="!string" xdef:ref="HtmlNameNode" />
<link name="!string" xdef:ref="HtmlNameNode" />
</head>
<body xdef:unknown-attr="string">
<!-- 仅支持差量定制 div 节点,定制时,其余 html 节点均需放在 div 中 -->
<div id="!string" xdef:unique-attr="id"
xdef:value="xml"
xdef:unknown-attr="string" />
<link name="!string" xdef:ref="HtmlNameNode" />
<script name="!string" xdef:ref="HtmlNameNode" />
</body>
</html>
在 DSL 中
style
、link
、script
等节点都通过name
进行定位(div
通过id
定位), 所以,在同一父节点下,各子节点间的唯一属性不能存在值相同的情况。
若是需要定制站点 HTML 页面,可以直接继承 /duzhou/web/app.site-html.xml
并通过站点 DSL 节点 site > layout
的 html
属性引用该派生的 DSL 文件即可:
<html xmlns:x="/nop/schema/xdsl.xdef" xmlns:c="c"
x:schema="/duzhou/schema/web/html.xdef"
x:extends="/duzhou/web/app.site-html.xml"
lang="zh_CN"
>
<head>
<title>
<x:gen-extends>
<c:script><![CDATA[
import io.crazydan.duzhou.framework.schema.RunInEnv;
// Note:运算符 += 只能用于数字运算
let suffix = '';
if ($site.runInEnv == RunInEnv.development) {
suffix = ' (开发中...)';
} else if ($site.runInEnv == RunInEnv.testing) {
suffix = ' (测试中...)';
}
const title = $site.subTitle + ' - ' + $site.title + suffix;
]]></c:script>
<!--
任意标签名称均可,最终都是将该标签内的子节点与
`x:gen-extends` 父节点的子节点做合并
-->
<_>${title}</_>
</x:gen-extends>
</title>
</head>
<body>
<div id="tips" class="tips">
<span>This is just a tip.</span>
</div>
</body>
</html>
在站点 HTML 页面的 DSL 中只能引用站点对象的全局变量
$site
。
<web>
<site>
<layout html="/duzhou/web/app.site-html.extends.xml">
</layout>
</site>
</web>
自定义静态资源根目录
对静态资源根目录的自定义支持需满足如下要求:
- 支持指定绝对路径的系统文件目录或 classpath 中的资源目录作为静态资源的根目录
- 对静态资源的路由需放在过滤链的尾部,以便于在其之前处理站点的入口页面请求, 并在请求资源(含静态资源)不存在时返回默认站点
- 可启/禁对静态资源的压缩
- 尽可能利用现有框架的处理实现,非必要,勿自行实现对静态资源的处理逻辑, 避免编写输出文件流、资源缓存、资源压缩、资源 MIME 类型 判定等代码
由于不同框架(Spring、Quarkus)的应用服务启动器的实现方式不一样,这里以 Quarkus 为例说明对静态资源自定义目录的支持方式。
Quarkus 自身已实现可支持自定义静态资源根目录的处理器 StaticHandler
(详见
From a local directory),
故而,只需要根据配置调整路由策略即可:
@ApplicationScoped
public class QuarkusStaticResourceRouter {
private Handler<RoutingContext> handler;
public void setupRouter(
@Observes Router router,
HttpBuildTimeConfig httpBuildTimeConfig
) {
router.route().handler(context -> {
getHandler(httpBuildTimeConfig).handle(context);
});
}
public Handler<RoutingContext> getHandler(
HttpBuildTimeConfig httpBuildTimeConfig
) {
if (this.handler == null) {
String path = GatewayConfigs.WEB_STATIC_RESOURCES_PATH.get();
String ns = ResourceHelper.getPathNamespace(path);
String root = ResourceHelper.removeNamespace(path, ns);
if (ResourceConstants.FILE_NS.equals(ns)) {
this.handler = StaticHandler.create(
FileSystemAccess.ROOT, root
);
} else if (ResourceConstants.CLASSPATH_NS.equals(ns)) {
this.handler = StaticHandler.create(
FileSystemAccess.RELATIVE, root
);
}
if (httpBuildTimeConfig.enableCompression) {
Set<String> compressedMediaTypes
= new HashSet<>(
httpBuildTimeConfig
.compressMediaTypes
.orElse(new ArrayList<>())
);
this.handler = new HttpCompressionHandler(
this.handler, compressedMediaTypes
);
}
}
return this.handler;
}
}
在
@QuarkusMain
所在的模块之外定义的@ApplicationScoped
类, 需要在该类所在的模块内启用 Maven 插件org.jboss.jandex:jandex-maven-plugin
, 否则,该类将不会被 Quarkus 加载。
由于 Nop IHttpServerFilter
的实现类是以高优先级注册到 Quarkus
的过滤器链中的(见 HttpServerFilterRegistrar
),其优先级高于
Router
的处理器,因此,对静态资源的处理会在过滤链的最靠后的位置。
故而,在 QuarkusStaticResourceRouter#setupRouter
中只需要处理静态资源已存在的情况。
QuarkusStaticResourceRouter#setupRouter
会在应用服务启动时执行,
此时,配置数据 GatewayConfigs.WEB_STATIC_RESOURCES_PATH
还未载入,
所以,需要延迟到首次请求处理时再创建 Router
的处理器实例。
Quarkus 的静态资源处理器 StaticHandler
已提供了对
FileSystemAccess.ROOT
和 FileSystemAccess.RELATIVE
两种静态资源目录的支持,前者用于指定系统文件的绝对路径,
后者则指定应用服务进程运行目录下的相对路径或者其 classpath 中的资源路径。
因此,只需要根据配置路径中的前缀是 file:
,还是 classpath:
来确定采用何种资源目录形式。
Quarkus 的 http
配置对象 HttpBuildTimeConfig
可以直接写在接口参数中,
Quarkus 将自动注入真实的配置数据到该接口中,因此,通过 httpBuildTimeConfig
便可以获取是否已启用压缩等配置信息。
在启用了压缩的情况下,还需要自行通过 HttpCompressionHandler
包装
StaticHandler
才能真正启用对静态资源的压缩支持。其本质是移除值为
HttpHeaders.IDENTITY
的响应头 HttpHeaders.CONTENT_ENCODING
,
从而由 Quarkus 自主对响应数据进行压缩。
Content-Encoding: identity
是默认设置的响应头,用于禁用对响应数据的压缩。
站点资源页面的加载
注:直接使用 PageProviderBizModel
提供的接口即可,暂时无定制需求。