Posted in

Go构建带分页&筛选的Web表格:前端Table组件 ↔ 后端Query Builder双向协议设计

第一章:Go构建带分页&筛选的Web表格:前端Table组件 ↔ 后端Query Builder双向协议设计

现代Web表格交互的核心挑战在于前后端对“数据请求意图”的无歧义表达。前端需声明分页参数(当前页、每页条数)、字段级筛选条件(如 status=active&created_after=2024-01-01)及排序规则;后端则需将这些语义安全地映射为SQL查询,同时避免SQL注入与字段越权访问。

协议约定:标准化查询参数结构

采用扁平化键值对设计,拒绝嵌套JSON作为查询参数(降低前端序列化/后端解析复杂度):

  • page=2、limit=20 → 分页控制
  • filter_name=John&filter_email__contains=gmail → 字段筛选(支持操作符后缀:__eq、__contains、__gt)
  • sort=+created_at,-id → +升序、-降序

前端Table组件职责

  • 自动收集用户操作(翻页、输入搜索、选择筛选项)并拼装标准URL参数
  • 使用URLSearchParams确保编码安全:
    const params = new URLSearchParams();
    params.set('page', 3);
    params.set('filter_status__eq', 'published');
    params.set('sort', '+updated_at');
    fetch(`/api/posts?${params}`)

后端Query Builder实现要点

Go服务使用结构体绑定参数,并通过白名单校验字段合法性:

type TableQuery struct {
    Page  int    `form:"page" default:"1"`
    Limit int    `form:"limit" default:"10"`
    Sort  string `form:"sort"`
    Filters map[string]string `form:"-"` // 手动解析filter_*键
}

// 白名单防止非法字段注入
var allowedFields = map[string]bool{"name": true, "status": true, "created_at": true}
func (q *TableQuery) BuildQuery() (*gorm.DB, error) {
    db := db.Model(&Post{})
    for k, v := range q.Filters {
        if !strings.HasPrefix(k, "filter_") { continue }
        field := strings.TrimPrefix(k, "filter_")
        if !allowedFields[field] { continue } // 严格字段白名单
        op := "__eq"
        if idx := strings.LastIndex(field, "__"); idx > 0 {
            op = field[idx:] // 提取操作符如 __contains
            field = field[:idx]
        }
        db = applyFilter(db, field, op, v)
    }
    return db.Order(q.Sort), nil
}

关键保障机制

  • 前端禁止提交未在Schema中声明的字段名
  • 后端对所有filter_*键执行正则校验:^filter_[a-zA-Z0-9_]+(__[a-z]+)?$
  • 分页参数强制范围限制(page ≥ 1, limit ∈ [10, 100])

该协议使前端可复用Table组件对接任意后端资源,后端亦能统一处理多业务线的列表请求,消除定制化API膨胀。

第二章:双向协议的核心设计原则与Go实现基础

2.1 分页语义建模:offset/limit vs cursor-based 的Go结构体抽象

分页不是接口契约,而是数据访问语义的显式建模。

两种范式的本质差异

  • offset/limit:位置寻址,依赖全局有序假设,易受写入干扰导致漏/重;
  • cursor-based:状态快照,基于上一页末项唯一键(如 id > 100),天然支持强一致性滚动。

Go 结构体抽象对比

// offset/limit 风格 —— 简单但脆弱
type OffsetPage struct {
    Offset int `json:"offset"`
    Limit  int `json:"limit"`
}

// cursor-based 风格 —— 可扩展、无状态
type CursorPage struct {
    Cursor string `json:"cursor"` // base64-encoded last-id + sort-key
    Limit  int    `json:"limit"`
}

Cursor 字段需编码复合排序键(如 "MTAwfDE3MjQyNzg5MDAwMDAwfGFzc2VudA==" 表示 id=100&ts=1724278900000&dir=asc),解码后用于生成 WHERE id > ? AND ts >= ? 条件。

性能与语义对照表

维度 offset/limit cursor-based
数据一致性 弱(写入干扰) 强(游标锚定)
查询复杂度 O(1) 索引跳转 O(1) 范围扫描
缓存友好性 差(偏移量不可缓存) 优(游标可签名缓存)
graph TD
    A[客户端请求] --> B{分页类型}
    B -->|offset/limit| C[ORDER BY id LIMIT N OFFSET M]
    B -->|cursor-based| D[WHERE id > $last_id ORDER BY id LIMIT N]
    C --> E[可能跳过新插入行]
    D --> F[严格保序、无漏重]

2.2 筛选条件DSL设计:从URL Query到Go Filter AST的解析与校验

设计目标

统一表达 URL 中的 ?status=active&score__gte=80&tags__in=go,cloud 等查询参数,并安全映射为类型安全的 Go 结构体。

解析流程

// 将 query string 转为 AST 节点
type BinaryOp struct {
    Left     *FieldExpr `json:"left"`
    Operator string     `json:"op"` // "eq", "gte", "in"
    Right    interface{} `json:"right"`
}

该结构支持嵌套组合(如 and, or),Operator 字段经白名单校验(map[string]bool{"eq":true,"gte":true,"in":true}),防止注入式操作符。

校验规则

  • 字段名必须存在于预定义 Schema(如 status, score, tags)
  • __in 操作符右侧必须为字符串切片,且长度 ≤ 10
  • 数值型字段(如 score)自动做 strconv.Atoi 并捕获错误
操作符 支持字段类型 示例值
eq string/int ?name=alice
__gte int/float ?score__gte=90
__in string ?tags__in=a,b
graph TD
A[URL Query] --> B[Tokenize & Parse]
B --> C{Validate Field & Op}
C -->|OK| D[Build Filter AST]
C -->|Fail| E[Return 400]
D --> F[Compile to SQL/Go Predicate]

2.3 排序字段安全映射:白名单机制与反射驱动的OrderByBuilder实现

安全痛点与设计原则

直接拼接用户传入的 sortField 易引发 SQL 注入或敏感字段泄露(如 password, email_verified_at)。白名单机制强制约束可排序字段范围,结合反射实现类型安全的动态构建。

白名单声明与校验逻辑

public class UserSortWhitelist {
    public static final Set<String> ALLOWED_FIELDS = Set.of("id", "username", "created_at", "status");
}
  • ALLOWED_FIELDS 为编译期确定的不可变集合;
  • 运行时校验通过 ALLOWED_FIELDS.contains(input) 实现 O(1) 时间复杂度判断。

OrderByBuilder 核心实现

public class OrderByBuilder {
    public static String build(String field, String direction) {
        if (!UserSortWhitelist.ALLOWED_FIELDS.contains(field)) {
            throw new IllegalArgumentException("Invalid sort field: " + field);
        }
        if (!"ASC".equalsIgnoreCase(direction) && !"DESC".equalsIgnoreCase(direction)) {
            throw new IllegalArgumentException("Invalid sort direction");
        }
        return String.format("%s %s", escapeIdentifier(field), direction.toUpperCase());
    }

    private static String escapeIdentifier(String field) {
        return "`" + field.replace("`", "``") + "`"; // MySQL 标识符转义
    }
}
  • escapeIdentifier() 防止标识符注入,双重反引号转义规避恶意字段名;
  • build() 方法返回已校验、已转义的 SQL 片段,供 DAO 层安全拼接。

支持字段映射对照表

业务参数 实际数据库字段 类型安全
userName username ✅(反射校验存在)
createdAt created_at ✅(驼峰→下划线自动转换)
userStatus status ✅

构建流程(mermaid)

graph TD
    A[用户输入 sort=userStatus,dir=DESC] --> B{字段白名单校验}
    B -->|通过| C[反射解析字段映射]
    B -->|拒绝| D[抛出 IllegalArgumentException]
    C --> E[标识符转义]
    E --> F[生成 ORDER BY `status` DESC]

2.4 协议版本兼容性:基于Content-Type协商与Schema Evolution的Go版本路由

在微服务演进中,API版本需平滑过渡。Go服务通过Content-Type头(如application/vnd.api+json; version=1.2)提取语义化版本,并结合Schema Evolution策略实现运行时路由。

版本解析与路由分发

func parseVersion(r *http.Request) (semver.Version, error) {
    ct := r.Header.Get("Content-Type")
    vStr := strings.TrimSpace(strings.Split(ct, ";")[1]) // " version=1.2"
    return semver.Parse(strings.TrimPrefix(vStr, " version="))
}

该函数从Content-Type中安全提取语义化版本,避免正则开销;semver.Version结构支持比较运算,支撑路由决策。

Schema Evolution支持矩阵

演进类型 向前兼容 向后兼容 Go实现方式
字段添加 ✅ ✅ json:",omitempty"
字段重命名 ✅ ❌ json:"old_name,omitempty" + 自定义UnmarshalJSON
类型变更 ⚠️ ⚠️ 接口+类型断言

路由决策流程

graph TD
    A[HTTP Request] --> B{Parse Content-Type}
    B --> C[Extract Version]
    C --> D{Version ≥ 1.2?}
    D -->|Yes| E[Use V2Handler]
    D -->|No| F[Use V1Handler]

2.5 错误语义统一:HTTP状态码、错误码与前端Table组件可消费的Error Payload设计

为什么需要三层错误语义对齐

  • HTTP 状态码(如 400/401/500)表达协议层意图,但粒度粗;
  • 业务错误码(如 "USER_NOT_FOUND")承载领域语义,需跨服务一致;
  • Table 组件需结构化错误载荷(errorPayload),支持自动渲染提示、重试、列级错误标记。

标准化 Error Payload 结构

{
  "code": "VALIDATION_FAILED",
  "message": "邮箱格式不合法",
  "details": {
    "field": "email",
    "rowId": "row_abc123"
  },
  "severity": "warning"
}

逻辑分析:code 为全局唯一枚举键,供 i18n 映射;details.field 支持 Table 定位列,rowId 实现行级错误绑定;severity 控制 UI 反馈样式(error/warning/info)。

错误映射关系表

HTTP Status Business Code Table Behavior
400 VALIDATION_FAILED 高亮字段 + 行内气泡
404 RESOURCE_NOT_FOUND 显示空态 + “刷新重试”按钮
500 SERVICE_UNAVAILABLE 全表禁用 + 全局 toast

前端 Table 消费流程

graph TD
  A[HTTP Response] --> B{Status ≥ 400?}
  B -->|Yes| C[解析 errorPayload]
  B -->|No| D[渲染正常数据]
  C --> E[按 details.field 绑定列错误]
  C --> F[按 rowId 注入行级 error state]

第三章:后端Query Builder的工程化构建

3.1 基于sqlc+pgx的类型安全查询生成与分页包装器封装

为什么选择 sqlc + pgx 组合

  • sqlc 将 SQL 语句编译为强类型 Go 代码,消除手写 ORM 的运行时类型错误
  • pgx 提供原生 PostgreSQL 协议支持,性能优于 database/sql + pq

分页包装器核心设计

type PageResult[T any] struct {
    Items      []T     `json:"items"`
    TotalCount int64   `json:"total_count"`
    Page       int     `json:"page"`
    PageSize   int     `json:"page_size"`
}

func Paginate[T any](ctx context.Context, q *Queries, page, pageSize int) (PageResult[T], error) {
    // 使用 WITH RECURSIVE 或 COUNT(*) OVER() 实现高效总数+偏移
}

此封装复用 sqlc 生成的 *Queries,通过泛型统一返回结构;page 和 pageSize 经校验后转换为 LIMIT/OFFSET,避免 SQL 注入。

查询生成流程(mermaid)

graph TD
    A[SQL 文件] --> B[sqlc generate]
    B --> C[类型安全 Go 接口]
    C --> D[pgx.Conn 执行]
    D --> E[编译期类型检查]
特性 sqlc pgx 组合优势
类型推导 ✅ ❌ 编译时捕获字段错
连接池管理 ❌ ✅ 高并发连接复用
分页扩展能力 ⚠️(需手动) ✅(自定义) 可注入 COUNT 逻辑

3.2 动态WHERE构建器:支持嵌套AND/OR、NULL处理与时间范围的Go DSL实现

核心设计理念

以链式调用表达逻辑结构,避免字符串拼接,兼顾类型安全与可读性。

关键能力一览

  • ✅ 嵌套布尔组合:AND(OR(A, B), C)
  • ✅ NULL感知过滤:IsNull("deleted_at") / NotNull("updated_at")
  • ✅ 时间范围语法糖:BetweenTime("created_at", start, end)

示例代码:构建复合查询条件

cond := Where().
    And(
        Eq("status", "active"),
        Or(
            IsNull("archived_at"),
            BetweenTime("updated_at", time.Now().Add(-7*24*time.Hour), time.Now()),
        ),
    )

逻辑分析:Where() 初始化空条件;And() 和 Or() 返回嵌套 Condition 实例,内部通过 []Expr 维护子表达式;BetweenTime 自动转换为 BETWEEN ? AND ? 并绑定 time.Time 参数,规避时区/精度风险。

方法 生成SQL片段 参数约束
Eq("id", 123) id = ? 非nil任意类型
IsNull("col") col IS NULL 字段名字符串
BetweenTime(...) col BETWEEN ? AND ? 两个 time.Time
graph TD
    A[Where()] --> B[And/Or]
    B --> C[原子条件 Eq/IsNull/BetweenTime]
    C --> D[参数绑定与SQL渲染]

3.3 多租户上下文注入:Request-scoped TenantID与Query Builder的透明集成

核心设计目标

将租户标识(TenantID)从 HTTP 请求生命周期无缝注入到数据访问层,避免业务代码显式传递,同时对现有 Query Builder 零侵入。

实现机制

利用请求作用域(Request-scoped)绑定 TenantID,并通过拦截器/装饰器注入至查询构造链:

// 在 ORM 查询构建器中自动追加 tenant_id 过滤
const query = db.select().from('orders')
  .where('status', '=', 'pending'); // 原始业务逻辑

// 拦截后透明增强为:
// WHERE status = 'pending' AND tenant_id = 't_123'

逻辑分析:tenant_id 由 RequestContext.get('tenantId') 动态获取;所有 .select()、.update()、.delete() 方法均被代理,自动追加 AND tenant_id = ? 条件。参数 tenantId 类型为 string,不可为空,缺失时抛出 TenantContextMissingError。

租户隔离策略对比

策略 性能开销 实现复杂度 SQL 可读性
Schema-level 低 高 高(天然隔离)
Column-level(本节方案) 中 中 中(自动拼接)
Connection-level 高 低 低(需多连接池)

数据流示意

graph TD
  A[HTTP Request] --> B[Middleware 解析 TenantID]
  B --> C[RequestScope 绑定 tenantId]
  C --> D[QueryBuilder 拦截器]
  D --> E[自动注入 WHERE tenant_id = ?]
  E --> F[执行安全查询]

第四章:前端Table组件与Go后端的协同契约落地

4.1 Vue/React Table组件Props Schema与Go API响应结构的双向TypeScript-Go类型对齐

数据同步机制

前端 Table 组件的 columns 与后端分页响应需语义一致。关键字段如 id, createdAt, status 必须在两端类型定义中严格对齐。

类型映射实践

// frontend/types.ts
export interface UserRow {
  id: number;           // ← 对应 Go 的 int64(JSON序列化为number)
  name: string;         // ← 对应 Go 的 string
  created_at: string;   // ← Go time.Time → RFC3339 string(非 Date 对象!)
}

逻辑分析:created_at 命名采用蛇形(Go习惯),但 TypeScript 中不转换为驼峰——因 API 响应体原始字段名即 created_at,Table 组件直接消费,避免运行时重映射开销。

双向校验保障

TypeScript 字段 Go struct 字段 JSON Tag 序列化行为
id ID json:"id" ✅ 直接透传
created_at CreatedAt json:"created_at" ✅ RFC3339字符串
// backend/model/user.go
type UserRow struct {
    ID        int64     `json:"id"`
    Name      string    `json:"name"`
    CreatedAt time.Time `json:"created_at"`
}

参数说明:CreatedAt 使用 time.Time 类型,由 encoding/json 自动格式化为 ISO8601 字符串;前端无需 Date.parse(),可直传给 <el-table> 或 useTable hook。

4.2 分页控件事件流→Go Query参数的自动序列化/反序列化管道设计

核心设计目标

将前端分页控件(如页码、每页条数、排序字段)的变更,无缝映射为 URL Query 参数,并在 Go HTTP handler 中自动还原为结构化分页对象。

序列化管道示例

type Pagination struct {
    Page     int `query:"page" validate:"min=1"`
    PageSize int `query:"size" validate:"min=1,max=100"`
    SortBy   string `query:"sort" validate:"omitempty,oneof=id name created_at"`
}

// 自动从 http.Request.URL.Query() 绑定
func ParsePagination(r *http.Request) (*Pagination, error) {
    q := r.URL.Query()
    p := &Pagination{}
    if err := query.Unmarshal(q, p); err != nil {
        return nil, fmt.Errorf("parse pagination: %w", err)
    }
    return p, nil
}

query.Unmarshal 利用结构体 tag 提取 page, size, sort 参数并完成类型转换与基础校验;validate 标签由 validator 库触发运行时约束检查。

反序列化流程

graph TD
    A[前端分页事件] --> B[生成 URL Query]
    B --> C[HTTP GET 请求]
    C --> D[query.Unmarshal]
    D --> E[结构体实例]
    E --> F[业务逻辑调用]

支持的 Query 映射规则

Query Key 结构体字段 类型转换 示例值
page Page string → int page=3
size PageSize string → int size=20
sort SortBy string → string sort=-created_at

4.3 筛选表单联动→Go Filter AST的实时编译与防注入验证策略

当用户在前端调整多级筛选条件(如「部门=研发部 ∧ 状态∈[进行中,已完成] ∧ 创建时间≥2024-01-01」),后端需将动态表达式安全、高效地转化为可执行的查询逻辑。

核心流程:从字符串到受信AST

// 输入原始过滤字符串(经HTTP Query传入)
raw := "dept == '研发部' && status in ['进行中','已完成'] && created_at >= '2024-01-01'"

// 使用 go-filter 库解析为AST节点,非直接eval
ast, err := parser.Parse(raw)
if err != nil {
    return errors.New("语法非法:不支持赋值、函数调用或嵌套括号逃逸")
}

▶ 解析器禁用 eval 和反射调用,仅允许预注册的二元/一元操作符与白名单字段(dept, status, created_at);所有字符串字面量自动转义单引号,防止闭合绕过。

防注入关键校验项

  • ✅ 字段名必须命中预定义 Schema(如 map[string]Type{"dept": StringType, "status": EnumType{...}})
  • ✅ 时间字面量强制通过 time.ParseInLocation("2006-01-02", ...) 校验
  • ❌ 拒绝任何含 ;、$、反引号、未闭合引号的输入

编译性能对比(百万次解析)

方式 平均耗时 内存分配 安全等级
go-filter AST 82 ns 48 B ★★★★★
govaluate Eval 1.2 μs 320 B ★★☆☆☆
graph TD
    A[HTTP Query参数] --> B[正则初筛<br>剔除危险字符]
    B --> C[Parser.Parse → AST]
    C --> D{字段/类型校验}
    D -- 通过 --> E[Compile → FilterFunc]
    D -- 拒绝 --> F[400 Bad Request]

4.4 加载状态、空态、错误态在Go HTTP Handler层的标准化响应模板设计

统一响应结构是API健壮性的基石。Go中常通过封装 http.Handler 实现中间件式状态注入。

响应模板结构定义

type APIResponse struct {
    Code    int         `json:"code"`    // 业务码:0=成功,1001=空数据,1002=加载中,500x=错误
    Message string      `json:"message"` // 状态语义化描述
    Data    interface{} `json:"data,omitempty"
}

Code 遵循内部约定: 表示成功;1001 显式标识空列表/空对象;1002 用于服务端主动返回“加载中”提示(如长轮询前置响应);5xx 保留给真实错误。

标准化构造函数

func NewLoadingResp() APIResponse {
    return APIResponse{Code: 1002, Message: "loading"}
}

func NewEmptyResp() APIResponse {
    return APIResponse{Code: 1001, Message: "no content"}
}

避免硬编码,便于全局文案与码值治理。

状态码映射表

HTTP 状态 Code 值 适用场景
200 0 正常成功
200 1002 前端需展示加载动画
200 1001 查询结果为空但非异常
graph TD
A[HTTP Handler] --> B{业务逻辑执行}
B -->|无数据| C[NewEmptyResp]
B -->|异步未就绪| D[NewLoadingResp]
B -->|出错| E[NewErrorResp]
C & D & E --> F[WriteJSON]

第五章:性能压测、可观测性与生产就绪建议

基于真实电商大促场景的压测实践

某头部电商平台在双11前采用阿里云PTS(性能测试服务)对订单履约服务进行全链路压测。设定目标为峰值QPS 8,500,模拟20万并发用户,压测脚本复用线上真实TraceID与JWT Token签名逻辑,并注入地域标签(如region=shanghai)以验证多可用区流量调度能力。压测中发现库存扣减接口P99延迟从120ms飙升至2.3s,根因定位为Redis集群单节点内存打满触发逐出策略——通过redis-cli --latency -p 6380确认高延迟时段与INFO memory中mem_fragmentation_ratio > 2.1强相关。最终通过将热点SKU库存分片数从16提升至128,并启用maxmemory-policy volatile-lfu解决。

可观测性数据闭环设计

构建“指标-日志-链路-事件”四维融合看板,关键字段对齐示例如下:

数据类型 核心字段 关联方式 示例值
Metrics http_server_requests_seconds_count{app="order",status="500"} 通过trace_id关联 trace_id=abc123xyz789
Logs {"trace_id":"abc123xyz789","error":"TimeoutException"} 通过span_id匹配调用栈 span_id=span-456
Traces /api/v1/order/submit span with db.query.time=1420ms 通过service.name聚合 service.name=inventory-svc

生产就绪检查清单

  • ✅ Kubernetes Pod必须配置readinessProbe与livenessProbe分离:前者检测HTTP /health/ready端点(超时1s,失败阈值3次),后者执行curl -f http://localhost:8080/health/live(超时3s,失败阈值2次);
  • ✅ 所有Java应用启动时强制注入JVM参数:-XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:+PrintGCDetails -Xloggc:/var/log/app/gc.log;
  • ✅ Prometheus exporter端点需暴露/actuator/prometheus且指标命名遵循application_{metric}_total规范(如application_http_client_requests_total);
  • ✅ 部署前执行kubectl get pods -n prod -o wide | grep -v Running | wc -l确保0非Running Pod。

火焰图驱动的CPU瓶颈定位

使用async-profiler采集生产环境30秒CPU火焰图:

./profiler.sh -e cpu -d 30 -f /tmp/profile.html pid_of_java_process

分析发现com.example.order.service.OrderProcessor.process()方法中String.replaceAll()被高频调用(占CPU 42%),替换为预编译Pattern.compile("\\s+").matcher(str).replaceAll(" ")后,订单吞吐量提升3.7倍。

混沌工程常态化演练机制

每月执行一次网络延迟注入实验:在支付网关Pod中部署chaos-mesh故障模块,对payment-svc.default.svc.cluster.local:8080注入200ms ±50ms网络抖动,持续15分钟。验证下游风控服务是否触发熔断(Hystrix fallback阈值设为错误率>50%且10秒内失败≥20次),并校验补偿任务队列积压告警是否在30秒内触发企业微信机器人推送。

日志采样策略优化

将/api/v1/order/create请求日志采样率从100%动态降为0.1%,但保留所有status >= 400日志(通过Logback <filter class="ch.qos.logback.core.filter.EvaluatorFilter">实现)。同时对trace_id哈希后取模,确保同一链路日志100%保留——<expression>hashcode(traceId) % 1000 == 0</expression>。

SLO基线定义与告警收敛

定义核心SLO:订单创建成功率 ≥ 99.95%(滚动15分钟窗口),告警规则设置为连续3个周期低于阈值才触发PagerDuty,避免毛刺干扰。配套建立Error Budget Burn Rate看板,当Burn Rate > 2.0(即消耗速率超预算2倍)时自动冻结发布流水线。

容器镜像安全加固

基础镜像统一使用ubi8-minimal:8.8,构建阶段禁用root用户:

FROM registry.access.redhat.com/ubi8-minimal:8.8
USER 1001:1001
COPY --chown=1001:1001 target/app.jar /app.jar
ENTRYPOINT ["java","-jar","/app.jar"]

CI流水线集成Trivy扫描,阻断CVE评分≥7.0的漏洞镜像推送至生产仓库。

生产灰度发布黄金法则

新版本发布必须满足三重验证:① 流量染色(Header x-deployment-id: v2.3.1);② 5%流量灰度运行2小时无P99延迟劣化(对比基线±5%以内);③ 关键业务指标(如支付成功数)环比波动≤±0.3%。未达标则自动回滚至前一稳定版本。

一杯咖啡,一段代码,分享轻松又有料的技术时光。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注