Skip to content
体验新版
项目
组织
正在加载...
登录
切换导航
打开侧边栏
rainie_yuyue
soar
提交
461b6899
S
soar
项目概览
rainie_yuyue
/
soar
与 Fork 源项目一致
Fork自
Xiaomi / soar
通知
1
Star
1
Fork
0
代码
文件
提交
分支
Tags
贡献者
分支图
Diff
Issue
0
列表
看板
标记
里程碑
合并请求
0
Wiki
0
Wiki
分析
仓库
DevOps
项目成员
Pages
S
soar
项目概览
项目概览
详情
发布
仓库
仓库
文件
提交
分支
标签
贡献者
分支图
比较
Issue
0
Issue
0
列表
看板
标记
里程碑
合并请求
0
合并请求
0
Pages
分析
分析
仓库分析
DevOps
Wiki
0
Wiki
成员
成员
收起侧边栏
关闭侧边栏
动态
分支图
创建新Issue
提交
Issue看板
体验新版 GitCode,发现更多精彩内容 >>
提交
461b6899
编写于
11月 05, 2018
作者:
martianzhang
浏览文件
操作
浏览文件
下载
电子邮件补丁
差异文件
extract config check into initConfig
上级
d622a265
变更
6
显示空白变更内容
内联
并排
Showing
6 changed file
with
73 addition
and
52 deletion
+73
-52
advisor/explainer.go
advisor/explainer.go
+1
-1
advisor/heuristic.go
advisor/heuristic.go
+4
-4
advisor/index.go
advisor/index.go
+25
-25
advisor/rules.go
advisor/rules.go
+1
-1
cmd/soar/soar.go
cmd/soar/soar.go
+29
-8
common/config.go
common/config.go
+13
-13
未找到文件。
advisor/explainer.go
浏览文件 @
461b6899
...
...
@@ -58,7 +58,7 @@ func checkExplainSelectType(exp *database.ExplainInfo) {
if
exp
.
ExplainFormat
==
database
.
JSONFormatExplain
{
// TODO
// JSON
形式遍历分析不方便,转成Row格式也没有SelectType
暂不处理
// JSON
形式遍历分析不方便,转成 Row 格式也没有 SelectType
暂不处理
return
}
for
_
,
v
:=
range
common
.
Config
.
ExplainWarnSelectType
{
...
...
advisor/heuristic.go
浏览文件 @
461b6899
...
...
@@ -1446,7 +1446,7 @@ func (q *Query4Audit) RuleSubqueryDepth() Rule {
}
// RuleSubQueryLimit SUB.005
// 只有
IN的SUBQUERY限制了LIMIT,FROM子句中的SUBQUERY并未限制
LIMIT
// 只有
IN 的 SUBQUERY 限制了 LIMIT, FROM 子句中的 SUBQUERY 并未限制
LIMIT
func
(
q
*
Query4Audit
)
RuleSubQueryLimit
()
Rule
{
var
rule
=
q
.
RuleOK
()
err
:=
sqlparser
.
Walk
(
func
(
node
sqlparser
.
SQLNode
)
(
kontinue
bool
,
err
error
)
{
...
...
@@ -1890,7 +1890,7 @@ func (idxAdv *IndexAdvisor) RuleUpdatePrimaryKey() Rule {
err
:=
sqlparser
.
Walk
(
func
(
node
sqlparser
.
SQLNode
)
(
kontinue
bool
,
err
error
)
{
switch
node
.
(
type
)
{
case
*
sqlparser
.
UpdateExpr
:
// 获取
set操作的全部
column
// 获取
set 操作的全部
column
setColumns
=
append
(
setColumns
,
ast
.
FindAllCols
(
node
)
...
)
}
return
true
,
nil
...
...
@@ -2820,7 +2820,7 @@ func (q *Query4Audit) RuleIntPrecision() Rule {
switch
col
.
Tp
.
Tp
{
case
mysql
.
TypeLong
:
if
(
col
.
Tp
.
Flen
<
10
||
col
.
Tp
.
Flen
>
11
)
&&
col
.
Tp
.
Flen
>
0
{
// 有些语言
ORM框架会生成int(11),有些语言的框架生成
int(10)
// 有些语言
ORM 框架会生成 int(11),有些语言的框架生成
int(10)
rule
=
HeuristicRules
[
"COL.016"
]
break
}
...
...
@@ -2840,7 +2840,7 @@ func (q *Query4Audit) RuleIntPrecision() Rule {
switch
col
.
Tp
.
Tp
{
case
mysql
.
TypeLong
:
if
(
col
.
Tp
.
Flen
<
10
||
col
.
Tp
.
Flen
>
11
)
&&
col
.
Tp
.
Flen
>
0
{
// 有些语言
ORM框架会生成int(11),有些语言的框架生成
int(10)
// 有些语言
ORM 框架会生成 int(11),有些语言的框架生成
int(10)
rule
=
HeuristicRules
[
"COL.016"
]
break
}
...
...
advisor/index.go
浏览文件 @
461b6899
...
...
@@ -48,7 +48,7 @@ type IndexInfo struct {
Name
string
`json:"name"`
// 索引名称
Database
string
`json:"database"`
// 数据库名
Table
string
`json:"table"`
// 表名
DDL
string
`json:"ddl"`
// ALTER, CREATE
等类型的DDL
语句
DDL
string
`json:"ddl"`
// ALTER, CREATE
等类型的 DDL
语句
ColumnDetails
[]
*
common
.
Column
`json:"column_details"`
// 列详情
}
...
...
@@ -96,7 +96,7 @@ func NewAdvisor(env *env.VirtualEnv, rEnv database.Connector, q Query4Audit) (*I
dbRef
=
rEnv
.
Database
}
// DDL
在Env
初始化的时候已经执行过了
// DDL
在 Env
初始化的时候已经执行过了
if
_
,
ok
:=
env
.
TableMap
[
dbRef
];
!
ok
{
env
.
TableMap
[
dbRef
]
=
make
(
map
[
string
]
string
)
}
...
...
@@ -279,15 +279,15 @@ func (idxAdv *IndexAdvisor) IndexAdvise() IndexAdvises {
if
len
(
idxAdv
.
whereINEQ
)
>
0
{
mergeIndex
(
indexList
,
idxAdv
.
whereINEQ
[
0
])
}
// 有WHERE条件,但
WHERE条件未能给出索引建议就不能再加GROUP BY和ORDER BY
建议了
// 有WHERE条件,但
WHERE 条件未能给出索引建议就不能再加 GROUP BY 和 ORDER BY
建议了
if
len
(
ignore
)
==
0
{
// 没有非等值查询条件时可以再为
GroupBy和OrderBy
添加索引
// 没有非等值查询条件时可以再为
GroupBy 和 OrderBy
添加索引
for
_
,
index
:=
range
idxAdv
.
groupBy
{
mergeIndex
(
indexList
,
index
)
}
// OrderBy
// 没有
GroupBy时可以为OrderBy
加索引
// 没有
GroupBy 时可以为 OrderBy
加索引
if
len
(
idxAdv
.
groupBy
)
==
0
{
for
_
,
index
:=
range
idxAdv
.
orderBy
{
mergeIndex
(
indexList
,
index
)
...
...
@@ -295,14 +295,14 @@ func (idxAdv *IndexAdvisor) IndexAdvise() IndexAdvises {
}
}
}
else
{
// 未指定
Where条件的,只需要GroupBy和OrderBy
的索引建议
// 未指定
Where 条件的,只需要 GroupBy 和 OrderBy
的索引建议
for
_
,
index
:=
range
idxAdv
.
groupBy
{
mergeIndex
(
indexList
,
index
)
}
// OrderBy
// 没有GroupBy
时可以为OrderBy
加索引
// 没有
where条件时OrderBy
的索引仅能够在索引覆盖的情况下被使用
// 没有GroupBy
时可以为 OrderBy
加索引
// 没有
where 条件时 OrderBy
的索引仅能够在索引覆盖的情况下被使用
// if len(idxAdv.groupBy) == 0 {
// for _, index := range idxAdv.orderBy {
...
...
@@ -329,8 +329,8 @@ func (idxAdv *IndexAdvisor) IndexAdvise() IndexAdvises {
indexes
=
mergeAdvices
(
indexes
,
idxAdv
.
buildJoinIndex
(
joinTableMeta
)
...
)
if
common
.
Config
.
TestDSN
.
Disable
||
common
.
Config
.
OnlineDSN
.
Disable
{
// 无
env环境下只提供单列索引,无法确定table
时不给予优化建议
// 仅有
table信息时给出的建议不包含DB
信息
// 无
env 环境下只提供单列索引,无法确定 table
时不给予优化建议
// 仅有
table 信息时给出的建议不包含 DB
信息
indexes
=
mergeAdvices
(
indexes
,
idxAdv
.
buildIndexWithNoEnv
(
indexList
)
...
)
}
else
{
// 给出尽可能详细的索引建议
...
...
@@ -339,16 +339,16 @@ func (idxAdv *IndexAdvisor) IndexAdvise() IndexAdvises {
indexes
=
mergeAdvices
(
indexes
,
subQueryAdvises
...
)
// 在开启
env
的情况下,检查数据库版本,字段类型,索引总长度
// 在开启
env
的情况下,检查数据库版本,字段类型,索引总长度
indexes
=
idxAdv
.
idxColsTypeCheck
(
indexes
)
// 在开启
env
的情况下,会对索引进行检查,对全索引进行过滤
// 在前几步都不会对
idx生成DDL
语句,DDL语句在这里生成
// 在开启
env
的情况下,会对索引进行检查,对全索引进行过滤
// 在前几步都不会对
idx 生成 DDL
语句,DDL语句在这里生成
return
idxAdv
.
mergeIndexes
(
indexes
)
}
// idxColsTypeCheck 对超长的字段添加前缀索引,剔除无法添索引字段的列
// TODO
暂不支持fulltext
索引,
// TODO
: 暂不支持 fulltext
索引,
func
(
idxAdv
*
IndexAdvisor
)
idxColsTypeCheck
(
idxList
[]
IndexInfo
)
[]
IndexInfo
{
if
common
.
Config
.
TestDSN
.
Disable
{
return
rmSelfDupIndex
(
idxList
)
...
...
@@ -363,7 +363,7 @@ func (idxAdv *IndexAdvisor) idxColsTypeCheck(idxList []IndexInfo) []IndexInfo {
idxBytesTotal
:=
0
isOverFlow
:=
false
for
_
,
col
:=
range
idx
.
ColumnDetails
{
// 获取字段bytes
// 获取字段
bytes
bytes
:=
col
.
GetDataBytes
(
common
.
Config
.
OnlineDSN
.
Version
)
tmpCol
:=
col
.
Name
overFlow
:=
0
...
...
@@ -474,7 +474,7 @@ func (idxAdv *IndexAdvisor) mergeIndexes(idxList []IndexInfo) []IndexInfo {
var
indexes
[]
IndexInfo
for
_
,
idx
:=
range
idxList
{
// 将
DB替换成vEnv
中的数据库名称
// 将
DB 替换成 vEnv
中的数据库名称
dbInVEnv
:=
idx
.
Database
if
_
,
ok
:=
idxAdv
.
vEnv
.
DBRef
[
idx
.
Database
];
ok
{
dbInVEnv
=
idxAdv
.
vEnv
.
DBRef
[
idx
.
Database
]
...
...
@@ -503,7 +503,7 @@ func (idxAdv *IndexAdvisor) mergeIndexes(idxList []IndexInfo) []IndexInfo {
var
cols
[]
string
var
colsDetail
[]
*
common
.
Column
// 把已经存在的
key
摘出来遍历一遍对比是否是包含关系
// 把已经存在的
key
摘出来遍历一遍对比是否是包含关系
for
_
,
col
:=
range
indexMeta
.
FindIndex
(
database
.
IndexKeyName
,
existedIdx
.
KeyName
)
{
cols
=
append
(
cols
,
col
.
ColumnName
)
colsDetail
=
append
(
colsDetail
,
&
common
.
Column
{
...
...
@@ -532,7 +532,7 @@ func (idxAdv *IndexAdvisor) mergeIndexes(idxList []IndexInfo) []IndexInfo {
}
// 库、表、列名需要用反撇转义
// TODO 关于外键索引去重的优雅解决方案
// TODO
:
关于外键索引去重的优雅解决方案
if
!
isConstraint
{
if
common
.
Config
.
AllowDropIndex
{
alterSQL
:=
fmt
.
Sprintf
(
"alter table `%s`.`%s` drop index `%s`"
,
idx
.
Database
,
idx
.
Table
,
idxName
)
...
...
@@ -736,12 +736,12 @@ func CompleteColumnsInfo(stmt sqlparser.Statement, cols []*common.Column, env *e
return
cols
}
// 从
Ast中拿到
DBStructure,包含所有表的相关信息
// 从
Ast 中拿到
DBStructure,包含所有表的相关信息
dbs
:=
ast
.
GetMeta
(
stmt
,
nil
)
// 此处生成的
meta信息中不应该含有""db的信息,若DB为空则认为是已传入的db为默认db
并进行信息补全
// 此处生成的
meta 信息中不应该含有""db的信息,若 DB 为空则认为是已传入的 db 为默认 db
并进行信息补全
// BUG Fix:
// 修补
dbs中空DB的导致后续补全列信息时无法获取正确table
名称的问题
// 修补
dbs 中空 DB 的导致后续补全列信息时无法获取正确 table
名称的问题
if
_
,
ok
:=
dbs
[
""
];
ok
{
dbs
[
env
.
Database
]
=
dbs
[
""
]
delete
(
dbs
,
""
)
...
...
@@ -829,7 +829,7 @@ func CompleteColumnsInfo(stmt sqlparser.Statement, cols []*common.Column, env *e
// 将已经获取到正确表信息的列信息带入到env中,利用show columns where table 获取库表信息
// 此出会传入之前从ast中,该 db 下获取的所有表来作为where限定条件,
// 防止与SQL无关的库表信息干扰准确性
// 此处传入的是测试环境,DB
是经过变换的,所以在寻找列名的时候需要将DB名称转换成测试环境中经过hash的DB
名称
// 此处传入的是测试环境,DB
是经过变换的,所以在寻找列名的时候需要将 DB 名称转换成测试环境中经过 hash 的 DB
名称
// 不然会找不到col的信息
realCols
,
err
:=
env
.
FindColumn
(
col
.
Name
,
env
.
DBHash
(
db
),
dbs
.
Tables
(
db
)
...
)
if
err
!=
nil
{
...
...
@@ -840,7 +840,7 @@ func CompleteColumnsInfo(stmt sqlparser.Statement, cols []*common.Column, env *e
// 对比 column 信息中的表名与从 env 中获取的库表名的一致性
for
_
,
realCol
:=
range
realCols
{
if
col
.
Name
==
realCol
.
Name
{
// 如果查询到了列名一致,但从
ast中获取的列的前缀与env
中的表信息不符
// 如果查询到了列名一致,但从
ast 中获取的列的前缀与 env
中的表信息不符
// 1.存在一个同名列,但不同表,该情况下忽略
// 2.存在一个未正确转换的别名(如表名为),该情况下修正,大概率是正确的
if
col
.
Table
!=
""
&&
col
.
Table
!=
realCol
.
Table
{
...
...
@@ -897,7 +897,7 @@ func (idxAdv *IndexAdvisor) calcCardinality(cols []*common.Column) []*common.Col
continue
}
// 将获取的索引信息以db.tb
维度组织到IndexMeta
中
// 将获取的索引信息以db.tb
维度组织到 IndexMeta
中
idxAdv
.
IndexMeta
[
realDB
][
col
.
Table
]
=
indexInfo
}
...
...
@@ -1039,7 +1039,7 @@ func DuplicateKeyChecker(conn *database.Connector, databases ...string) map[stri
}
}
// 不指定
DB的时候检查online dsn中的
DB
// 不指定
DB 的时候检查 online dsn 中的
DB
if
len
(
databases
)
==
0
{
databases
=
append
(
databases
,
tmpOnline
.
Database
)
}
...
...
advisor/rules.go
浏览文件 @
461b6899
...
...
@@ -989,7 +989,7 @@ func init() {
Case
:
"SELECT DISTINCT c.c_id, c.c_name FROM c,e WHERE e.c_id = c.c_id"
,
Func
:
(
*
Query4Audit
)
.
RuleDistinctJoinUsage
,
},
// TODO: 5.6有了semi join
还要把in转成exists
么?
// TODO: 5.6有了semi join
还要把 in 转成e xists
么?
// Use EXISTS instead of IN to check existence of data.
// http://www.winwire.com/25-tips-to-improve-sql-query-performance/
"SUB.004"
:
{
...
...
cmd/soar/soar.go
浏览文件 @
461b6899
...
...
@@ -38,6 +38,7 @@ import (
func
main
()
{
// 全局变量
var
err
error
var
sql
string
// 单条评审指定的 sql 或 explain
sqlCounter
:=
1
// SQL 计数器
lineCounter
:=
1
// 行计数器
...
...
@@ -45,15 +46,8 @@ func main() {
alterTableTimes
:=
make
(
map
[
string
]
int
)
// 待评审的 SQL 中同一经表 ALTER 请求计数器
suggestMerged
:=
make
(
map
[
string
]
map
[
string
]
advisor
.
Rule
)
// 优化建议去重, key 为 sql 的 fingerprint.ID
ex
,
err
:=
os
.
Executable
()
if
err
!=
nil
{
panic
(
err
)
}
common
.
BaseDir
=
filepath
.
Dir
(
ex
)
// binary 文件所在路径
// 配置文件&命令行参数解析
err
=
common
.
ParseConfig
(
common
.
ArgConfig
())
common
.
LogIfWarn
(
err
,
""
)
initConfig
()
// 打印支持启发式建议
if
common
.
Config
.
ListHeuristicRules
{
...
...
@@ -496,6 +490,33 @@ func main() {
}
}
func
initConfig
()
{
// 更新 binary 文件所在路径为 BaseDir
ex
,
err
:=
os
.
Executable
()
if
err
!=
nil
{
panic
(
err
)
}
common
.
BaseDir
=
filepath
.
Dir
(
ex
)
for
i
,
c
:=
range
os
.
Args
{
// 如果指定了 -config, 它必须是第一个参数
if
strings
.
HasPrefix
(
c
,
"-config"
)
&&
i
!=
1
{
fmt
.
Println
(
"-config must be the first arg"
)
os
.
Exit
(
1
)
}
// 等号两边请不要加空格
if
c
==
"="
{
// -config = soar.yaml not support
fmt
.
Println
(
"wrong format, no space between '=', eg: -config=soar.yaml"
)
os
.
Exit
(
1
)
}
}
// 加载配置文件,处理命令行参数
err
=
common
.
ParseConfig
(
common
.
ArgConfig
())
common
.
LogIfWarn
(
err
,
""
)
}
func
shutdown
(
vEnv
*
env
.
VirtualEnv
,
rEnv
*
database
.
Connector
)
{
if
common
.
Config
.
DropTestTemporary
{
vEnv
.
CleanUp
()
...
...
common/config.go
浏览文件 @
461b6899
...
...
@@ -78,18 +78,18 @@ type Configration struct {
RewriteRules
[]
string
`yaml:"rewrite-rules"`
// 生效的重写规则
BlackList
string
`yaml:"blacklist"`
// blacklist 中的 SQL 不会被评审,可以是指纹,也可以是正则
MaxJoinTableCount
int
`yaml:"max-join-table-count"`
// 单条 SQL 中 JOIN 表的最大数量
MaxGroupByColsCount
int
`yaml:"max-group-by-cols-count"`
// 单条
SQL中GroupBy
包含列的最大数量
MaxDistinctCount
int
`yaml:"max-distinct-count"`
// 单条
SQL中Distinct
的最大数量
MaxGroupByColsCount
int
`yaml:"max-group-by-cols-count"`
// 单条
SQL 中 GroupBy
包含列的最大数量
MaxDistinctCount
int
`yaml:"max-distinct-count"`
// 单条
SQL 中 Distinct
的最大数量
MaxIdxColsCount
int
`yaml:"max-index-cols-count"`
// 复合索引中包含列的最大数量
MaxTotalRows
int64
`yaml:"max-total-rows"`
// 计算散粒度时,当数据行数大于 MaxTotalRows即开启数据库保护模式,散粒度返回结果可信度下降
MaxTotalRows
int64
`yaml:"max-total-rows"`
// 计算散粒度时,当数据行数大于 MaxTotalRows
即开启数据库保护模式,散粒度返回结果可信度下降
MaxQueryCost
int64
`yaml:"max-query-cost"`
// last_query_cost 超过该值时将给予警告
SpaghettiQueryLength
int
`yaml:"spaghetti-query-length"`
// SQL最大长度警告,超过该长度会给警告
AllowDropIndex
bool
`yaml:"allow-drop-index"`
// 允许输出删除重复索引的建议
MaxInCount
int
`yaml:"max-in-count"`
// IN()最大数量
MaxIdxBytesPerColumn
int
`yaml:"max-index-bytes-percolumn"`
// 索引中单列最大字节数,默认767
MaxIdxBytes
int
`yaml:"max-index-bytes"`
// 索引总长度限制,默认3072
TableAllowCharsets
[]
string
`yaml:"table-allow-charsets"`
// Table
允许使用的
DEFAULT CHARSET
TableAllowEngines
[]
string
`yaml:"table-allow-engines"`
// Table
允许使用的
Engine
TableAllowCharsets
[]
string
`yaml:"table-allow-charsets"`
// Table
允许使用的
DEFAULT CHARSET
TableAllowEngines
[]
string
`yaml:"table-allow-engines"`
// Table
允许使用的
Engine
MaxIdxCount
int
`yaml:"max-index-count"`
// 单张表允许最多索引数
MaxColCount
int
`yaml:"max-column-count"`
// 单张表允许最大列数
IdxPrefix
string
`yaml:"index-prefix"`
// 普通索引建议使用的前缀
...
...
@@ -98,16 +98,16 @@ type Configration struct {
MaxVarcharLength
int
`yaml:"max-varchar-length"`
// varchar最大长度
// ++++++++++++++EXPLAIN检查项+++++++++++++
ExplainSQLReportType
string
`yaml:"explain-sql-report-type"`
// EXPLAIN markdown
格式输出SQL样式,支持sample, fingerprint, pretty
ExplainSQLReportType
string
`yaml:"explain-sql-report-type"`
// EXPLAIN markdown
格式输出 SQL 样式,支持 sample, fingerprint, pretty 等
ExplainType
string
`yaml:"explain-type"`
// EXPLAIN方式 [traditional, extended, partitions]
ExplainFormat
string
`yaml:"explain-format"`
// FORMAT=[json, traditional]
ExplainWarnSelectType
[]
string
`yaml:"explain-warn-select-type"`
// 哪些
select_type
不建议使用
ExplainWarnAccessType
[]
string
`yaml:"explain-warn-access-type"`
// 哪些
access type
不建议使用
ExplainMaxKeyLength
int
`yaml:"explain-max-keys"`
// 最大key_len
ExplainMinPossibleKeys
int
`yaml:"explain-min-keys"`
// 最小
possible_keys
警告
ExplainWarnSelectType
[]
string
`yaml:"explain-warn-select-type"`
// 哪些
select_type
不建议使用
ExplainWarnAccessType
[]
string
`yaml:"explain-warn-access-type"`
// 哪些
access type
不建议使用
ExplainMaxKeyLength
int
`yaml:"explain-max-keys"`
// 最大
key_len
ExplainMinPossibleKeys
int
`yaml:"explain-min-keys"`
// 最小
possible_keys
警告
ExplainMaxRows
int
`yaml:"explain-max-rows"`
// 最大扫描行数警告
ExplainWarnExtra
[]
string
`yaml:"explain-warn-extra"`
// 哪些
extra
信息会给警告
ExplainMaxFiltered
float64
`yaml:"explain-max-filtered"`
// filtered大于该配置给出警告
ExplainWarnExtra
[]
string
`yaml:"explain-warn-extra"`
// 哪些
extra
信息会给警告
ExplainMaxFiltered
float64
`yaml:"explain-max-filtered"`
// filtered
大于该配置给出警告
ExplainWarnScalability
[]
string
`yaml:"explain-warn-scalability"`
// 复杂度警告名单
ShowWarnings
bool
`yaml:"show-warnings"`
// explain extended with show warnings
ShowLastQueryCost
bool
`yaml:"show-last-query-cost"`
// switch with show status like 'last_query_cost'
...
...
@@ -376,7 +376,7 @@ func version() {
fmt
.
Println
(
"GitDirty:"
,
GitDirty
)
}
// 因为vitess sqlparser
使用了glog中也会使用flag,为了不让用户困扰我们单独写一个
usage
// 因为vitess sqlparser
使用了 glog 中也会使用 flag,为了不让用户困扰我们单独写一个
usage
func
usage
()
{
regPwd
:=
regexp
.
MustCompile
(
`:.*@`
)
vitessHelp
:=
[]
string
{
...
...
编辑
预览
Markdown
is supported
0%
请重试
或
添加新附件
.
添加附件
取消
You are about to add
0
people
to the discussion. Proceed with caution.
先完成此消息的编辑!
取消
想要评论请
注册
或
登录