第一章:Go插件机制的核心原理与演进脉络
Go 插件机制(plugin package)是 Go 1.8 引入的实验性特性,旨在支持运行时动态加载编译后的 .so 文件,实现模块解耦与热扩展能力。其底层依赖于操作系统的动态链接器(如 Linux 的 dlopen/dlsym),要求宿主程序与插件必须使用完全一致的 Go 版本、构建标签、CGO 状态及 GOOS/GOARCH 环境编译,否则将触发 plugin.Open: plugin was built with a different version of package 等不可恢复错误。
动态符号绑定的本质
插件并非传统意义上的“代码注入”,而是通过导出符号表(exported symbols)建立静态契约:插件中所有需被宿主调用的变量或函数,必须满足三个条件——首字母大写(可导出)、类型为接口或函数签名、且在插件主包中显式赋值。例如:
// plugin/main.go
package main
import "fmt"
// 必须定义为全局变量,类型为 func() string
var GetVersion = func() string {
return "v1.2.0"
}
编译后生成插件:go build -buildmode=plugin -o version.so plugin/。宿主程序通过 plugin.Open("version.so") 加载,并用 sym, _ := p.Lookup("GetVersion") 获取符号,再经类型断言调用:versionFunc := sym.(func() string); fmt.Println(versionFunc())。
构建约束与兼容性边界
| 约束维度 | 强制要求 | 违反后果 |
|---|---|---|
| Go 运行时版本 | 宿主与插件必须完全相同(含 patch) | plugin.Open 直接 panic |
| CGO 启用状态 | 二者必须同为启用或禁用 | 符号解析失败或内存崩溃 |
| 接口定义一致性 | 插件中使用的接口类型需与宿主包内完全等价 | 类型断言失败(即使结构相同) |
演进中的现实局限
自 Go 1.15 起,插件机制被明确标记为“仅限 Linux/macOS,Windows 不支持”;Go 1.22 文档进一步强调其“不适用于生产环境”,推荐以 HTTP 微服务、gRPC 外部进程或 WASM 作为替代方案。社区主流实践已转向基于 go:generate + 接口注册表的编译期插件化,兼顾类型安全与部署可靠性。
第二章:插件加载失败的根源剖析与实战修复
2.1 动态链接与符号解析:理解plugin.Open的底层约束
Go 的 plugin.Open() 并非简单加载 .so 文件,而是依赖 ELF 动态链接器完成符号绑定,受制于运行时符号可见性规则。
符号可见性约束
- 导出函数必须使用
//export注释且定义在main包(或被main间接引用) - 非导出符号(首字母小写)无法被插件解析
- Cgo 构建时需启用
-buildmode=plugin
典型错误示例
// plugin.go
package main
import "C"
//export SayHello
func SayHello() string { return "hello" } // ✅ 可见
func internal() {} // ❌ 插件无法调用
此代码块中
SayHello经 CGO 处理后生成全局弱符号_cgo_XXXX,供动态链接器在dlsym()时查找;internal因无//export且未被 C 接口引用,不进入动态符号表(.dynsym)。
符号解析流程
graph TD
A[plugin.Open] --> B[dlopen]
B --> C[dlsym for exported symbols]
C --> D[检查 DT_NEEDED & symbol visibility]
D --> E[失败:符号未定义/protected]
| 约束类型 | 表现 | 检查时机 |
|---|---|---|
| 符号可见性 | dlsym 返回 nil |
运行时 |
| 构建模式 | 非 -buildmode=plugin 报错 |
go build 阶段 |
| Go 版本兼容性 | v1.8+ 支持,v1.16+ 强化校验 | plugin.Open 内部 |
2.2 构建环境一致性陷阱:CGO_ENABLED、GOOS/GOARCH与插件兼容性验证
Go 构建过程对环境变量高度敏感,微小配置偏差即可导致二进制不兼容或插件加载失败。
CGO_ENABLED:C 依赖的双刃剑
启用时链接系统 libc,禁用时生成纯静态 Go 二进制——但 net 包 DNS 解析行为会降级为纯 Go 实现:
# 禁用 CGO → 静态链接,但可能无法解析某些 DNS 配置
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o app-static .
# 启用 CGO → 动态链接,需目标环境存在对应 libc 版本
CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -o app-dynamic .
分析:
CGO_ENABLED=0强制纯 Go 运行时,规避 libc 版本冲突,但牺牲cgo支持的 syscall 和部分标准库功能(如os/user);CGO_ENABLED=1要求构建机与目标机 ABI 兼容,否则插件dlopen失败。
多平台交叉编译组合矩阵
| GOOS | GOARCH | 插件兼容性风险点 |
|---|---|---|
| linux | amd64 | libc 版本 mismatch(glibc ≥ 2.28) |
| linux | arm64 | 内核 ABI 差异(如 membarrier) |
| windows | amd64 | DLL 导出符号命名约定(_Cfunc_ 前缀) |
插件加载验证流程
graph TD
A[构建插件] --> B{CGO_ENABLED=0?}
B -->|Yes| C[纯 Go 符号表]
B -->|No| D[动态符号 + libc 依赖]
C --> E[LoadPlugin OK]
D --> F[检查目标 libc 版本]
F -->|匹配| E
F -->|不匹配| G[plugin.Open: symbol not found]
关键实践:始终在目标环境执行 go env -json 并复现构建参数,而非仅依赖本地开发配置。
2.3 接口定义跨编译单元失效:unsafe.Pointer转型与反射边界实测案例
现象复现:接口方法调用在包边界崩溃
当 unsafe.Pointer 转型为跨编译单元定义的接口(如 io.Reader 在 pkgA 定义,pkgB 中用 reflect.Interface() 构造)时,运行时报 panic: interface conversion: interface {} is *main.T, not io.Reader——即使底层类型完全匹配。
核心原因:接口类型元数据隔离
Go 编译器为每个编译单元生成独立的 runtime._type 和 runtime.itab 表。即使签名一致,pkgA/io.Reader 与 pkgB/io.Reader 的 itab 地址不同,导致 reflect.Value.Convert() 拒绝转换。
// pkgB/main.go —— 尝试将 *T 强转为 pkgA 定义的 Reader 接口
t := &T{}
ptr := unsafe.Pointer(t)
v := reflect.NewAt(reflect.TypeOf((*T)(nil)).Elem(), ptr).Elem()
// ❌ panic:无法将 *T 转为 pkgA.Reader(非同一 itab)
reader := v.Interface().(pkgA.Reader) // runtime error
逻辑分析:
reflect.NewAt创建的是未绑定目标包itab的reflect.Value;Interface()返回的interface{}底层类型仍归属当前包,与pkgA.Reader的类型描述符不兼容。参数ptr有效,但类型系统无跨包itab共享机制。
实测对比表:跨包接口转换结果
| 场景 | 类型定义位置 | reflect.Convert() 是否成功 |
原因 |
|---|---|---|---|
| 同包内接口 | main 包 |
✅ | itab 共享 |
| 跨包同名接口 | pkgA vs pkgB |
❌ | itab 地址隔离 |
使用 unsafe + syscall 透传 |
仅限 unsafe 直接调用 |
⚠️(需手动构造 itab) |
超出安全边界 |
graph TD
A[unsafe.Pointer] --> B[reflect.NewAt]
B --> C[reflect.Value.Interface]
C --> D{类型匹配检查}
D -->|itab 不同| E[panic: interface conversion]
D -->|itab 相同| F[成功转型]
2.4 插件热加载时的内存泄漏路径:runtime.SetFinalizer与goroutine生命周期管理
Finalizer 与插件对象的隐式引用链
当插件模块通过 plugin.Open() 加载后,若为其注册 runtime.SetFinalizer(obj, cleanup),Finalizer 会阻止 GC 回收 obj 及其闭包中所有可达对象——包括持有 *http.ServeMux 或 sync.WaitGroup 的插件实例。
goroutine 泄漏的典型模式
func startPluginHandler(p *Plugin) {
go func() {
defer p.wg.Done()
http.ListenAndServe(":8080", p.mux) // 阻塞直至插件卸载
}()
}
p.wg和p.mux被匿名 goroutine 持有 → 即使p被设为nil,Finalizer 未触发前 GC 不回收ListenAndServe未显式关闭 → goroutine 永驻,p无法被 Finalizer 清理
修复策略对比
| 方案 | 是否打破引用环 | 需手动调用 runtime.GC() |
适用场景 |
|---|---|---|---|
runtime.SetFinalizer(nil) 显式清除 |
✅ | ❌ | 插件卸载时精准清理 |
使用 sync.Once + atomic.Bool 控制启动 |
✅ | ❌ | 避免 goroutine 重复启动 |
context.WithCancel 管理监听生命周期 |
✅ | ❌ | 支持优雅关闭 |
关键修复代码
// 插件卸载时必须解绑 Finalizer 并取消 goroutine
func (p *Plugin) Unload() {
p.cancel() // 触发 ListenAndServe 退出
p.wg.Wait() // 等待 goroutine 结束
runtime.SetFinalizer(p, nil) // 解除 Finalizer 引用
}
runtime.SetFinalizer(p, nil)是打破引用环的必要操作;否则 Finalizer 闭包持续持有p,导致p.wg、p.mux及其依赖对象全部泄漏。
2.5 Go 1.16+ plugin限制升级应对:替代方案选型(io/fs、embed、WASM)与迁移策略
Go 1.16 起 plugin 包在非 Linux 平台被禁用,且静态链接下彻底失效。需转向安全、可移植的模块化方案。
核心替代路径对比
| 方案 | 适用场景 | 编译时依赖 | 运行时动态加载 | 安全边界 |
|---|---|---|---|---|
embed |
静态资源/模板/配置 | ✅ | ❌ | 高 |
io/fs |
可挂载只读文件系统 | ⚠️(FS 接口) | ✅(FS 实现) | 中 |
| WebAssembly | 跨平台沙箱逻辑模块 | ✅(wazero) | ✅ | 极高 |
embed 示例(编译期注入)
import _ "embed"
//go:embed assets/config.json
var configJSON []byte // 自动嵌入为只读字节切片
// embed 指令将文件内容在编译时固化进二进制,零运行时 I/O 开销;
// configJSON 是不可变 []byte,无反射或 unsafe 操作,符合内存安全模型。
迁移决策流程
graph TD
A[插件需求] --> B{是否需热更新?}
B -->|否| C[首选 embed + io/fs 组合]
B -->|是| D{是否跨语言/平台?}
D -->|是| E[WASM + wazero]
D -->|否| F[自定义 FS + reloadable http.FileSystem]
第三章:插件安全边界失控的三大高危场景
3.1 未签名插件注入攻击:SHA256校验链构建与TLS证书绑定实践
未签名插件因缺乏完整性校验,易被中间人篡改并注入恶意逻辑。防御核心在于建立可信校验链:插件二进制 → SHA256哈希 → TLS双向认证绑定的证书公钥。
校验链构建流程
# 1. 生成插件哈希(带路径绑定防重放)
sha256sum /opt/plugins/monitor-v2.so | awk '{print $1}' > plugin.sha256
# 2. 将哈希嵌入服务端证书扩展字段(OID: 1.3.6.1.4.1.9999.1.5)
openssl x509 -in server.crt -text -noout | grep -A2 "1.3.6.1.4.1.9999"
该命令确保插件哈希由CA私钥签名,客户端校验时同步验证证书链与哈希一致性,阻断未授权替换。
TLS证书绑定关键字段
| 字段名 | 值示例 | 作用 |
|---|---|---|
| Subject OID | 1.3.6.1.4.1.9999.1.5=abc… | 绑定插件SHA256摘要 |
| Extended Key Usage | clientAuth, serverAuth | 强制双向认证启用 |
验证流程图
graph TD
A[加载插件] --> B[计算SHA256]
B --> C{比对证书扩展字段}
C -->|匹配| D[加载执行]
C -->|不匹配| E[拒绝并告警]
3.2 类型断言绕过导致的越权调用:interface{}类型系统沙箱设计
Go 的 interface{} 是万能容器,但隐式类型转换常成为权限逃逸入口。
沙箱边界失效场景
当服务端将用户角色封装为 interface{} 并依赖运行时断言校验时,攻击者可构造恶意嵌套结构绕过类型检查:
// 恶意 payload:伪装成合法 Admin 接口实现
type FakeAdmin struct{}
func (f FakeAdmin) HasPermission() bool { return true } // 绕过鉴权逻辑
var userRole interface{} = FakeAdmin{} // 非预期类型注入
if admin, ok := userRole.(Admin); ok { // 断言失败 → 跳过权限分支
admin.DoCriticalAction()
}
此处
userRole.(Admin)断言失败(FakeAdmin不实现Admin接口),但若后续逻辑未校验ok即执行敏感操作,则触发越权。关键参数:ok布尔值必须参与控制流,否则断言形同虚设。
安全加固策略对比
| 方案 | 可靠性 | 运行时开销 | 适用场景 |
|---|---|---|---|
接口显式校验 + ok 分支强制处理 |
⭐⭐⭐⭐⭐ | 低 | 所有类型断言 |
| 类型白名单反射校验 | ⭐⭐⭐⭐ | 中 | 动态插件场景 |
| 编译期接口约束(如 generics) | ⭐⭐⭐⭐⭐ | 零 | Go 1.18+ 新项目 |
graph TD
A[interface{} 输入] --> B{类型断言}
B -->|ok==true| C[执行特权操作]
B -->|ok==false| D[拒绝或降级处理]
D --> E[日志审计]
3.3 插件panic传播至宿主进程:recover机制嵌套保护与错误隔离域实现
插件系统需在宿主进程中构建多层recover防护边界,防止插件panic穿透至主业务逻辑。
错误隔离域设计原则
- 每个插件执行上下文独占goroutine + defer-recover对
- recover必须在最外层defer中触发,且不得裸抛panic
- 隔离域间禁止共享可panic的闭包或未受控反射调用
嵌套recover典型实现
func runPluginSafely(plugin Plugin) error {
defer func() {
if r := recover(); r != nil {
log.Error("plugin panic recovered", "err", r)
// 转换为可控错误,不传播至宿主
atomic.StoreUint32(&pluginState, StateCrashed)
}
}()
plugin.Execute() // 可能panic
return nil
}
recover()仅捕获当前goroutine内最近一次panic;atomic.StoreUint32确保崩溃状态线程安全更新,避免竞态导致隔离失效。
防护层级对比
| 层级 | 作用域 | recover位置 | 是否阻断传播 |
|---|---|---|---|
| 插件函数内 | 单次Execute调用 | 函数入口defer | ✅ |
| 插件管理器 | 多插件调度循环 | goroutine外层defer | ✅✅(双重防护) |
graph TD
A[Plugin.Execute] --> B{panic?}
B -->|是| C[内层recover捕获]
B -->|否| D[正常返回]
C --> E[转为error并标记状态]
E --> F[宿主继续调度其他插件]
第四章:生产级插件架构的工程化落地
4.1 插件元数据注册中心:JSON Schema驱动的Manifest规范与校验器开发
插件生态的可维护性始于统一、可验证的元数据契约。我们定义 manifest.json 为插件身份与能力的唯一声明入口,其结构由严格版本化的 JSON Schema 约束。
核心 Schema 片段(v1.2)
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["id", "name", "version", "manifest_version"],
"properties": {
"id": { "type": "string", "pattern": "^[a-z][a-z0-9_\\-]{2,32}$" },
"manifest_version": { "const": 2 },
"permissions": { "type": "array", "items": { "type": "string" } }
}
}
该 Schema 强制插件 ID 符合小写字母开头、含连字符/下划线的命名规范,manifest_version 锁定为 2 以保障向后兼容;permissions 数组确保权限声明为字符串集合,避免类型歧义。
校验器核心逻辑
def validate_manifest(manifest_path: str) -> bool:
with open(manifest_path) as f:
data = json.load(f)
validator = Draft202012Validator(schema)
errors = list(validator.iter_errors(data))
return len(errors) == 0
调用 Draft202012Validator 实例执行深度语义校验,iter_errors() 返回结构化错误链(含字段路径、失败断言),支撑精准调试。
| 字段 | 类型 | 约束说明 |
|---|---|---|
id |
string | 正则校验,防注入与命名冲突 |
version |
string | 语义化版本(SemVer)格式校验(额外扩展) |
main |
string | 必须指向存在且可读的 JS 文件 |
graph TD
A[读取 manifest.json] --> B[解析 JSON]
B --> C[加载 v1.2 Schema]
C --> D[执行 Draft202012 验证]
D --> E{无错误?}
E -->|是| F[注册至元数据中心]
E -->|否| G[返回结构化错误位置]
4.2 版本语义化控制:Go Module Proxy拦截与插件依赖图谱可视化
Go Module Proxy 不仅加速依赖拉取,更成为语义化版本治理的关键入口。通过自定义 proxy handler,可拦截 GET /@v/list、GET /@v/{version}.info 等请求,动态注入合规性校验逻辑。
拦截式版本过滤示例
func versionListHandler(w http.ResponseWriter, r *http.Request) {
module := strings.TrimPrefix(r.URL.Path, "/@v/")
versions, _ := listVersionsFromUpstream(module) // 从 upstream 获取原始版本列表
filtered := semver.Filter(versions, ">=1.2.0 <2.0.0") // 仅允许 v1.x.y
json.NewEncoder(w).Encode(filtered)
}
该 handler 在代理层强制执行语义化约束:semver.Filter 基于主版本号隔离,避免插件意外升级至不兼容大版本。
依赖图谱生成流程
graph TD
A[go list -m -json all] --> B[解析 module path & version]
B --> C[构建有向边:require → replace]
C --> D[渲染为 Force-Directed Graph]
| 工具链 | 输出格式 | 可视化能力 |
|---|---|---|
go mod graph |
文本边集 | 需配合 Graphviz |
gomod-graph |
JSON | 支持 Web 交互渲染 |
deps.dev API |
REST | 提供 CVE 关联标注 |
4.3 热更新原子性保障:双阶段加载+原子指针切换+健康探针集成
热更新的可靠性依赖于三重协同机制:加载隔离、切换安全、就绪验证。
双阶段加载:预加载与校验分离
新版本模块在独立内存空间完成解压、解析与静态校验(如签名、哈希),避免污染主运行时。仅当全部校验通过后,才进入切换准备态。
原子指针切换:零停机关键操作
// 使用 GCC __atomic_load_n / __atomic_store_n 实现无锁切换
void atomic_swap_handler(handler_t **current, handler_t *new) {
handler_t *old = __atomic_load_n(current, __ATOMIC_ACQUIRE);
__atomic_store_n(current, new, __ATOMIC_RELEASE); // 内存屏障保证可见性
// old 可安全异步释放(RCU风格延迟回收)
}
__ATOMIC_RELEASE 确保新 handler 对所有 CPU 核心立即可见;__ATOMIC_ACQUIRE 保证读取旧值时已同步所有前置写操作。
健康探针集成:切换后自验证
| 探针类型 | 检查项 | 超时阈值 | 失败动作 |
|---|---|---|---|
| Liveness | /healthz HTTP 200 |
2s | 回滚并告警 |
| Readiness | /readyz 业务就绪 |
5s | 暂缓流量导入 |
graph TD
A[新模块预加载] --> B[全量校验通过?]
B -->|Yes| C[原子指针切换]
C --> D[启动健康探针轮询]
D --> E{Liveness/Readiness OK?}
E -->|Yes| F[流量逐步切流]
E -->|No| G[触发自动回滚]
4.4 插件可观测性体系:OpenTelemetry插件追踪上下文透传与指标埋点规范
插件需在无侵入前提下继承宿主的分布式追踪上下文,避免 Span 断链。
上下文透传机制
通过 propagators.extract() 从插件调用入口(如 HTTP header、消息属性)中解析 traceparent 和 tracestate:
from opentelemetry.propagators import get_global_textmap
carrier = {"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"}
ctx = get_global_textmap().extract(carrier)
# 逻辑分析:extract() 自动识别 W3C TraceContext 格式,还原 SpanContext 并注入当前上下文栈;
# carrier 必须含 traceparent(必需),tracestate(可选,用于跨厂商传播采样决策)
指标埋点统一规范
插件应复用宿主注册的 Meter 实例,禁止新建:
| 指标类型 | 名称示例 | 单位 | 推荐标签 |
|---|---|---|---|
| Counter | plugin.requests | {req} | plugin_name, status |
| Histogram | plugin.latency | ms | plugin_name, op |
数据同步机制
插件生命周期钩子(如 on_invoke/on_response)中自动绑定上下文:
graph TD
A[插件入口] --> B{是否携带 traceparent?}
B -->|是| C[extract → 注入全局 Context]
B -->|否| D[继承父 Span 或创建独立 Root]
C & D --> E[所有 span.start_span 使用当前 Context]
第五章:Go插件生态的未来演进与替代技术展望
插件热加载在CI/CD流水线中的真实落地案例
某金融级API网关项目(基于Gin + Go Plugin)在2023年Q4完成插件热加载改造。通过plugin.Open()动态加载.so文件,并结合atomic.Value缓存插件实例,将风控策略更新延迟从分钟级压缩至800ms内。关键约束在于:所有插件必须实现PluginInterface{Init(), Handle(*http.Request) error}接口,且编译时需严格匹配Go版本(1.21.6)、GOOS(linux)、GOARCH(amd64)。该方案已稳定运行超18个月,日均热加载策略插件47次,零因ABI不兼容导致的panic。
WebAssembly作为跨平台插件载体的可行性验证
Cloudflare Workers团队开源的wazero运行时已在Go 1.22中实现实验性集成。某边缘计算平台将原Go插件重写为WASI兼容模块,编译命令如下:
tinygo build -o auth.wasm -target wasi ./auth_plugin.go
实测表明:单次WASM插件调用平均耗时2.3ms(对比原生.so插件1.7ms),内存隔离性提升300%,且支持Windows/macOS/Linux三端统一部署。但需注意:net/http等标准库无法直接调用,必须通过wazero提供的host function桥接。
模块化架构迁移路径对比表
| 迁移方案 | 编译依赖管理 | ABI稳定性 | 调试支持 | 生产就绪度 |
|---|---|---|---|---|
| 原生plugin包 | Go版本强绑定 | 极脆弱 | gdb可用 | ★★★★☆ |
| WASI+Wazero | 无Go依赖 | 高 | wasm-tools | ★★★☆☆ |
| gRPC插件服务 | 独立进程 | 无 | pprof+trace | ★★★★★ |
动态链接安全加固实践
某支付系统采用-buildmode=plugin构建插件时,强制启用以下安全措施:
go build -ldflags="-buildid= -s -w"移除调试符号与构建ID- 使用
notary对.so文件签名,加载前校验sha256sum与PGP签名 - 插件进程运行于
seccomp白名单模式(仅允许read/write/mmap等12个系统调用)
多语言插件协同工作流
Kubernetes Operator项目采用gRPC+Protobuf定义插件契约:
service PluginService {
rpc Validate(Request) returns (Response);
}
message Request { string payload = 1; bytes context = 2; }
Python/Rust插件通过grpcurl注册到同一服务发现中心(Consul),Go主程序通过grpc.DialContext()按需调用。实测吞吐量达12,400 QPS,错误率
构建时插件注入机制
利用Go 1.22引入的//go:embed与embed.FS特性,某IoT固件升级系统将插件逻辑编译进二进制:
//go:embed plugins/*.so
var pluginFS embed.FS
func LoadEmbeddedPlugin(name string) (*plugin.Plugin, error) {
data, _ := pluginFS.ReadFile("plugins/" + name)
return plugin.Open(io.NopCloser(bytes.NewReader(data)))
}
该方案规避了运行时文件系统依赖,使容器镜像体积减少42%,启动时间缩短至1.2秒。
插件沙箱逃逸漏洞修复记录
2024年3月发现CVE-2024-29821:恶意插件可通过unsafe.Pointer绕过plugin包类型检查。修复方案包括:
- 在
plugin.Open()后立即执行reflect.ValueOf(plugin).CanAddr()校验 - 启用
GODEBUG=pluginuntrusted=1环境变量强制禁用unsafe操作 - 对插件导出符号执行
strings.HasPrefix(sym.Name, "main.")黑名单过滤
WASM插件性能压测数据
使用hey -z 30s -c 100 http://localhost:8080/auth对同功能插件进行对比测试:
| 插件类型 | 平均延迟(ms) | P99延迟(ms) | 内存占用(MB) | CPU使用率(%) |
|---|---|---|---|---|
| 原生.so | 1.7 | 4.2 | 18.3 | 22 |
| WASM+wazero | 2.3 | 6.8 | 9.1 | 15 |
| gRPC服务 | 8.9 | 24.7 | 42.6 | 38 |
