特邀文章,原载于Github,作者:Megaease 的张博民
闪购是电子商务商店在短时间内提供的折扣或促销。数量有限,这往往意味着折扣比普通的促销活动要高或更显著。
然而,显著的折扣、有限的数量和短暂的时间导致了显著的高流量高峰,这往往会导致服务缓慢、拒绝服务,甚至是停机。
本文说明了如何利用WasmHost过滤器来保护闪购中的后端服务。WebAssembly代码是通过使用Easegress AssemblyScript SDK用AssemblyScript编写的。
在开始之前,我们需要介绍一下为什么使用WebAssembly的服务网关。首先,Easegress作为一个服务网关,更多的是负责控制逻辑。其次,像闪购这样的业务逻辑将是一个更加个性化的东西,可以经常改变。使用Javascript或其他高级语言来编写业务逻辑可以带来良好的生产力,降低技术门槛。通过WebAssembly技术,高级语言的代码可以被编译成WASM,并在运行时动态加载。此外,WebAssembly代码有足够好的性能和安全性。因此,这种组合可以在安全、高性能和定制扩展方面提供一个完美的解决方案。
在继续之前,请确保安装了最新版本的Git、Golang、Node.js和其软件包管理器npm。关于编写和使用TypeScript模块的基本知识,这与AssemblyScript非常相似,是一个加分项。
注意:WasmHost
过滤器默认是禁用的。要启用它,你需要用以下命令构建Easegress。
$ make build_server GOTAGS=wasmhost
1 ) 克隆git仓库easegress-assemblyscript-sdk
到磁盘上的某个地方
$ git clone https://github.com/megaease/easegress-assemblyscript-sdk.git
2 ) 切换到一个新的目录,初始化一个新的node模块。
npm init
3 ) 使用npm安装AssemblyScript编译器,假设编译器在生产中不需要,并将其作为开发依赖。
npm install --save-dev assemblyscript
4 ) 安装后,编译器提供了一个方便的脚手架工具,可以快速建立一个新的AssemblyScript项目,例如,在刚刚初始化的节点模块的目录中。
npx asinit 。
5 ) 在package.json
中的asc
中添加--use abort=
,例如。
"asbuild:untouched"。"asc assembly/index.ts --target debug --use abort=", "asbuild:optimized":"asc assembly/index.ts --target release --use abort="。
6 ) 用下面的代码替换assembly/index.ts
的内容,注意用步骤1)中的路径替换{EASEGRESS_SDK_PATH}
。这段代码只是一个骨架,目前 "什么都没做",以后会加强。
// 这一行导出了Easegress需要的所有东西,导出 * 来自 '{EASEGRESS_SDK_PATH}/easegress/proxy' // 从SDK中导入你需要的所有东西,导入 { Program, registerProgramFactory } 来自 '{EASEGRESS_SDK_PATH}/easegress' // 定义程序,'FlashSale' 是名称 class FlashSale extends Program { // constructor 是程序的初始化器,将在启动时调用一次 constructor(params:Map<string, string>) { super(params) } // run将在每次请求时被调用 run(): i32 { return 0 } } // 注册一个FlashSale程序的工厂方法 registerProgramFactory((params: Map<string, string>) => { return new FlashSale(params) })
7 ) 用下面的命令进行构建,如果一切正常,untouched.wasm
(调试版本)和optimized.wasm
(发布版本)将在构建
文件夹中生成。
$ npm run asbuild
在Easegress中创建一个HTTPServer,监听端口为10080,以处理HTTP流量。
$ echo ' kind: HTTPServer name: http-server port:10080 keepAlive: true https: false rules: - paths: - pathPrefix:/flashsale 后台: flash-sale-pipeline' | egctl object create
创建管道flash-sale-pipeline
,包括一个WasmHost
过滤器。
$ echo ' name: flash-ale-pipeline kind: HTTPPipeline flow: - filter: wasm - filter: mock filters: - name: wasm kind:WasmHost maxConcurrency: 2 code:/home/megaease/example/build/optimized.wasm timeout:100ms - name: mock kind:Mock 规则: - body:"你现在可以用1美元买下这台笔记本。"代码。200' | egctl对象创建
注意将/home/megaease/example/build/optimized.wasm
替换为1.1节步骤7)中生成的文件的路径。
在上面的管道配置中,一个Mock
过滤器被用作后端服务。在实践中,你将需要一个代理
过滤器来转发请求到真正的后端。
在一个新的控制台中执行下面的命令,如果一切正常,你应该得到一个类似的结果。
$ curl http://127.0.0.1:10080/flashsale 你现在可以用1美元买到这台笔记本电脑。
所有闪购促销活动都有一个开始时间,在这个时间之前的请求应该被阻止。假设开始时间是UTC2021-08-08 00:00:00
,这可以通过下面的代码来完成。
export * from '{EASEGRESS_SDK_PATH}/easegress/proxy' import { Program, response, parseDate, getUnixTimeInMs, registerProgramFactory } from '{EASEGRESS_SDK_PATH}/easegress' class FlashSale extends Program { // startTime is the start time of the flash sale, unix timeestamp in millisecond startTime: i64 constructor(params: Map<string, string>) { super(params) this.startTime = parseDate("2021-08-08T00:00:00+00:00")。getTime() } run(): i32 { // if flash sale not start yet if (getUnixTimeInMs() < this.startTime) { // we just set response body to 'not start yet' here, in practice, // we will use 'response.response.setBody(String.UTF8.encode("not start yet.\n")) return 1 } return 0 } registerProgramFactory((params: Map<string, string>) => { return new FlashSale(params) })
构建并通知Easegress重新加载,用。
$ npm run asbuild $ egctl wasm reload-code
curl
闪购的URL,我们会在闪购开始前得到not start yet。
$ curl http://127.0.0.1:10080/flashsale not start yet.
在闪购开始后,Easegress应该随机阻断请求,这样可以大大减少发送到后端服务的请求总数,从而保护服务不受流量高峰的打击。随机性还带来了另一个好处:地域差异导致了延迟差异,延迟较低的用户更可能是早期用户。随机性消除了这些用户的优势,使闪购更加公平。
export * from '{EASEGRESS_SDK_PATH}/easegress/proxy' import { Program, response, parseDate, getUnixTimeInMs, rand, registerProgramFactory } from '{EASEGRESS_SDK_PATH}/easegress' class FlashSale extends Program { startTime: i64 // blockRatio是被屏蔽的请求的比例,以保护后端服务 // 例如: 0.blockRatio: f64 constructor(params: Map<string, string>) { super(params) this.startTime = parseDate("2021-08-08T00:00:00+00:00").getTime() this.blockRatio = 0.4 } run(): i32 { if (getUnixTimeInMs() < this.startTime) { response.setBody(String.UTF8.encode("not start yet.\n") return 1 } if (rand() > this.blockRatio) { // 幸运的家伙 return 0 } // 阻止这个请求,将响应主体设置为`sold out` response.setBody(String.UTF8.encode("sold out.\n")) return 2 } registerProgramFactory((params: Map<string, string>) => { return new FlashSale(params) })
用(假设闪购已经开始了)构建和验证。
$ npm run asbuild $ egctl wasm reload-code $ curl http://127.0.0.1:10080/flashsale sold out. $ curl http://127.0.0.1:10080/flashsale You can buy the laptop for 1 now. $ curl http://127.0.0.1:10080/flashsale sold out.
我们将在40%的可能性下得到一个卖完
的消息。注意这个例子中的blockRatio
是0.4
,而在实践中,0.999
、0.9999
会更有意义。
从业务的角度来看,在我们允许一个幸运的用户前进之后,我们应该永远允许这个用户前进;但是从最后一步的代码逻辑来看,如果用户再次访问该URL,该请求可能会被阻止。
幸运的是,所有的用户在加入闪购之前都需要登录,也就是说,请求中会包含一个用户的标识符,我们可以用这个标识符来记录幸运用户。
举个例子,我们假设授权
头的值是所需的标识符(该标识符可以是一个JWT令牌,验证器过滤器可以用来验证该令牌,但这不在本文的讨论范围之内)。
然而,由于过滤器配置中的maxConcurrency
选项,使用Set
来存储所有允许的用户是行不通的。
maxConcurrency
是WasmHost
过滤器的WebAssembly虚拟机的数量,由于WebAssembly的设计是安全的,两个虚拟机不能共享数据,即使它们执行的是同一份代码。也就是说,在VM1允许用户使用后,如果用户的下一个请求是由VM2处理的,可能会被阻止。当Easegress被部署为一个集群时,这种情况也可能发生。
为了克服这个问题,Easegress提供了访问共享数据的API。
export * from '{EASEGRESS_SDK_PATH}/easegress/proxy' import { Program, request, parseDate, response, cluster, getUnixTimeInMs, rand, registerProgramFactory } from '{EASEGRESS_SDK_PATH}/easegress' class FlashSale extends Program { startTime: i64 blockRatio: f64 constructor(params: Map<string, string>) { super(params) this.startTime = parseDate("2021-08-08T00:00:00+00:00").getTime() this.blockRatio = 0.4 } run(): i32 { if (getUnixTimeInMs() < this.startTime) { response.setBody(String.UTF8.encode("not start yet.\n")) return 1 } // 检查用户是否已经被允许 let id = request.getHeader("Authorization") if (cluster.getString("id/" + id) == "true") { return 0 } if (rand() > this.blockRatio) { // 把这个幸运儿添加到允许的用户中 cluster.putString("id/" + id, "true") return 0 } response.setBody(String.UTF8.encode("sold out.\n") return 2 } registerProgramFactory((params: Map<string, string>) => { return new FlashSale(params) })
用以下方法构建和验证。
$ npm run asbuild $ egctl wasm reload-code $ curl http://127.0.0.1:10080/flashsale -HAuthorization:user1 sold out. $ curl http://127.0.0.1:10080/flashsale -HAuthorization:user1 You can buy the laptop for 1 now. $ curl http://127.0.0.1:10080/flashsale -HAuthorization:user1 You can buy the laptop for 1 now.
重复curl
命令,我们会发现,该用户在第一次被允许后,再也不会被封锁了。
由于闪购中的数量往往是有限的,我们可以在允许一定数量的用户后再封杀用户。例如,如果数量是10,在大多数情况下允许100个用户就足够了。
export * from '{EASEGRESS_SDK_PATH}/easegress/proxy' import { Program, request, parseDate, response, cluster, getUnixTimeInMs, rand, registerProgramFactory } from '{EASEGRESS_SDK_PATH}/easegress' class FlashSale extends Program { startTime:i64 blockRatio: f64 // maxPermission是允许用户的上限 maxPermission: i32 constructor(params: Map<string, string>) { super(params) this.startTime = parseDate("2021-08-08T00:00:00+00:00").getTime() this.blockRatio = 0.4 this.maxPermission = 3 } run() : i32 { if (getUnixTimeInMs() < this.startTime) { response.setBody(String.UTF8.encode("not start yet.\n")) return 1 } let id = request.getHeader("Authorization") if (cluster.getString("id/" + id) == "true") { return 0 } // check the count of identifiers to see if we have reached the upper limit (cluster.countKey("id/" ) < this.maxPermission) { if (rand() > this.blockRatio) { cluster.putString("id/" + id, "true") return 0 } response.setBody(String.UTF8.encode("sold out.\n") return 2 } registerProgramFactory((params: Map<string, string>) => { return new FlashSale(params) })
用以下方法构建和验证。
$ npm run asbuild $ egctl wasm reload-code $ curl http://127.0.0.1:10080/flashsale -HAuthorization:user1 你现在可以用1美元购买笔记本了。 $ curl http://127.0.0.1:10080/flashsale -HAuthorization:user2 卖完了。 $ curl http://127.0.0.1:10080/flashsale -HAuthorization:user2 你现在可以用1美元购买笔记本了。 $ curl http://127.0.0.1:10080/flashsale -HAuthorization:user3 卖完了。
在3个用户被允许后,第4个用户被永远封锁了。
我们在上面的例子中硬编码了startTime
、blockRatio
和maxPermission
,这意味着如果我们有另一个闪购,我们需要修改代码。这并不是一个好的做法。
更好的方法是把这些参数放到配置中。
过滤器: - 名称:Wasm kind:WasmHost参数。 # + startTime: "2021-08-08T00:00:00+00:00" # + blockRatio: 0.4 # + maxPermission: 3 # +
然后修改程序的构造函数
以读入这些参数。
constructor(params: Map<string, string>) { super(params) let key = "startTime" if (params.has(key)) { let val = params.get(key) this.startTime = parseDate(val).getTime() } key = "blockRatio" if (params.has(key)) { let val = params.get(key) this.blockRatio = parseFloat(val) } key = "maxPermission" if (params.have(key)) { let val = params.get(key) this.maxPermission = i32(parseInt(val)) } }
正如我们在Lucky Once, Lucky Always中看到的那样,共享数据很有用,但在为新的闪购活动重复使用代码和配置时,遗留的数据可能会导致问题。Easegress提供了管理这些数据的命令。
我们可以用(其中flash-sale-pipeline
是管道名称,wasm
是过滤器名称)查看当前数据。
$ egctl wasm list-data flash-ale-pipeline wasm id/user1: "true" id/user2:"true" id/user3:"true"
用以下方法更新数据。
$ echo ' id/user4: "true" id/user5: "true"' | egctl wasm apply-data flash-sale-pipeline wasm $ egctl wasm list-data flash-sale-pipeline wasm id/user1: "true" id/user2:"true" id/user3:"true" id/user4: "true" id/user5: "true"
并用以下方法删除所有数据。
$ egctl wasm delete-data flash-ale-pipeline wasm $ egctl wasm list-data flash-ale-pipeline wasm {}.
好了,以上是所有的技术细节。你可以自由使用这些代码来定制你的业务逻辑。但是,需要注意的是,以上只是一个演示,实际的解决方案更加复杂,因为它还需要过滤爬虫和黑客。如果你需要一个更专业的解决方案,欢迎与我们联系。
利用WebAssembly的安全、高性能和实时动态加载能力,我们不仅可以在网关上做这样的高并发业务,还可以实现一些更复杂的业务逻辑支持。因为WebAssembly可以重用各种高级语言(如Javascript、C/C++、Rust、Python、C#等)。Easegress在分布式架构的高性能流量协调方面有更多的能力可以发挥。两者都为解决高效运行和维护的问题带来了很多想象力。