跳到主要内容

实现

版权声明

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
  • API 端点目前只能挂载到 /graphql 上,若需要路由到不同服务, 可能需要通过变量进行区分

Web 资源网关

根据 URL 生成站点入口页面

对站点入口页面的生成需满足如下要求:

  • 需在 API 网关过滤器之后路由剩余请求,对匹配到的站点请求,返回站点入口页面, 对未匹配到的非静态资源请求,返回默认的站点页面(一般为 404)
  • 支持对站点设置默认的入口 HTML 页面,也可以按需为个别站点指定不同的入口 HTML 页面,并且,对已有的 HTML 页面还可以做差量定制

按照 创建最小 Quarkus 应用服务启动器 的指导步骤可以创建出网关服务启动器 gateway-starter,其已内置对 IHttpServerFilter 过滤器的扫描和加载,故而,在 gateway-web 模块中直接实现针对站点入口页面生成的过滤器即可:

WebSiteHttpServerFilter.java
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;
}
}

@Injectprivate 属性无效,其仅可标注在 protectedpublic 属性上,也可以直接标注在 setter 接口上。

WebSiteHttpServerFilter#doFilter 中会通过 WebSiteProvider 根据请求的路径去匹配已定义的站点, 若未匹配到站点,并且请求的也不是已存在的静态资源,则会尝试获取默认站点, 其以 * 为匹配条件,当存在 XWebSite#url* 的站点时, 便返回该站点的页面。默认站点应该始终有定义,故而,在最后一步仍未得到 HTML 页面,则继续后续的静态资源路由。

通过 IHttpServerContext#executeBlocking 会以异步方式调用 WebSiteHttpServerFilter#doFilter,以提高应用服务的并发能力。

WebSiteHttpServerFilter#order 决定了当前过滤器的优先级,其值越大, 其优先级越低。常量 GatewayConstants.PRIORITY_WEB_SITE_FILTER 是定义在 API 网关过滤器之后的一个数值。

站点页面由 WebSiteProvider 生成,其先读取默认位置的 XWeb DSL 定义文件,再通过匹配到的站点得到其入口 HTML 页面:

WebSiteProvider.java
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 模型加载器 机制,以避免写死代码,从而提供更加灵活的定制能力。

注意,WebSiteProviderWebSiteHttpServerFilter 需通过 Nop IoC 机制创建实例(详细说明见 Nop Beans 的创建与加载):

/nop/autoconfig/duzhou-gateway-web.beans
/duzhou/web/beans/default.beans.xml
/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 页面:

XWebSite.java
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 定义如下:

/duzhou/web/app.site-html.xml
<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 页面的差量定制需求:

/duzhou/schema/web/html.xdef
<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 中 stylelinkscript 等节点都通过 name 进行定位(div 通过 id 定位), 所以,在同一父节点下,各子节点间的唯一属性不能存在值相同的情况。

若是需要定制站点 HTML 页面,可以直接继承 /duzhou/web/app.site-html.xml 并通过站点 DSL 节点 site > layouthtml 属性引用该派生的 DSL 文件即可:

/duzhou/web/app.site-html.extends.xml
<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

/duzhou/web/app.web.xml
<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), 故而,只需要根据配置调整路由策略即可:

QuarkusStaticResourceRouter.java
@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.ROOTFileSystemAccess.RELATIVE 两种静态资源目录的支持,前者用于指定系统文件的绝对路径, 后者则指定应用服务进程运行目录下的相对路径或者其 classpath 中的资源路径。 因此,只需要根据配置路径中的前缀是 file:,还是 classpath: 来确定采用何种资源目录形式。

Quarkus 的 http 配置对象 HttpBuildTimeConfig 可以直接写在接口参数中, Quarkus 将自动注入真实的配置数据到该接口中,因此,通过 httpBuildTimeConfig 便可以获取是否已启用压缩等配置信息。

在启用了压缩的情况下,还需要自行通过 HttpCompressionHandler 包装 StaticHandler 才能真正启用对静态资源的压缩支持。其本质是移除值为 HttpHeaders.IDENTITY 的响应头 HttpHeaders.CONTENT_ENCODING, 从而由 Quarkus 自主对响应数据进行压缩。

Content-Encoding: identity 是默认设置的响应头,用于禁用对响应数据的压缩。

站点资源页面的加载

:直接使用 PageProviderBizModel 提供的接口即可,暂时无定制需求。

附录

参考