如何編寫一個 CSI 插件
這里以csi-driver-host-path作為例子,來看看是如何實現(xiàn)一個csi插件的?
目標(biāo):
支持PV動態(tài)創(chuàng)建,并且能夠掛載在POD中 volume來自本地目錄,主要是模擬volume產(chǎn)生的過程,這樣就不依賴于某個特定的存儲服務(wù)
預(yù)備知識
在上一篇文章中,已經(jīng)對CSI概念有個了解,并且提出了CSI組件需要實現(xiàn)的RPC接口,那我們?yōu)槭裁葱枰@些接口,這需要從volume要被使用經(jīng)過了以下流程:
volume創(chuàng)建 volume attach到節(jié)點(比如像EBS硬盤,NFS可能就直接下一步mount了)volume 被 mount到指定目錄(這個目錄其實就被映射到容器中,由kubelet 中的VolumeManager調(diào)用)
而當(dāng)卸載時正好是相反的:unmount,detach,delete volume
正好對應(yīng)如下圖:
???CreateVolume?+------------+?DeleteVolume
?+------------->|??CREATED???+--------------+
?|??????????????+---+----^---+??????????????|
?|???????Controller?|????|?Controller???????v
+++?????????Publish?|????|?Unpublish???????+++
|X|??????????Volume?|????|?Volume??????????|?|
+-+?????????????+---v----+---+?????????????+-+
????????????????|?NODE_READY?|
????????????????+---+----^---+
???????????????Node?|????|?Node
??????????????Stage?|????|?Unstage
?????????????Volume?|????|?Volume
????????????????+---v----+---+
????????????????|??VOL_READY?|
????????????????+---+----^---+
???????????????Node?|????|?Node
????????????Publish?|????|?Unpublish
?????????????Volume?|????|?Volume
????????????????+---v----+---+
????????????????|?PUBLISHED??|
????????????????+------------+
而為什么多個NodeStageVolume的過程是因為:
對于塊存儲來說,設(shè)備只能mount到一個目錄上,所以
NodeStageVolume就是先mount到一個globalmount目錄(類似:/var/lib/kubelet/plugins/kubernetes.io/csi/pv/pvc-bcfe33ed-e822-4b0e-954a-0f5c0468525e/globalmount),然后再NodePublishVolume這一步中通過mount bind到pod的目錄(/var/lib/kubelet/pods/9c5aa371-e5a7-4b67-8795-ec7013811363/volumes/kubernetes.io~csi/pvc-bcfe33ed-e822-4b0e-954a-0f5c0468525e/mount/hello-world),這樣就可以實現(xiàn)一個pv掛載在多個pod中使用。
代碼實現(xiàn)
我們并不一定要實現(xiàn)所有的接口,這個可以通過CSI中Capabilities能力標(biāo)識出來,我們組件提供的能力,比如
IdentityServer中的GetPluginCapabilities方法ControllerServer中的ControllerGetCapabilities方法NodeServer中的NodeGetCapabilities
這些方法都是在告訴調(diào)用方,我們的組件實現(xiàn)了哪些能力,未實現(xiàn)的方法就不會調(diào)用了。
IdentityServer
IdentityServer包含了三個接口,這里我們主要實現(xiàn)
//?IdentityServer?is?the?server?API?for?Identity?service.
type?IdentityServer?interface?{
?GetPluginInfo(context.Context,?*GetPluginInfoRequest)?(*GetPluginInfoResponse,?error)
?GetPluginCapabilities(context.Context,?*GetPluginCapabilitiesRequest)?(*GetPluginCapabilitiesResponse,?error)
?Probe(context.Context,?*ProbeRequest)?(*ProbeResponse,?error)
}
主要看下GetPluginCapabilities這個方法:
identityserver.go#L60:
func?(ids?*identityServer)?GetPluginCapabilities(ctx?context.Context,?req?*csi.GetPluginCapabilitiesRequest)?(*csi.GetPluginCapabilitiesResponse,?error)?{
?return?&csi.GetPluginCapabilitiesResponse{
??Capabilities:?[]*csi.PluginCapability{
???{
????Type:?&csi.PluginCapability_Service_{
?????Service:?&csi.PluginCapability_Service{
??????Type:?csi.PluginCapability_Service_CONTROLLER_SERVICE,
?????},
????},
???},
???{
????Type:?&csi.PluginCapability_Service_{
?????Service:?&csi.PluginCapability_Service{
??????Type:?csi.PluginCapability_Service_VOLUME_ACCESSIBILITY_CONSTRAINTS,
?????},
????},
???},
??},
?},?nil
}
以上就告訴調(diào)用者我們提供了ControllerService的能力,以及volume訪問限制的能力(CSI 處理時需要根據(jù)集群拓?fù)渥髡{(diào)整)
PS:其實在k8s還提供了一個包:github.com/kubernetes-csi/drivers/pkg/csi-common,里面提供了比如
DefaultIdentityServer,DefaultControllerServer,DefaultNodeServer的struct,只要在我們自己的XXXServer struct中繼承這些struct,我們的代碼中就只要包含自己實現(xiàn)的方法就行了,可以參考alibaba-cloud-csi-driver中的。
###ControllerServer
ControllerServer我們主要關(guān)注CreateVolume,DeleteVolume,因為是hostpath volume,所以就沒有attach的這個過程了,我們放在NodeServer中實現(xiàn):
CreateVolume
controllerserver.go#L73
func?(cs?*controllerServer)?CreateVolume(ctx?context.Context,?req?*csi.CreateVolumeRequest)?(*csi.CreateVolumeResponse,?error)?{
??//校驗參數(shù)是否有CreateVolume的能力
?if?err?:=?cs.validateControllerServiceRequest(csi.ControllerServiceCapability_RPC_CREATE_DELETE_VOLUME);?err?!=?nil?{
??glog.V(3).Infof("invalid?create?volume?req:?%v",?req)
??return?nil,?err
?}
??//.....這里省略的校驗參數(shù)的過程
??//這里根據(jù)volume?name判斷是否已經(jīng)存在了,存在了就返回就行了
?if?exVol,?err?:=?getVolumeByName(req.GetName());?err?==?nil?{
??//?volume已經(jīng)存在,但是大小不符合
??if?exVol.VolSize????return?nil,?status.Errorf(codes.AlreadyExists,?"Volume?with?the?same?name:?%s?but?with?different?size?already?exist",?req.GetName())
??}
????//這里判斷是否設(shè)置了pvc.dataSource,就表示是一個restore過程
??if?req.GetVolumeContentSource()?!=?nil?{
???volumeSource?:=?req.VolumeContentSource
???switch?volumeSource.Type.(type)?{
????????//校驗:從快照中恢復(fù)
???case?*csi.VolumeContentSource_Snapshot:
????if?volumeSource.GetSnapshot()?!=?nil?&&?exVol.ParentSnapID?!=?""?&&?exVol.ParentSnapID?!=?volumeSource.GetSnapshot().GetSnapshotId()?{
?????return?nil,?status.Error(codes.AlreadyExists,?"existing?volume?source?snapshot?id?not?matching")
????}
????????//校驗:clone過程
???case?*csi.VolumeContentSource_Volume:
????if?volumeSource.GetVolume()?!=?nil?&&?exVol.ParentVolID?!=?volumeSource.GetVolume().GetVolumeId()?{
?????return?nil,?status.Error(codes.AlreadyExists,?"existing?volume?source?volume?id?not?matching")
????}
???default:
????return?nil,?status.Errorf(codes.InvalidArgument,?"%v?not?a?proper?volume?source",?volumeSource)
???}
??}
??//?TODO?(sbezverk)?Do?I?need?to?make?sure?that?volume?still?exists?
??return?&csi.CreateVolumeResponse{
???Volume:?&csi.Volume{
????VolumeId:??????exVol.VolID,
????CapacityBytes:?int64(exVol.VolSize),
????VolumeContext:?req.GetParameters(),
????ContentSource:?req.GetVolumeContentSource(),
???},
??},?nil
?}
??//創(chuàng)建volume
?volumeID?:=?uuid.NewUUID().String()
??//創(chuàng)建hostpath的volume
?vol,?err?:=?createHostpathVolume(volumeID,?req.GetName(),?capacity,?requestedAccessType,?false?/*?ephemeral?*/)
?if?err?!=?nil?{
??return?nil,?status.Errorf(codes.Internal,?"failed?to?create?volume?%v:?%v",?volumeID,?err)
?}
?glog.V(4).Infof("created?volume?%s?at?path?%s",?vol.VolID,?vol.VolPath)
??
??//判斷是從快照恢復(fù),還是clone
?if?req.GetVolumeContentSource()?!=?nil?{
??path?:=?getVolumePath(volumeID)
??volumeSource?:=?req.VolumeContentSource
??switch?volumeSource.Type.(type)?{
??????//從快照恢復(fù)
??case?*csi.VolumeContentSource_Snapshot:
???if?snapshot?:=?volumeSource.GetSnapshot();?snapshot?!=?nil?{
????err?=?loadFromSnapshot(capacity,?snapshot.GetSnapshotId(),?path,?requestedAccessType)
????vol.ParentSnapID?=?snapshot.GetSnapshotId()
???}
??????//clone
??case?*csi.VolumeContentSource_Volume:
???if?srcVolume?:=?volumeSource.GetVolume();?srcVolume?!=?nil?{
????err?=?loadFromVolume(capacity,?srcVolume.GetVolumeId(),?path,?requestedAccessType)
????vol.ParentVolID?=?srcVolume.GetVolumeId()
???}
??default:
???err?=?status.Errorf(codes.InvalidArgument,?"%v?not?a?proper?volume?source",?volumeSource)
??}
??if?err?!=?nil?{
???if?delErr?:=?deleteHostpathVolume(volumeID);?delErr?!=?nil?{
????glog.V(2).Infof("deleting?hostpath?volume?%v?failed:?%v",?volumeID,?delErr)
???}
???return?nil,?err
??}
??glog.V(4).Infof("successfully?populated?volume?%s",?vol.VolID)
?}
??
??//Topology表示volume能夠部署在哪些節(jié)點(生產(chǎn)情況可能就對應(yīng)可用區(qū))
?topologies?:=?[]*csi.Topology{&csi.Topology{
??Segments:?map[string]string{TopologyKeyNode:?cs.nodeID},
?}}
?return?&csi.CreateVolumeResponse{
??Volume:?&csi.Volume{
???VolumeId:???????????volumeID,
???CapacityBytes:??????req.GetCapacityRange().GetRequiredBytes(),
???VolumeContext:??????req.GetParameters(),
???ContentSource:??????req.GetVolumeContentSource(),
???AccessibleTopology:?topologies,
??},
?},?nil
}
createHostpathVolume
再來看下createHostpathVolume方法,這里accessType有兩個選項,是創(chuàng)建文件系統(tǒng),還是創(chuàng)建塊,其實就是對應(yīng)pvc中volumeMode字段:
pkg/hostpath/hostpath.go#L208
//?createVolume?create?the?directory?for?the?hostpath?volume.
//?It?returns?the?volume?path?or?err?if?one?occurs.
func?createHostpathVolume(volID,?name?string,?cap?int64,?volAccessType?accessType,?ephemeral?bool)?(*hostPathVolume,?error)?{
?path?:=?getVolumePath(volID)
?switch?volAccessType?{
?case?mountAccess:
????//創(chuàng)建文件
??err?:=?os.MkdirAll(path,?0777)
??if?err?!=?nil?{
???return?nil,?err
??}
?case?blockAccess:
????//創(chuàng)建塊
??executor?:=?utilexec.New()
??size?:=?fmt.Sprintf("%dM",?cap/mib)
??//?Create?a?block?file.
??_,?err?:=?os.Stat(path)
??if?err?!=?nil?{
???if?os.IsNotExist(err)?{
????out,?err?:=?executor.Command("fallocate",?"-l",?size,?path).CombinedOutput()
????if?err?!=?nil?{
?????return?nil,?fmt.Errorf("failed?to?create?block?device:?%v,?%v",?err,?string(out))
????}
???}?else?{
????return?nil,?fmt.Errorf("failed?to?stat?block?device:?%v,?%v",?path,?err)
???}
??}
????//?通過losetup將文件虛擬成塊設(shè)備
??//?Associate?block?file?with?the?loop?device.
??volPathHandler?:=?volumepathhandler.VolumePathHandler{}
??_,?err?=?volPathHandler.AttachFileDevice(path)
??if?err?!=?nil?{
???//?Remove?the?block?file?because?it'll?no?longer?be?used?again.
???if?err2?:=?os.Remove(path);?err2?!=?nil?{
????glog.Errorf("failed?to?cleanup?block?file?%s:?%v",?path,?err2)
???}
???return?nil,?fmt.Errorf("failed?to?attach?device?%v:?%v",?path,?err)
??}
?default:
??return?nil,?fmt.Errorf("unsupported?access?type?%v",?volAccessType)
?}
?hostpathVol?:=?hostPathVolume{
??VolID:?????????volID,
??VolName:???????name,
??VolSize:???????cap,
??VolPath:???????path,
??VolAccessType:?volAccessType,
??Ephemeral:?????ephemeral,
?}
?hostPathVolumes[volID]?=?hostpathVol
?return?&hostpathVol,?nil
}
DeleteVolume
在DeleteVolume這里主要是刪除volume:
pkg/hostpath/controllerserver.go#L2
func?(cs?*controllerServer)?DeleteVolume(ctx?context.Context,?req?*csi.DeleteVolumeRequest)?(*csi.DeleteVolumeResponse,?error)?{
?//?Check?arguments
?if?len(req.GetVolumeId())?==?0?{
??return?nil,?status.Error(codes.InvalidArgument,?"Volume?ID?missing?in?request")
?}
?if?err?:=?cs.validateControllerServiceRequest(csi.ControllerServiceCapability_RPC_CREATE_DELETE_VOLUME);?err?!=?nil?{
??glog.V(3).Infof("invalid?delete?volume?req:?%v",?req)
??return?nil,?err
?}
?volId?:=?req.GetVolumeId()
?if?err?:=?deleteHostpathVolume(volId);?err?!=?nil?{
??return?nil,?status.Errorf(codes.Internal,?"failed?to?delete?volume?%v:?%v",?volId,?err)
?}
?glog.V(4).Infof("volume?%v?successfully?deleted",?volId)
?return?&csi.DeleteVolumeResponse{},?nil
}
在ControllerService中還有一些其他接口,比如CreateSnapshot創(chuàng)建快照,DeleteSnapshot刪除快照,擴(kuò)容等,其實都會依賴于我們存儲服務(wù)端的提供的能力,調(diào)用相應(yīng)的接口就行了。
NodeServer
在nodeServer中就是實現(xiàn)我們的mount,unmount過程了,分別對應(yīng)NodePublishVolume和NodeUnpublishVolume
NodePublishVolume
pkg/hostpath/nodeserver.go#L5
func?(ns?*nodeServer)?NodePublishVolume(ctx?context.Context,?req?*csi.NodePublishVolumeRequest)?(*csi.NodePublishVolumeResponse,?error)?{
?//......這里省略校驗參數(shù)代碼
?
?vol,?err?:=?getVolumeByID(req.GetVolumeId())
?if?err?!=?nil?{
??return?nil,?status.Error(codes.NotFound,?err.Error())
?}
??//對應(yīng)pvc.volumeBind字段是block的情況
?if?req.GetVolumeCapability().GetBlock()?!=?nil?{
??if?vol.VolAccessType?!=?blockAccess?{
???return?nil,?status.Error(codes.InvalidArgument,?"cannot?publish?a?non-block?volume?as?block?volume")
??}
??volPathHandler?:=?volumepathhandler.VolumePathHandler{}
????//獲取device地址(通過loopset?-l命令,因為是通過文件虛擬出來的塊設(shè)備)
??//?Get?loop?device?from?the?volume?path.
??loopDevice,?err?:=?volPathHandler.GetLoopDevice(vol.VolPath)
??if?err?!=?nil?{
???return?nil,?status.Error(codes.Internal,?fmt.Sprintf("failed?to?get?the?loop?device:?%v",?err))
??}
??mounter?:=?mount.New("")
??//?Check?if?the?target?path?exists.?Create?if?not?present.
??_,?err?=?os.Lstat(targetPath)
??if?os.IsNotExist(err)?{
???if?err?=?mounter.MakeFile(targetPath);?err?!=?nil?{
????return?nil,?status.Error(codes.Internal,?fmt.Sprintf("failed?to?create?target?path:?%s:?%v",?targetPath,?err))
???}
??}
??if?err?!=?nil?{
???return?nil,?status.Errorf(codes.Internal,?"failed?to?check?if?the?target?block?file?exists:?%v",?err)
??}
??//?Check?if?the?target?path?is?already?mounted.?Prevent?remounting.
??notMount,?err?:=?mounter.IsNotMountPoint(targetPath)
??if?err?!=?nil?{
???if?!os.IsNotExist(err)?{
????return?nil,?status.Errorf(codes.Internal,?"error?checking?path?%s?for?mount:?%s",?targetPath,?err)
???}
???notMount?=?true
??}
??if?!notMount?{
???//?It's?already?mounted.
???glog.V(5).Infof("Skipping?bind-mounting?subpath?%s:?already?mounted",?targetPath)
???return?&csi.NodePublishVolumeResponse{},?nil
??}
????//進(jìn)行綁定掛載(mount bind),將塊設(shè)備綁定到容器目錄(targetpath類似這種:/var/lib/kubelet/pods/9c5aa371-e5a7-4b67-8795-ec7013811363/volumes/kubernetes.io~csi/pvc-bcfe33ed-e822-4b0e-954a-0f5c0468525e/mount)
??options?:=?[]string{"bind"}
??if?err?:=?mount.New("").Mount(loopDevice,?targetPath,?"",?options);?err?!=?nil?{
???return?nil,?status.Error(codes.Internal,?fmt.Sprintf("failed?to?mount?block?device:?%s?at?%s:?%v",?loopDevice,?targetPath,?err))
??}
????//對應(yīng)pvc.volumeBind字段是filesystem的情況
?}?else?if?req.GetVolumeCapability().GetMount()?!=?nil?{
??//....這里省略,因為跟上面類似也是mount?bind過程
?}
?return?&csi.NodePublishVolumeResponse{},?nil
}
####NodeUnpublishVolume
NodeUnpublishVolume過程就是unmount過程,如下:
pkg/hostpath/nodeserver.go#L191
func?(ns?*nodeServer)?NodeUnpublishVolume(ctx?context.Context,?req?*csi.NodeUnpublishVolumeRequest)?(*csi.NodeUnpublishVolumeResponse,?error)?{
?//?Check?arguments
?if?len(req.GetVolumeId())?==?0?{
??return?nil,?status.Error(codes.InvalidArgument,?"Volume?ID?missing?in?request")
?}
?if?len(req.GetTargetPath())?==?0?{
??return?nil,?status.Error(codes.InvalidArgument,?"Target?path?missing?in?request")
?}
?targetPath?:=?req.GetTargetPath()
?volumeID?:=?req.GetVolumeId()
?vol,?err?:=?getVolumeByID(volumeID)
?if?err?!=?nil?{
??return?nil,?status.Error(codes.NotFound,?err.Error())
?}
?//?Unmount?only?if?the?target?path?is?really?a?mount?point.
?if?notMnt,?err?:=?mount.IsNotMountPoint(mount.New(""),?targetPath);?err?!=?nil?{
??if?!os.IsNotExist(err)?{
???return?nil,?status.Error(codes.Internal,?err.Error())
??}
?}?else?if?!notMnt?{
??//?Unmounting?the?image?or?filesystem.
??err?=?mount.New("").Unmount(targetPath)
??if?err?!=?nil?{
???return?nil,?status.Error(codes.Internal,?err.Error())
??}
?}
?//?Delete?the?mount?point.
?//?Does?not?return?error?for?non-existent?path,?repeated?calls?OK?for?idempotency.
?if?err?=?os.RemoveAll(targetPath);?err?!=?nil?{
??return?nil,?status.Error(codes.Internal,?err.Error())
?}
?glog.V(4).Infof("hostpath:?volume?%s?has?been?unpublished.",?targetPath)
?if?vol.Ephemeral?{
??glog.V(4).Infof("deleting?volume?%s",?volumeID)
??if?err?:=?deleteHostpathVolume(volumeID);?err?!=?nil?&&?!os.IsNotExist(err)?{
???return?nil,?status.Error(codes.Internal,?fmt.Sprintf("failed?to?delete?volume:?%s",?err))
??}
?}
?return?&csi.NodeUnpublishVolumeResponse{},?nil
}
啟動grpc server
pkg/hostpath/hostpath.go#L164
func?(hp?*hostPath)?Run()?{
?//?Create?GRPC?servers
?hp.ids?=?NewIdentityServer(hp.name,?hp.version)
?hp.ns?=?NewNodeServer(hp.nodeID,?hp.ephemeral,?hp.maxVolumesPerNode)
?hp.cs?=?NewControllerServer(hp.ephemeral,?hp.nodeID)
??
?s?:=?NewNonBlockingGRPCServer()
?s.Start(hp.endpoint,?hp.ids,?hp.cs,?hp.ns)
?s.Wait()
}
##測試
我們可以通過csc工具來進(jìn)行g(shù)rpc接口的測試:
$?GO111MODULE=off?go?get?-u?github.com/rexray/gocsi/csc
Get plugin info
$?csc?identity?plugin-info?--endpoint?tcp://127.0.0.1:10000
"csi-hostpath"??"0.1.0"
Create a volume
$?csc?controller?new?--endpoint?tcp://127.0.0.1:10000?--cap?1,block?CSIVolumeName
CSIVolumeID
Delete a volume
$?csc?controller?del?--endpoint?tcp://127.0.0.1:10000?CSIVolumeID
CSIVolumeID
Validate volume capabilities
$?csc?controller?validate-volume-capabilities?--endpoint?tcp://127.0.0.1:10000?--cap?1,block?CSIVolumeID
CSIVolumeID??true
NodePublish a volume
$?csc?node?publish?--endpoint?tcp://127.0.0.1:10000?--cap?1,block?--target-path?/mnt/hostpath?CSIVolumeID
CSIVolumeID
NodeUnpublish a volume
$?csc?node?unpublish?--endpoint?tcp://127.0.0.1:10000?--target-path?/mnt/hostpath?CSIVolumeID
CSIVolumeID
Get Nodeinfo
$?csc?node?get-info?--endpoint?tcp://127.0.0.1:10000
CSINode
部署
從上一篇文章中我們可以看到,CSI真正運行起來,其實還需要一些官方提供的組件進(jìn)行配合,比如node-driver-registrar,csi-provision,csi-attacher,我們將這些container作為我們的sidecar容器,通過volume共享socket連接,方便調(diào)用,部署在一起。
我們把服務(wù)分為兩個部分:
controller :以Deployment或者Statefulset方式部署,通過leader selector,控制只有一個在工作。 node:以DaemonSet方式部署,在每個節(jié)點上都調(diào)度
hostpath因為只有在單個節(jié)點上測試用,所以它的都使用了Statefulset,因為只是測試。
在生產(chǎn)部署的話可以參考csi-driver-nfs 服務(wù)的部署,這個服務(wù)比較完整。
https://github.com/kubernetes-csi/csi-driver-nfs/blob/master/deploy/csi-nfs-node.yaml https://github.com/kubernetes-csi/csi-driver-nfs/blob/master/deploy/csi-nfs-controller.yaml
當(dāng)然還有一些rbac,CSIDriver的創(chuàng)建,這里就不貼出來了。
總結(jié)
回顧下整個組件是怎么協(xié)調(diào)工作的:
csi-provisioner組件監(jiān)聽pvc的創(chuàng)建,從而通過 CSI socket 創(chuàng)建 CreateVolumeRequest 請求至CreateVolume方法csi-provisioner創(chuàng)建 PV 以及更新 PVC狀態(tài)至 ?bound ,從而由 controller-manager創(chuàng)建VolumeAttachment對象csi-attacher監(jiān)聽VolumeAttachments對象創(chuàng)建,從而調(diào)用 ControllerPublishVolume 方法。kubelet一直都在等待volume attach, 從而調(diào)用 NodeStageVolume (主要做格式化以及mount到節(jié)點上一個全局目錄) 方法 - 這一步可選CSI Driver在 在 NodeStageVolume 方法中將volumemount到 /var/lib/kubelet/plugins/kubernetes.io/csi/pv/這個目錄并返回給kubelet - 這一步可選/globalmount kubelet調(diào)用NodePublishVolume (掛載到pod目錄通過mount bind)CSI Driver相應(yīng) NodePublishVolume 請求,將volume掛載到pod目錄 /var/lib/kubelet/pods//volumes/[kubernetes.io](http://kubernetes.io/)~csi/ /mount 最后,kubelet啟動容器
參考
https://medium.com/velotio-perspectives/kubernetes-csi-in-action-explained-with-features-and-use-cases-4f966b910774
https://kubernetes-csi.github.io/docs/developing.html
?點擊屏末?|?閱讀原文?|?即刻學(xué)習(xí)