标签:http gns context 进制 返回 turn 关于 建立 ref
作者:freewind
比原项目仓库:
Github地址:https://github.com/Bytom/bytom
Gitee地址:https://gitee.com/BytomBlockchain/bytom
在前面几篇中,我们做了足够了准备,现在终于可以试一试转帐功能了!
这里的转帐最好使用solonet
,再按前一篇文章的办法修改代码后产生单机测试币,然后再试。在此之前,如果需要的话,请先备份好你之前的帐户,然后删除(或重命名)你的数据目录,再使用bytomd init --chain_id=solonet
重新初始化。
下面是我通过dashboard进行的转帐操作,在操作之前,我先建立了两个帐户,然后把钱从一个帐户转到另一个帐户的地址中:
新建一个交易,填上把哪个帐户的哪种资产转到某个地址上。可以看到还要消耗一定的gas:
(上图为图1)
转帐成功后,如下:
(上图为图2)
我们看一下这个交易的详细信息,由于太长,截成了两个图:
(上面两图合称为图3)
我们今天(以及往后的几天)就是把这一块流程搞清楚。
由于上面展示的操作还是有点多的,所以我们还是按之前的套路,先把它分解成多个小问题,一一解决:
今天的文章,我们主要是研究前两个问题,即跟图1相关的逻辑。
由于是前端,所以我们要去从前端的代码库中寻找。通过搜索“简单交易”这个词,我们很快定位到下面这块代码:
src/features/transactions/components/New/New.jsx#L275-L480
return (
<FormContainer onSubmit={handleSubmit(this.submitWithValidation)}>
// ...
</FormContainer>
)
由于上面的代码实在太长太细节,全是一些jsx用于生成表单的代码,我们就跳过算了,有兴趣的同学可以自行看细节。我们需要关注的是,当我们单击了“提交交易”的按钮以后,this.submitWithValidation
会被调用,而它对应的代码是:
src/features/transactions/components/New/New.jsx#L159-L177
submitWithValidation(data) {
return new Promise((resolve, reject) => {
this.props.submitForm(Object.assign({}, data, {state: this.state}))
.catch((err) => {
// ...
return reject(response)
})
})
}
通常我们应该会在这个函数里找到一些线索,发现数据会提交到后台哪个接口。但是这次却好像没有有用的信息,只有一个来自于props
的看起来非常通用的submitForm
。看来需要多找找线索。
好在很快在同一个文件的最后面,看到了用于把React组件与Redux连接起来的代码,非常有用:
src/features/transactions/components/New/New.jsx#L515-L572
export default BaseNew.connect(
(state) => {
// ...
return {
// ...
}
},
(dispatch) => ({
// ...
...BaseNew.mapDispatchToProps(‘transaction‘)(dispatch)
}),
// ...
)(Form)
)
我把不太关注的内容都省略了,需要关注的是BaseNew.mapDispatchToProps(‘transaction‘)(dispatch)
这一行。
为什么要关注mapDispatchToProps
这个方法呢?这是因为当我们点击了表单中的提交按钮后,不论中间怎么操作,最后一定要调用dispatch
来处理某个action。而在前面看到,点击“提交交易”后,执行的是this.props.submitForm
,通过this.props.
可以看出,这个submitForm
是从外部传进来的,而mapDispatchToPros
就是把dispatch操作映射在props
上,让props
中有我们需要的函数。所以如果我们不能从其它地方看到明显的线索的时候,应该考虑去看看这个。
而BaseNew.mapDispatchToProps
是来自于BaseNew
,我们又找到了相应的代码:
src/features/shared/components/BaseNew.jsx#L9-L16
import actions from ‘actions‘
// ...
export const mapDispatchToProps = (type) => (dispatch) => ({
submitForm: (data) => {
return dispatch(actions[type].submitForm(data)).then((resp) => {
dispatch(actions.tutorial.submitTutorialForm(data, type))
return resp
})
}
})
果然在里面找到了submitForm
的定义。在里面第一个dispatch
处,传入了参数actions[type].submitForm(data)
,这里的type
应该是transaction
,而actions
应该是之前某处定义的各种action的集合。
根据import actions from ‘actions‘
,我们发现from
后面的‘actions‘
不是相对路径,那么它对应的就是js的源代码根目录src
下的某个文件,比如actions.js
。
找到后打开一看,里面果然有transaction
:
// ...
import { actions as transaction } from ‘features/transactions‘
// ...
const actions = {
// ...
transaction,
// ...
}
我们继续进入features/transactions/
探索,很快找到:
src/features/transactions/actions.js#L100-L200
form.submitForm = (formParams) => function (dispatch) {
// ...
// 2.
const buildPromise = connection.request(‘/build-transaction‘, {actions: processed.actions})
const signAndSubmitTransaction = (transaction, password) => {
// 4.
return connection.request(‘/sign-transaction‘, {
password,
transaction
}).then(resp => {
if (resp.status === ‘fail‘) {
throw new Error(resp.msg)
}
const rawTransaction = resp.data.transaction.rawTransaction
// 5.
return connection.request(‘/submit-transaction‘, {rawTransaction})
}).then(dealSignSubmitResp)
}
// ...
if (formParams.submitAction == ‘submit‘) {
// 1.
return buildPromise
.then((resp) => {
if (resp.status === ‘fail‘) {
throw new Error(resp.msg)
}
// 3.
return signAndSubmitTransaction(resp.data, formParams.password)
})
}
// ...
}
上面的代码经过了我的简化,其实它本来是有很多分支的(因为表单中除了“简单交易”还有“高级交易”等情况)。即使如此,也可以看出来这个过程还是比较复杂的,经过了好几次的后台接口访问:
buildPromise
,这里面应该包括了对后台的访问buildPromise
的定义,可以看到会访问/build-transaction
signAndSubmitTransaction
signAndSubmitTransaction
内部了,可以看到,它会访问一个新的接口/sign-transaction
/submit-transaction
。后面的dealSignSubmitResp
是一些对前端的操作,所以就不看它了可以看到,这一个表单的提交,在内部对应着好几个接口的访问,每个提交的数据也不一样,代码跟踪起来不太方便。但是好在只要我们知道了这一条主线,那么寻找其它的信息就会简单一些。不过我们也没有必要执着于全部从源代码中找到答案,因为我们的目的并不是学习React/Redux,而是理解比原的逻辑,所以我们可以借助别的工具(比如Chrome的Developer Tools),来捕获请求的数据,从而推理出逻辑。
我已经从Chrome的开发工具中取得了前端向下面几个接口发送的数据:
/build-transaction
/sign-transaction
/submit-transaction
但是由于我们在这个小问题中,关注的重点是前端如何把数据提交给后台的,所以对于这里提交的数据的意义暂时不讨论,留待下个小问题中一一解答。
由于在图1中前端一共访问了3个不同的后端接口,所以在这里我们就需要依次分开讨论。
/build-transaction
下面是我通过Chrome的开发工具捕获的数据,看起来还比较多:
/build-transaction
{
"actions": [{
"amount": 437400,
"type": "spend_account",
"receiver": null,
"account_alias": "freewind",
"account_id": "",
"asset_alias": "BTM",
"reference_data": null
}, {
"amount": 23400000000,
"type": "spend_account",
"receiver": null,
"account_alias": "freewind",
"account_id": "",
"asset_alias": "BTM",
"asset_id": "",
"reference_data": null
}, {
"address": "sm1qe4z3ava34wv5njdgekcgdlrckc95gnljazezva",
"amount": 23400000000,
"type": "control_address",
"receiver": null,
"asset_alias": "BTM",
"asset_id": "",
"reference_data": null
}]
}
可以看到前端向/build-transaction
发送的数据包含了三个元素,其中前两个是来源帐户的信息,第三个是目的帐户地址。这三个元素都包含一个叫amount
的key,它的值对应的是相应资产的数量,如果是BTM
的话,这个数字就需要从右向左数8位,再加上一个小数点。也就是说,第一个amount对应的是0.00437400
个BTM,第二个是234.00000000
,第三个是234.00000000
。
第一个元素对应的费用是gas,也就是图1中显示出来的估算的手续费。第二个是要从相应帐户中转出234
个BTM,第三个是要转入234
个BTM。
另外,前两个的type
是spend_account
,表明了是帐户,但是spend
是什么意思目前还不清楚(TODO);第三个是control_address
,表示是一个地址。
通过这些数据,比原的后台就知道该怎么做了。
{
"status": "success",
"data": {
"raw_transaction": "070100010161015f643bef0936443042ccb1e94213ed52af72488088702d88e7fc3580359a19a522ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8099c4d599010001160014108c5ba0934951a12755523f8a1fe42a6c24342f010002013dffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe8ebaabf4201160014b111c8114dc7ee02050598022b46855fd482d27300013dffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff80d4fe955701160014cd451eb3b1ab9949c9a8cdb086fc78b60b444ff200",
"signing_instructions": [{
"position": 0,
"witness_components": [{
"type": "raw_tx_signature",
"quorum": 1,
"keys": [{
"xpub": "f98b3a39b4eef67707cac85240ef07235c990301b2e0658001545bdb7fde3a21363a23682a1dfbb727dec7565624812c314ca9f31a7f7374101e0247d05cb248",
"derivation_path": ["010100000000000000", "0100000000000000"]
}],
"signatures": null
}, {
"type": "data",
"value": "b826dcccff76d19d097ca207e053e67d67e3da3a90896ae9fa2d984c6f36d16c"
}]
}],
"allow_additional_actions": false
}
}
这个回应信息是什么意思呢?我们现在开始研究。
我们在比原的后端代码库中,通过查找/build-transaction
,很快找到了它的定义处:
func (a *API) buildHandler() {
// ...
if a.wallet != nil {
// ...
m.Handle("/build-transaction", jsonHandler(a.build))
// ...
}
可以看到它对就的方法是a.build
,其代码为:
func (a *API) build(ctx context.Context, buildReqs *BuildRequest) Response {
subctx := reqid.NewSubContext(ctx, reqid.New())
tmpl, err := a.buildSingle(subctx, buildReqs)
if err != nil {
return NewErrorResponse(err)
}
return NewSuccessResponse(tmpl)
}
其中的buildReqs
就对应着前端提交过来的参数,只不过被jsonHandler
自动转成了Go代码。其中BuildRequest
是这样定义的:
type BuildRequest struct {
Tx *types.TxData `json:"base_transaction"`
Actions []map[string]interface{} `json:"actions"`
TTL json.Duration `json:"ttl"`
TimeRange uint64 `json:"time_range"`
}
可以看出来有一些字段比如base_transaction
, ttl
, time_range
等在本例中并没有提交上来,它们应该是可选的。
继续看a.buildSingle
:
func (a *API) buildSingle(ctx context.Context, req *BuildRequest) (*txbuilder.Template, error) {
// 1.
err := a.filterAliases(ctx, req)
// ...
// 2.
if onlyHaveSpendActions(req) {
return nil, errors.New("transaction only contain spend actions, didn‘t have output actions")
}
// 3.
reqActions, err := mergeActions(req)
// ...
// 4.
actions := make([]txbuilder.Action, 0, len(reqActions))
for i, act := range reqActions {
typ, ok := act["type"].(string)
// ...
decoder, ok := a.actionDecoder(typ)
// ...
b, err := json.Marshal(act)
// ...
action, err := decoder(b)
// ...
actions = append(actions, action)
}
// 5.
ttl := req.TTL.Duration
if ttl == 0 {
ttl = defaultTxTTL
}
maxTime := time.Now().Add(ttl)
// 6.
tpl, err := txbuilder.Build(ctx, req.Tx, actions, maxTime, req.TimeRange)
// ...
return tpl, nil
}
这段代码内容还是比较多的,但总体基本上还是对参数进行验证、补全和转换,然后交给后面的方法处理。我分成了多块,依次讲解大意:
filterAliases
主要是对传进来的参数进行验证和补全。比如像account和asset,一般都有id和alias这两个属性,如果只提交了alias而没有提交id的话,则filterAliases
就会从数据库或者缓存中查找到相应的id
补全。如果过程中出了错,比如alias不存在,则报错返回onlyHaveSpendActions
是检查如果这个交易中,只存在资金来源方,而没有资金目标方,显示是不对的,报错返回mergeActions
是把请求数据中的spend_account
进行分组累加,把相同account的相同asset的数量累加到一起actionDecoder(typ)
里通过手动比较type
的值返回相应的Decoderttl
是指Time To Live
,指的这个请求的存活时间,如果没指明的话(本例就没有),则设为默认值5分钟txbuilder.Build
继续处理在这几处里提到的方法和函数的代码我就不贴出来了,因为基本上都是一些针对map的低级操作,大片大片的看着很累,实际上没做多少事。这种类型的代码反复出现,在别的语言中(甚至Java)都可以抽出来很多工具方法,但是在Go里由于语言特性(缺少泛型,麻烦的错误处理),似乎不是很容易。看一眼广大Go程序员的期盼:
https://github.com/golang/go/issues/15292
看看在Go2中会不会实现。
让我们继续看txbuilder.Build
:
blockchain/txbuilder/txbuilder.go#L40-L79
func Build(ctx context.Context, tx *types.TxData, actions []Action, maxTime time.Time, timeRange uint64) (*Template, error) {
builder := TemplateBuilder{
base: tx,
maxTime: maxTime,
timeRange: timeRange,
}
// Build all of the actions, updating the builder.
var errs []error
for i, action := range actions {
err := action.Build(ctx, &builder)
// ...
}
// If there were any errors, rollback and return a composite error.
if len(errs) > 0 {
builder.rollback()
return nil, errors.WithData(ErrAction, "actions", errs)
}
// Build the transaction template.
tpl, tx, err := builder.Build()
// ...
return tpl, nil
}
这块代码经过简化后,还是比较清楚的,基本上就是想尽办法把TemplateBuilder
填满。TemplateBuilder
是这样的:
blockchain/txbuilder/builder.go#L17-L28
type TemplateBuilder struct {
base *types.TxData
inputs []*types.TxInput
outputs []*types.TxOutput
signingInstructions []*SigningInstruction
minTime time.Time
maxTime time.Time
timeRange uint64
referenceData []byte
rollbacks []func()
callbacks []func() error
}
可以看到有很多字段,但是只要清楚了它们的用途,我们也就清楚了交易transaction
是怎么回事。但是我发现一旦深入下去,很快又触及到比原的核心部分,所以就停在这里不去深究了。前面Build
函数里面提到的其它的方法,比如action.Build
等,我们也不进去了,因为它们基本上都是在想尽办法组装出最后需要的对象。
到这里,我们可以认为buildSingle
就走完了,然后回到func (a *API) build(...)
,把生成的对象返回给前端。
那么,这个接口/build-transaction
到底是做什么的呢?通过上面我分析,我们可以知道它有两个作用:
id
,公钥等等,方便前端进行后面的操作在这个接口的分析过程中,我们还是忽略了很多内容,比如返回给客户端的那一大段JSON代码中的数据。我想这些东西还是留着我们研究到比原的核心的时候,再一起学习吧。
/sign-transaction
在前一步/build-transaction
成功完成以后,会进行下一步操作/sign-transaction
。
下面是通过Chrome的开发工具捕获的内容:
{
"password": "my-password",
"transaction": {
"raw_transaction": "070100010161015f643bef0936443042ccb1e94213ed52af72488088702d88e7fc3580359a19a522ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8099c4d599010001160014108c5ba0934951a12755523f8a1fe42a6c24342f010002013dffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe8ebaabf4201160014b111c8114dc7ee02050598022b46855fd482d27300013dffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff80d4fe955701160014cd451eb3b1ab9949c9a8cdb086fc78b60b444ff200",
"signing_instructions": [{
"position": 0,
"witness_components": [{
"type": "raw_tx_signature",
"quorum": 1,
"keys": [{
"xpub": "f98b3a39b4eef67707cac85240ef07235c990301b2e0658001545bdb7fde3a21363a23682a1dfbb727dec7565624812c314ca9f31a7f7374101e0247d05cb248",
"derivation_path": ["010100000000000000", "0100000000000000"]
}],
"signatures": null
}, {
"type": "data",
"value": "b826dcccff76d19d097ca207e053e67d67e3da3a90896ae9fa2d984c6f36d16c"
}]
}],
"allow_additional_actions": false
}
}
可以看到这里提交的请求数据,与前面/build-transaction
相比,基本上是一样的,只是多了一个password
,即我们刚才在表单最后一处填写的密码。从这个接口的名字中含有sign
可以推测,这一步应该是与签名有关。
{
"status": "success",
"data": {
"transaction": {
"raw_transaction": "070100010161015f643bef0936443042ccb1e94213ed52af72488088702d88e7fc3580359a19a522ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8099c4d599010001160014108c5ba0934951a12755523f8a1fe42a6c24342f630240c52a057fa26322a48fdd88c842cf31a84c6aec54ae2dc62554dc3c7e0216986a0a4f4a5c935a5ae6d88b4c7a4d1ca1937205f5eb23089128cc6744fbd2b88d0520b826dcccff76d19d097ca207e053e67d67e3da3a90896ae9fa2d984c6f36d16c02013dffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe8ebaabf4201160014b111c8114dc7ee02050598022b46855fd482d27300013dffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff80d4fe955701160014cd451eb3b1ab9949c9a8cdb086fc78b60b444ff200",
"signing_instructions": [{
"position": 0,
"witness_components": [{
"type": "raw_tx_signature",
"quorum": 1,
"keys": [{
"xpub": "f98b3a39b4eef67707cac85240ef07235c990301b2e0658001545bdb7fde3a21363a23682a1dfbb727dec7565624812c314ca9f31a7f7374101e0247d05cb248",
"derivation_path": ["010100000000000000", "0100000000000000"]
}],
"signatures": ["c52a057fa26322a48fdd88c842cf31a84c6aec54ae2dc62554dc3c7e0216986a0a4f4a5c935a5ae6d88b4c7a4d1ca1937205f5eb23089128cc6744fbd2b88d05"]
}, {
"type": "data",
"value": "b826dcccff76d19d097ca207e053e67d67e3da3a90896ae9fa2d984c6f36d16c"
}]
}],
"allow_additional_actions": false
},
"sign_complete": true
}
}
回过来的消息也基本上跟提交的差不多,只是在成功操作后,raw_transaction
字段的内容也变长了,还添加上了signatures
字段。
我们开始看代码,通过搜索/sign-transaction
,我们很快定位到以下代码:
func (a *API) buildHandler() {
// ...
if a.wallet != nil {
// ...
m.Handle("/sign-transaction", jsonHandler(a.pseudohsmSignTemplates))
// ...
}
则/sign-transaction
对应的handler是a.pseudohsmSignTemplates
,让我们跟进去:
func (a *API) pseudohsmSignTemplates(ctx context.Context, x struct {
Password string `json:"password"`
Txs txbuilder.Template `json:"transaction"`
}) Response {
if err := txbuilder.Sign(ctx, &x.Txs, x.Password, a.pseudohsmSignTemplate); err != nil {
log.WithField("build err", err).Error("fail on sign transaction.")
return NewErrorResponse(err)
}
log.Info("Sign Transaction complete.")
return NewSuccessResponse(&signResp{Tx: &x.Txs, SignComplete: txbuilder.SignProgress(&x.Txs)})
}
可以看到这个方法内容也是比较简单的。通过调用txbuilder.Sign
,把前端传来的参数传进去,然后把结果返回给前端即可。那我们只需要看txbuilder.Sign
即可:
blockchain/txbuilder/txbuilder.go#L82-L100
func Sign(ctx context.Context, tpl *Template, auth string, signFn SignFunc) error {
// 1.
for i, sigInst := range tpl.SigningInstructions {
for j, wc := range sigInst.WitnessComponents {
switch sw := wc.(type) {
case *SignatureWitness:
err := sw.sign(ctx, tpl, uint32(i), auth, signFn)
// ...
case *RawTxSigWitness:
err := sw.sign(ctx, tpl, uint32(i), auth, signFn)
// ...
}
}
}
// 2.
return materializeWitnesses(tpl)
}
可以看到这段代码逻辑还是比较简单:
sw.sign
,生成相关的签名signatures
raw_transaction
处添加了一些操作符和约束条件,把它变成了一个合约(这块还需要以后确认)materializeWitnesses
函数。它主要是在检查没有数据错误之后,把第1步中生成的签名signatures
添加到tpl
对象上去。由于sw.Sign
和materializeWitnesses
基本上都是一些算法或者合约相关的东西,我们这里就暂时忽略,以后再研究吧。
这个接口/sign-transaction
的作用应该是对通过密码以及公钥对“交易”这个重要的操作进行验证,不然大家都能随便把别人的钱转到自己帐户里了。
/submit-transaction
当前一步/sign-transaction
签名成功之后,终于可以进行最后一步/submit-transaction
进行最终的提交了。
下面是通过Chrome的开发工具捕获的内容。
{
"raw_transaction": "070100010161015f643bef0936443042ccb1e94213ed52af72488088702d88e7fc3580359a19a522ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8099c4d599010001160014108c5ba0934951a12755523f8a1fe42a6c24342f630240c52a057fa26322a48fdd88c842cf31a84c6aec54ae2dc62554dc3c7e0216986a0a4f4a5c935a5ae6d88b4c7a4d1ca1937205f5eb23089128cc6744fbd2b88d0520b826dcccff76d19d097ca207e053e67d67e3da3a90896ae9fa2d984c6f36d16c02013dffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe8ebaabf4201160014b111c8114dc7ee02050598022b46855fd482d27300013dffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff80d4fe955701160014cd451eb3b1ab9949c9a8cdb086fc78b60b444ff200"
}
可以看到,到了这一步,提交的数据就少了,直接把前一步生成的签名后的raw_transaction
提交上去就行了。我想这里的内容应该已经包含了全部需要的信息,并且经过了验证,所以不需要其它数据了。
{
"status": "success",
"data": {
"tx_id": "6866c1ab2bfa2468ce44451ce6af2a83f3885cdb6a1673fec94b27f338acf9c5"
}
}
可以看到成功提交后,会得到一个tx_id
,即为当前这个交易生成的唯一的id,可以用来查询。
我们通过查找/submit-transaction
,可以在代码中找到:
func (a *API) buildHandler() {
// ...
if a.wallet != nil {
// ...
m.Handle("/submit-transaction", jsonHandler(a.submit))
// ...
}
那么/submit-transaction
所对应的handler就是a.submit
了。我们跟进去:
func (a *API) submit(ctx context.Context, ins struct {
Tx types.Tx `json:"raw_transaction"`
}) Response {
if err := txbuilder.FinalizeTx(ctx, a.chain, &ins.Tx); err != nil {
return NewErrorResponse(err)
}
log.WithField("tx_id", ins.Tx.ID).Info("submit single tx")
return NewSuccessResponse(&submitTxResp{TxID: &ins.Tx.ID})
}
可以看到主要逻辑就是调用txbuilder.FinalizeTx
来“终结”这个交易,然后把生成的tx_id
返回给前端。
让我们继续看txbuilder.FinalizeTx
:
blockchain/txbuilder/finalize.go#L25-L47
func FinalizeTx(ctx context.Context, c *protocol.Chain, tx *types.Tx) error {
// 1.
if err := checkTxSighashCommitment(tx); err != nil {
return err
}
// This part is use for prevent tx size is 0
// 2.
data, err := tx.TxData.MarshalText()
// ...
// 3.
tx.TxData.SerializedSize = uint64(len(data))
tx.Tx.SerializedSize = uint64(len(data))
// 4.
_, err = c.ValidateTx(tx)
// ...
}
这一个方法整体上还是各种验证
tx
中的某些字段txPool
(用来在内存中保存交易的对象池),等待广播出去以及打包到区块中。我觉得这个名字ValidateTx
有点问题,因为它即包含了验证,还包含了提交到池子中,这是两个不同的操作,应该分开这里涉及到的更细节的代码就不进去了,主线我们已经有了,感兴趣的同学可以自行进去深入研究。
那我们今天关于提交交易的这个小问题就算是完成了,下次会继续研究剩下的几个小问题。
标签:http gns context 进制 返回 turn 关于 建立 ref
原文地址:https://www.cnblogs.com/bytom/p/9356979.html