第一章:Go期货接口开发的核心架构与设计哲学
Go语言凭借其轻量级协程、高效并发模型和简洁的语法,成为高频期货交易系统后端开发的理想选择。核心架构围绕“分离关注点”与“可伸缩性优先”展开,强调网络层、协议解析层、业务逻辑层和持久化层的清晰边界。
接口抽象与统一通信契约
期货交易所(如CTP、DCE、INE)通常提供C++ SDK,Go生态通过cgo封装或纯Go实现的WebSocket/REST API对接。推荐采用接口抽象方式解耦具体实现:
// 定义统一行情与交易接口契约
type MarketClient interface {
Subscribe(symbols []string) error
OnQuote(func(Quote) error) // 注册回调,非阻塞处理
}
type TradeClient interface {
PlaceOrder(req OrderRequest) (string, error)
CancelOrder(orderID string) error
}
该设计使策略模块仅依赖接口,便于在模拟环境与实盘间无缝切换。
并发安全的数据流管理
行情数据高频涌入(如Level2每秒数千条),需避免锁竞争。典型实践是:每个连接启动独立goroutine读取原始字节流,经无锁通道(chan)投递给解析器;解析后结构体通过ring buffer暂存,供策略引擎以毫秒级延迟消费。
错误处理与熔断机制
期货接口调用失败不可忽视。应内置分级容错:
- 网络超时:设置
context.WithTimeout,重试上限3次,指数退避 - 协议异常:校验字段长度、校验和,非法包直接丢弃并告警
- 服务熔断:连续5次
Login失败后暂停连接10秒,防止雪崩
| 场景 | 响应策略 |
|---|---|
| 行情断连 | 自动重连 + 本地快照回补 |
| 撤单返回“订单不存在” | 同步查询委托状态,避免状态不一致 |
| 风控限速触发 | 降频发送 + 本地队列缓冲 |
设计哲学内核
Go期货系统拒绝过度工程——不引入服务网格、不强求DDD分层;坚持“小而专”的组件原则:一个goroutine只做一件事,一个包只解决一类问题。这种克制保障了低延迟(平均fsnotify监听配置变更)。
第二章:连接层稳定性保障:从WebSocket心跳到重连熔断
2.1 WebSocket长连接的生命周期管理与Go协程泄漏防控
WebSocket连接需严格绑定协程生命周期,否则易引发 goroutine 泄漏。关键在于:连接建立 → 消息收发 → 异常中断 → 资源清理 四阶段闭环控制。
协程安全退出机制
func handleConn(conn *websocket.Conn) {
defer conn.Close() // 确保连接关闭
done := make(chan struct{})
go func() {
defer close(done)
for {
_, _, err := conn.ReadMessage()
if err != nil {
return // 触发 cleanup
}
}
}()
select {
case <-done:
return // 正常退出
case <-time.After(30 * time.Second):
conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseGoingAway, ""))
}
}
done 通道同步读协程终止;select 防止写协程阻塞;conn.Close() 触发底层 net.Conn 关闭,自动唤醒所有阻塞 I/O。
常见泄漏场景对比
| 场景 | 是否持有 conn | 是否关闭 done | 是否触发 defer |
|---|---|---|---|
| 忘记 defer conn.Close() | ✅ | ❌ | ❌ |
| read goroutine panic 未 recover | ✅ | ❌ | ❌ |
| 心跳超时未主动 close | ✅ | ✅ | ✅(但资源未释放) |
生命周期状态流转
graph TD
A[New Conn] --> B[Handshake OK]
B --> C[Read/Write Active]
C --> D{Error or Timeout?}
D -->|Yes| E[conn.Close()]
D -->|No| C
E --> F[defer cleanup: mutex.Unlock, db.Close]
2.2 心跳超时检测机制与时间轮(TimingWheel)实践实现
心跳超时检测是分布式系统中服务可用性保障的核心环节。朴素的 Timer 或 ScheduledThreadPoolExecutor 在高并发场景下存在内存与调度开销瓶颈,而分层时间轮(Hierarchical TimingWheel)以 O(1) 插入/删除和空间换时间思想成为工业级首选。
为什么选择时间轮?
- ✅ 固定时间复杂度:添加/删除定时任务均为 O(1)
- ✅ 内存友好:避免大量
Future对象堆积 - ❌ 不支持动态调整精度(需预设 tickMs 和 wheelSize)
核心结构示意
| 字段 | 含义 | 典型值 |
|---|---|---|
tickMs |
每格代表毫秒数 | 100 |
wheelSize |
单层槽位总数 | 20 |
interval |
单层总时间跨度(ms) | tickMs × wheelSize = 2000 |
public class TimingWheel {
private final long tickMs; // 基础时间刻度(如100ms)
private final int wheelSize; // 每层槽数(如20)
private final TimerTaskList[] buckets; // 槽位数组,每个桶是双向链表
private long currentTime; // 当前已推进到的时间(对齐 tickMs 的倍数)
public void add(TimerTask task) {
long expiration = task.getExpiration();
if (expiration < currentTime + tickMs) {
// 立即过期,交由溢出处理器或直接执行
task.execute();
} else {
long virtualId = (expiration - currentTime) / tickMs;
int bucketIndex = (int) (virtualId % wheelSize);
buckets[bucketIndex].add(task); // O(1) 插入链表尾
}
}
}
逻辑分析:
add()方法将任务按相对偏移映射至对应槽位。currentTime严格按tickMs对齐(如 0, 100, 200…),确保所有任务在currentTime + tickMs到达时被批量触发。bucketIndex计算隐含取模,天然支持循环复用。
时间推进流程(mermaid)
graph TD
A[每 tickMs 触发一次推进] --> B[更新 currentTime += tickMs]
B --> C[清空并遍历当前槽位 buckets[currentIndex]]
C --> D[执行所有到期任务]
D --> E[若存在层级溢出任务?]
E -->|是| F[降级插入上层时间轮]
E -->|否| G[推进完成]
2.3 断线重连策略:指数退避+会话状态同步的工程化落地
核心重连逻辑实现
import time
import random
def exponential_backoff_retry(max_retries=5, base_delay=1.0, jitter=True):
for attempt in range(max_retries + 1):
try:
# 模拟连接操作
return connect_to_mqtt_broker()
except ConnectionError:
if attempt == max_retries:
raise
delay = base_delay * (2 ** attempt)
if jitter:
delay *= random.uniform(0.8, 1.2) # 抗雪崩抖动
time.sleep(delay)
逻辑分析:采用
2^attempt基础退避,base_delay=1.0s起始,第3次失败后等待约4–4.8秒。jitter避免全网客户端集体重试。
会话状态同步关键步骤
- 客户端本地缓存 last_seq_id 与 pending_msgs(未ACK消息队列)
- 重连成功后,向服务端发起
SYNC_REQ(last_seq_id) - 服务端返回增量消息流(含幂等ID与时间戳)
重连状态机(Mermaid)
graph TD
A[Disconnected] -->|网络异常| B[Backoff Wait]
B --> C[Reconnect Attempt]
C -->|Success| D[Sync Session State]
C -->|Fail| B
D --> E[Resend Unacked]
E --> F[Normal Operation]
2.4 多交易所连接复用与连接池抽象:基于sync.Pool与channel的轻量级封装
在高频交易场景中,频繁建立/销毁 WebSocket 或 HTTP 连接会引发系统调用开销与 GC 压力。我们采用 sync.Pool 管理连接实例,并通过 chan *Connection 实现租借-归还协议。
连接池核心结构
type ConnectionPool struct {
pool *sync.Pool
ch chan *Connection // 非阻塞预热通道(容量=5)
}
func newConnectionPool() *ConnectionPool {
return &ConnectionPool{
pool: &sync.Pool{New: func() interface{} { return NewConnection() }},
ch: make(chan *Connection, 5),
}
}
sync.Pool.New 在无可用对象时按需构造;ch 用于快速分发预热连接,规避 Get() 的首次初始化延迟。
租借与归还语义
- 租借:优先从
ch尝试非阻塞获取,失败则 fallback 到pool.Get() - 归还:若
ch未满则推入,否则调用pool.Put()回收
| 操作 | 路径优先级 | GC 影响 |
|---|---|---|
| 租借 | ch → pool | 无 |
| 归还 | ch(满)→ pool | 仅满时触发 Put |
graph TD
A[Request Conn] --> B{ch has available?}
B -->|Yes| C[Pop from ch]
B -->|No| D[pool.Get()]
C --> E[Use]
D --> E
E --> F[Return Conn]
F --> G{ch full?}
G -->|No| H[Push to ch]
G -->|Yes| I[pool.Put]
2.5 TLS双向认证在期货网关中的Go原生实现与证书热加载方案
核心实现逻辑
Go 原生 tls.Config 支持双向认证,关键在于设置 ClientAuth: tls.RequireAndVerifyClientCert 并加载 CA 证书池。
// 构建服务端 TLS 配置(支持热更新)
certPool := x509.NewCertPool()
certPool.AppendCertsFromPEM(caPEM) // 期货交易所根CA
config := &tls.Config{
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: certPool,
GetCertificate: getCertFunc, // 动态获取服务端证书
}
getCertFunc 是闭包函数,封装了证书文件监听与解析逻辑;ClientCAs 必须实时同步交易所更新的根证书,否则将拒绝合法客户端连接。
热加载机制设计
采用 fsnotify 监听证书目录变更,触发原子性重载:
- ✅ 监听
server.crt、server.key、ca.crt三类文件 - ✅ 双缓冲切换:新配置验证通过后,原子替换
atomic.StorePointer指向的*tls.Config - ❌ 禁止 reload 期间新建连接中断(利用
tls.Config.Clone()保障并发安全)
证书生命周期管理对比
| 阶段 | 传统方式 | 本方案 |
|---|---|---|
| 加载时机 | 启动时一次性加载 | 文件变更实时响应 |
| 连接影响 | 需重启进程 | 零停机,平滑过渡 |
| 安全性保障 | 依赖人工巡检 | SHA256 校验 + X.509 有效期自动校验 |
graph TD
A[证书文件变更] --> B{fsnotify 捕获}
B --> C[解析 PEM → *x509.Certificate]
C --> D[验证签名/有效期/链路]
D -->|成功| E[原子替换 tls.Config]
D -->|失败| F[保留旧配置,告警]
第三章:行情与订单数据流治理:序列化、校验与一致性保障
3.1 Protobuf vs JSON-RPC:期货报文序列化的性能压测与选型决策树
期货交易系统对报文序列化吞吐量、延迟和带宽敏感。我们基于真实行情+订单组合报文(含32字段、嵌套重复组)开展百万级TPS压测。
基准测试配置
- 环境:Intel Xeon Gold 6330 ×2,32GB RAM,Linux 5.15,gRPC v1.59(Protobuf底层),JSON-RPC 2.0 over HTTP/1.1(curl + rapidjson)
- 报文规模:平均原始字节数 — Protobuf二进制为 86B,JSON文本为 324B
序列化耗时对比(单线程,纳秒级)
| 序列化方式 | 平均耗时 | CPU周期波动 | 内存分配次数 |
|---|---|---|---|
| Protobuf | 128 ns | ±3.2% | 0(零拷贝复用) |
| JSON-RPC | 892 ns | ±17.6% | 4–7次堆分配 |
// order.proto 示例核心定义(影响序列化效率的关键设计)
message OrderRequest {
required int64 client_order_id = 1; // 使用int64而非string减少解析开销
required string symbol = 2; // 预分配symbol_pool避免动态字符串构造
repeated Fill fills = 3 [packed=true]; // packed=true压缩repeated int32/int64数组
}
逻辑分析:
packed=true使repeated int32 price_levels从每元素独立tag+length+value → 单tag+length+连续二进制流,降低编码体积38%,解码缓存行命中率提升2.1×。
选型决策树(mermaid流程图)
graph TD
A[QPS ≥ 50k? ∧ P99延迟 ≤ 200μs?] -->|Yes| B[选Protobuf+gRPC]
A -->|No| C{是否需浏览器直连或跨语言调试?}
C -->|Yes| D[选JSON-RPC + schema校验]
C -->|No| B
关键权衡点
- Protobuf:强契约、高吞吐、低延迟,但需IDL管理与版本兼容策略(如
reserved字段预留); - JSON-RPC:可读性强、调试友好,但HTTP头部冗余与文本解析成本不可忽视。
3.2 订单本地快照(Local Order Book)的并发安全构建与内存布局优化
核心挑战
高频交易场景下,本地订单簿需支持微秒级读写、零锁竞争、缓存行对齐,同时保证跨线程视角一致性。
内存布局优化
采用结构体打包(#[repr(C, align(64))])避免伪共享,关键字段按访问频次排序: |
字段 | 类型 | 对齐偏移 | 用途 |
|---|---|---|---|---|
bid_depth |
u16 |
0 | 买盘深度(只读热点) | |
asks |
[PriceLevel; 512] |
64 | 卖盘数组(缓存行起始) |
并发安全构建
pub struct LocalOrderBook {
pub bid_depth: AtomicU16,
// ... 其他字段
}
// 无锁更新示例:CAS 更新深度
let old = self.bid_depth.load(Ordering::Relaxed);
let new = (old as usize + 1).min(MAX_DEPTH) as u16;
self.bid_depth.compare_exchange(old, new, Ordering::Acquire, Ordering::Relaxed).is_ok();
逻辑分析:AtomicU16 避免全量锁;Relaxed 读配合 Acquire CAS 保证后续价格层访问的可见性;compare_exchange 原子性防止竞态覆盖。
数据同步机制
- 生产者线程通过 ring buffer 批量推送增量更新
- 消费者线程使用 seqlock 机制校验快照一致性
graph TD
A[增量更新流] --> B{Ring Buffer}
B --> C[消费者线程]
C --> D[SeqLock 读取校验]
D --> E[原子快照发布]
3.3 消息乱序/重复/丢失场景下的端到端幂等性设计:基于单调递增序列号+滑动窗口校验
核心机制
服务端为每条业务消息分配全局单调递增的 seq_id,客户端按发送顺序严格递增生成;服务端维护固定大小(如64)的滑动窗口,仅接受 seq_id ∈ [window_min, window_min + window_size) 且未处理过的消息。
滑动窗口校验逻辑
class IdempotentWindow:
def __init__(self, size=64):
self.size = size
self.received = set() # 已接收的seq_id(窗口内)
self.window_min = 0 # 当前窗口左边界(含)
def accept(self, seq_id: int) -> bool:
if seq_id < self.window_min:
return False # 已过期,拒绝
if seq_id >= self.window_min + self.size:
# 窗口右移:剔除旧ID,更新边界
self._slide_window(seq_id)
if seq_id in self.received:
return False # 重复
self.received.add(seq_id)
return True
def _slide_window(self, new_max):
# 仅保留新窗口范围内的已知ID
valid_range = range(new_max - self.size + 1, new_max + 1)
self.received = {x for x in self.received if x in valid_range}
self.window_min = new_max - self.size + 1
逻辑分析:
accept()先做越界判断,再动态滑动窗口——当新seq_id超出当前右边界时,将window_min推进至new_max - size + 1,并重建received集合。参数size=64平衡内存与容错能力,支持最多63条消息乱序到达。
状态迁移示意
graph TD
A[新消息 seq_id] --> B{seq_id < window_min?}
B -->|是| C[丢弃:已过期]
B -->|否| D{seq_id ≥ window_min + size?}
D -->|是| E[滑动窗口]
D -->|否| F[查重]
E --> F
F --> G{已在received中?}
G -->|是| H[丢弃:重复]
G -->|否| I[记录并处理]
关键保障维度
- ✅ 乱序容忍:窗口内任意顺序均可接受
- ✅ 重复拦截:
received集合实现O(1)查重 - ✅ 丢失无影响:
seq_id单调性确保空洞可跳过 - ⚠️ 前提:生产者必须保证
seq_id全局递增且不回退
第四章:交易执行闭环:风控、撮合模拟与实盘指令原子性控制
4.1 实时风控引擎嵌入:基于Gin中间件的预下单校验链与动态规则热更新
核心设计思想
将风控决策前移至 HTTP 请求生命周期早期,避免无效订单进入业务核心流程。Gin 中间件天然契合「请求拦截 → 规则匹配 → 响应拦截」链式模型。
预下单校验中间件示例
func RiskControlMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
order := new(OrderRequest)
if err := c.ShouldBindJSON(order); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, "invalid payload")
return
}
// 动态加载当前生效规则集(来自 etcd/Redis)
rules := ruleManager.GetActiveRules("pre_order")
if blocked, reason := engine.Evaluate(rules, order); blocked {
c.AbortWithStatusJSON(http.StatusForbidden, map[string]string{"reason": reason})
return
}
c.Next()
}
}
逻辑分析:ruleManager.GetActiveRules() 从分布式配置中心拉取带版本号的规则快照;engine.Evaluate() 支持表达式(如 amount > 5000 && ip in $highRiskIPs)实时解析,无需重启服务。
动态热更新机制
| 组件 | 更新触发方式 | 一致性保障 |
|---|---|---|
| 规则元数据 | Watch etcd key 变更 | 基于 Revision 的乐观锁 |
| 表达式编译缓存 | LRU 缓存 + TTL 失效 | 编译结果按 ruleID 隔离 |
数据同步机制
graph TD
A[规则管理后台] -->|HTTP PUT /v1/rules| B(etcd)
B --> C{Watch Event}
C --> D[RuleManager Reload]
D --> E[编译新规则入内存缓存]
E --> F[Gin 中间件无感切换]
4.2 本地撮合模拟器开发:支持限价单/市价单/FOK/IOC的Go泛型实现
为统一订单类型处理逻辑,采用 Order[T OrderType] 泛型接口抽象订单行为,其中 T 约束为 Limit | Market | FOK | IOC 枚举类型。
核心订单结构
type Order[T OrderType] struct {
ID string
Price float64 // 限价单有效,市价单为0
Quantity float64
Type T
TimeIn time.Time
}
Price 字段在 Market 类型下单时被忽略;TimeIn 支持 IOC/FOK 的时效性判断。
撮合策略映射表
| 订单类型 | 是否需价格匹配 | 是否立即部分成交 | 超时未成交行为 |
|---|---|---|---|
| Limit | 是 | 否 | 进入订单簿 |
| Market | 否 | 是 | 全部立即撮合 |
| FOK | 是 | 否 | 全部失败 |
| IOC | 是 | 是 | 剩余自动撤销 |
撮合流程简图
graph TD
A[接收订单] --> B{Type == Market?}
B -->|是| C[跳过价格匹配,直取最优对手盘]
B -->|否| D[按Price匹配对手盘]
C & D --> E[执行FOK/IOC规则校验]
E --> F[更新持仓/成交记录]
4.3 下单指令的事务边界界定:从http.Client超时控制到交易所响应ACK确认的双阶段提交模拟
在高频交易系统中,下单操作不能简单依赖 HTTP 请求成功即视为事务完成。需构建类两阶段提交(2PC)语义:Prepare 阶段由客户端主动施加 http.Client 超时约束;Commit 阶段则等待交易所返回带业务语义的 ACK 确认帧(非仅 HTTP 200)。
客户端超时与上下文传播
ctx, cancel := context.WithTimeout(context.Background(), 800*time.Millisecond)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "POST", "/order", bytes.NewReader(payload))
// ⚠️ 注意:800ms 是 Prepare 阶段最大容忍延迟,需严控于交易所 SLA(通常 ≤500ms)
该超时非网络层重试阈值,而是业务级“预备提交”窗口——超时即触发本地回滚(如释放预占资金锁),不进入 Commit 阶段。
ACK 确认协议设计
| 字段 | 类型 | 说明 |
|---|---|---|
order_id |
string | 交易所分配的唯一订单号 |
status |
string | "ack" 表示已持久化入账 |
timestamp |
int64 | 交易所服务端落库时间戳(毫秒) |
双阶段状态流转
graph TD
A[客户端发起下单] --> B{Prepare: HTTP请求+800ms ctx}
B -- timeout --> C[本地回滚/告警]
B -- 200+body --> D[解析响应体]
D -- status==\"ack\" --> E[Commit完成]
D -- else --> F[视为Prepare失败,触发补偿]
关键逻辑在于:HTTP 成功 ≠ 业务成功;ACK 才是分布式事务的真正提交点。
4.4 并发下单冲突处理:CAS+乐观锁在OrderID生成与委托状态跃迁中的实战应用
在高频交易场景中,同一用户秒级多次下单易引发 OrderID 重复或委托状态错乱(如 PENDING → FILLED 跳过 PARTIAL)。我们采用 CAS + 乐观锁双机制协同防御。
核心设计原则
- OrderID 生成:基于原子递增 + 时间戳前缀,规避全局序列瓶颈
- 状态跃迁:所有
UPDATE order SET status = ? WHERE id = ? AND status = ?均携带旧状态校验
CAS 生成唯一 OrderID(Java 示例)
private final AtomicLong sequence = new AtomicLong(0);
public String generateOrderId(long userId) {
long ts = System.currentTimeMillis() << 20; // 保留20位给序列
long seq = sequence.incrementAndGet() & 0xFFFFF; // 20位序列,避免溢出
return String.format("%d%05d", ts, seq); // 示例:17170234560000012345
}
sequence.incrementAndGet()提供无锁自增;& 0xFFFFF实现序列循环截断,确保不超 20 位;左移 20 位预留空间,保障毫秒级时间精度与序列正交性。
状态跃迁校验表
| 当前状态 | 允许跃迁至 | 数据库 WHERE 条件 |
|---|---|---|
| PENDING | PARTIAL, CANCELED | status = 'PENDING' |
| PARTIAL | FILLED, CANCELED | status = 'PARTIAL' |
状态更新流程(mermaid)
graph TD
A[收到下单请求] --> B{CAS生成OrderID}
B --> C[插入订单记录<br>status=PENDING]
C --> D[执行委托逻辑]
D --> E{状态需变更?}
E -->|是| F[UPDATE ... WHERE id=? AND status=旧值]
E -->|否| G[返回成功]
F --> H{影响行数 == 1?}
H -->|是| I[状态更新成功]
H -->|否| J[重试或拒绝]
第五章:血泪教训总结与Go期货系统演进路线图
熔断失效导致全链路雪崩的真实现场
2023年Q3某次夜盘期间,行情网关因上游CTP接口偶发超时未触发熔断,下游风控模块持续重试并堆积17万+未处理委托,最终OOM崩溃。根因是自研熔断器仅基于错误率统计,未纳入RT百分位(P99 > 800ms)和并发请求数双维度判定。修复后上线的熔断策略已覆盖以下指标:
| 维度 | 阈值 | 触发动作 |
|---|---|---|
| 错误率 | ≥35% (1min) | 拒绝新请求,降级为缓存 |
| P99延迟 | >600ms | 自动切换备用行情源 |
| 并发连接数 | >1200 | 启动连接限流(令牌桶) |
内存泄漏引发的跨日持仓错乱
某主力合约交割日前夕,结算服务连续运行72小时后出现持仓数据漂移。pprof分析发现sync.Pool误用:将含time.Time字段的结构体放入池中复用,而time.Time底层指针在GC周期中被回收,导致后续UnmarshalJSON解析出随机时间戳。修正方案强制使用&struct{}而非sync.Pool.Get().(*T),内存占用下降62%。
// ❌ 危险用法:Pool中对象携带不可控生命周期字段
type Position struct {
OpenTime time.Time // GC可能提前回收其底层数据
Qty int
}
// ✅ 安全重构:拆分可复用字段与瞬态字段
type PositionCore struct {
Qty int
}
低延迟场景下的GC停顿优化实践
为将订单撮合延迟P99压至≤120μs,我们禁用标准runtime.GC(),改用增量式内存管理:
- 所有订单结构体预分配至固定大小
[1024]Order数组池 - 使用
unsafe.Slice替代make([]Order, n)避免逃逸分析开销 - 关键路径禁用
fmt.Sprintf,改用strconv.AppendInt拼接字符串
多交易所协议适配的抽象陷阱
初期设计统一ExchangeAdapter接口时,强行抽象所有交易所的CancelOrder行为,导致OKX的cancel_batch_orders与DCE的单笔撤单无法共用同一错误码映射逻辑。最终采用策略模式解耦:每个交易所实现独立的ErrorTranslator,通过map[string]func(error) error动态注册转换规则。
演进路线图(2024–2025)
flowchart LR
A[2024 Q2:引入eBPF实时监控网络丢包率] --> B[2024 Q4:Rust重写行情解码器]
B --> C[2025 Q1:支持FPGA硬件加速订单匹配]
C --> D[2025 Q3:全链路WASM沙箱化风控规则引擎] 