聚合国内IT技术精华文章,分享IT技术精华,帮助IT从业人士成长

Docker registry 定制实例 – 集成您自己的镜像存储库

2016-07-30 15:05 浏览: 1368079 次 我要评论(0 条) 字号:

IBM Bluemix 点击按钮,开始云上的开发!

Docker registry 定制实例 – 集成您自己的镜像存储库

Docker registry 是 Docker 的镜像存储库。本文结合实际案例,对 Docker registry 的存储驱动拓展进行了讨论。阅读本文后,读者将能够结合商业需求,开发出符合自身需求的镜像存储库。

鲁 林, 软件工程师, IBM

鲁林,IBM 软件开发工程师,主要从事 Asset Manager 可复用资产管理库集成开发。在IBM developerWorks 上发表过的文章有《基于Chef自动化框架的FVT环境配置部署》、《使用Asset Manager Eclipse客户端拓展资产数据源实战》。



廖 晓娜, 软件测试工程师, IBM

廖晓娜是一名 IBM 从事全球化测试的工程师,具有丰富的全球化测试经验。



2016 年 7 月 28 日

本文对 Docker 的镜像存储库 Docker registry 的存储驱动拓展进行了讨论,全文分为三部分:

  • 第一部分:介绍了 Docker 组件,以及 Docker 组件中的存储解决方案 Docker registry。
  • 第二部分:对 Docker registry 进行了介绍。registry 在设计上实现了灵活拓展,通过 storage driver 与多种存储方案进行集成。
  • 第三部分:进入实战演练。我们将使用 IBM Asset Manager 作为 Docker 镜像存储后台,通过对 registry driver 进行拓展,集成 registry 和 Asset Manager。

由于本文中的开发实例的代码基于 golang,读者在继续阅读前,可以先参考 golang 语言相关文档,方便更快理解相关概念及阅读代码。

Docker 和 Docker registry

Docker——应用环境的搬运工人

作为一个平台化的容器构建和部署产品,Docker 允许用户将完整的应用环境,通过 DSL 构建成为只读的镜像文件。使用镜像文件,用户将能够在 Docker 所支持的不同的平台上迁移应用环境。同时借助于容器 (Container), Docker 能够使镜像中的应用在迁移环境中运行。诚如 Docker 这个名字(搬运工人)所代表的,通过 Docker,应用环境能够在开发环境中打包,被搬运至目标平台,完成开箱部署。

尽管无法取代,相较于虚拟机技术,Docker 的镜像不包含底层操作系统数据,在容量上相较于虚拟机镜像更为轻量,利于迁移。同时,Docker 的镜像运行于更为轻量的容器 (Container) 上,消耗的系统资源少,经常在同一台主机上能够运行多个 Docker 容器。

云计算的到来给予了 Docker 更大的舞台,通过 Docker 装箱的应用程序环境,能够无缝地从开发或测试环境迁移到 Bluemix 等云平台上,减少了开发运维的成本。

Docker registry——镜像存储部件

Docker 将我们的应用环境打包在镜像中,而 Docker registry 则是负责存储及分发镜像文件的部件。registry 为镜像的存储和管理提供了丰富的接口,用户通过 DSL 配置脚本,能够对 registry 进行定制。registry 中包含了一个消息系统,registry 能够通过该消息系统向用户发送相关事件的通知。目前 registry 的主版本为 V2。伴随 V2,registry 提供了一套 REST 风格的 HTTP API 用于客户端与 registry 进行交互,新的接口实现了对新版 Docker 镜像文件 (包含 manifest) 的支持,以及断点续传等功能。

registry 存储后台和 storage Driver

作为镜像的存储和分发部件,registry 通过驱动接口 (driver interface) 实现镜像文件在不同文件系统中的存储,不同的文件系统只需要实现 registry 的驱动接口所规定的方法,便能作为镜像文件的存储后台。这种机制使得 Docker 用户在配置 registry 能够在不同的存储方案之间进行选择,以满足自身对 Docker 镜像文件存储的需求。由于任何能够与驱动接口进行集成的存储方案都能作为 Docker 镜像存储后台,目前 registry 支持多种存储驱动,除了官方支持的驱动之外,还有由社区贡献的多种存储驱动(包括 S3, azure, Ceph, aliyun)用于集成更多的存储方案。

Registry 设计上的可拓展使得用户结合自身情况,开发自己的存储后台成为可能,我们接下来从 storage driver 的接口入手,了解实现存储驱动的细节。


Registry Storage API

本着高可移植的原则和 Docker engine 的后台可支持多文件系统类型。Docker registry 通过接口的方式允许 registry 使用多类型的存储后台用于存储 Docker 镜像文件。

Dokcer 镜像结构——registry 的存储内容

Docker 镜像是若干联合文件系统 (UnionFS) 层的叠加,UnionFS 允许不同层次上的文件和文件夹相互叠加,对外组成一个完整的文件系统。Docker 镜像也正是借助于这种特性,允许 Docker 用户叠加多层镜像来构建镜像文件。基本上,由于 Docker 镜像由层级文件组成,registry 中所存储的主要内容,也就是这些层级文件。

registry 除了存储层级文件之外,还需要序列化镜像文件的元数据。因为镜像是由层级文件叠加而成,指定构成一个镜像的层级文件信息,也需要保存在 registry 中,指导镜像的存储和获取。在 registry 的存储目录下,能够找到两个文件夹:一个是 blobs,用于存储层级文件;另外一个是 repositories,以索引的方式保存了 registry 中镜像的元数据。

下图是使用文件系统作为存储驱动的 docker registry 的文件结构,可以看到,路径"V2"表示该 registry 使用的是 V2 版本的规范。在路径"registry/v2"下,有两个子目录"blobs"和"repositories",分别用于存放文件以及镜像文件信息。“repositories”路径下的"busybox"是镜像文件的名字,而"blobs"下的"sha256"代表了文件上传是使用的 hash 方法,registry 通过 hash 值,能够实现镜像文件层复用的功能。

图 1. 文件系统中 registry 的层级结构
图 1. 文件系统中 registry 的层级结构

了解以上内容已经足够用户进行 registry 的存储驱动定制。registry 的存储协议也在不断完善,在新版本 (V2) 中发生了变化,新的 registry 协议主要包含了对于镜像文件的认证,想要进一步了解这些变化和 registry 存储的文件标准可以参考"docker/docker#8093"。

StorageDriver 接口

为了实现定制的存储驱动,我们需要继承由 registry 开放出的 driver 类型接口 (Interface)。接口 (Interface) 是 Golang 语言中的继承机制,同面向对象语言,如 Java 中的接口相比,golang 的接口机制更为灵活。registry 通过接口将镜像存储的具体实现交付给具体的存储驱动。我们需要继承 registry 中定义的类型为 StorageDriver 的接口,位于:

$GOPATHsrcgithub.comdockerdistributionregistrystoragedriverstoragedriver.go。
// StorageDriver
//registry 存储驱动接口,通过实现接口中定义的方法,实现用于类文件系统的操作方法
type StorageDriver interface {
//Name 方法用于返回一个具有可读性的关于存储驱动的描述信息,主要用于调试和报错
//一种实现是返回存储驱动的名字
Name() string

//GetContent 方法用于获取 registry 中指定路径下的文件内容,通过字节码返回,
//该接口用于执行 docker pull 命令时镜像文件层的下载
GetContent(ctx context.Context, path string) ([]byte, error)
//PutContent 方法用于向 registry 文件系统下的指定路径写入数据
PutContent(ctx context.Context, path string, content []byte) error
//ReadStream 方法实现了文件的随机读取功能,读取指定路径下指定偏移的内容,通过实现 ReadCloser 接口的对象返回
//可用于实现继续被中断的镜像文件下载过程
ReadStream(ctx context.Context, path string, offset int64) (io.ReadCloser, error)

//WriteStream 方法实现了文件的随机写功能,存储驱动实现向指定路径下,指定偏移量的文件写入的功能。
//可用于实现继续中断的镜像文件上传过程
WriteStream(ctx context.Context, path string, offset int64, reader io.Reader) (nn int64, err error)

//Stat 方法用户返回文件的大小和串讲时间,该方法的实现返回一个包含指定路径的文件大小以及创建时间的 FileInfo 实例
Stat(ctx context.Context, path string) (FileInfo, error)

// List 方法用于查询当前路径下的文件列表,该方法的实现返回一个字符串数组,其中包含了所查询路径下的所有文件和目录
List(ctx context.Context, path string) ([]string, error)

//Move 方法实现了 registry 中文件的移动,该方法的实现将源路径移动到目标路径下。
//在现实时,可以使用拷贝源目录至目标路径+删除原路径的方法
Move(ctx context.Context, sourcePath string, destPath string) error

//Delete 方法实现了迭代删除指定目录的功能,效果类似于操作系统中的 rm -r 命令
Delete(ctx context.Context, path string) error

//URLFor 是一个可选的方法,在实现驱动接口时通过该方法,可以将指定路径映射为其他路径
//我们也可以在其他函数中实现类似的功能
URLFor(ctx context.Context, path string, options map[string]interface{}) (string, error)
}

以上便是我们理论上需要实现的所有方法,可以发现 registry 通过这些方法和存储后台的交互,与我们一般和文件系统交互时用到的方法几乎无异。我们接着要做的,是结合选定的存储后台,考虑如何实现上述存储驱动的接口方法。

集成 IBM Asset Manager 的 storage driver

Rational Asset Manager (RAM) 是一个可定制的、基于角色的、用于存储已发布资产的库。可以与各种硬件和操作系统平台协作,并提供富客户端 (包括 RESTful services)。RAM 通过有序的层次结构对资产进行管理,结合丰富可定制的生命周期策略,以满足不同客户对于可复用资源的管理和利用。

本文将使用 RAM 作为 Docker registry 的存储后台,编写连接 RAM 和 Docker registry 的存储驱动。存储后台的考量千差万别,本文为了演示目的选择 RAM,用户可以结合各自的需求选择适合的存储后台,或者考虑直接使用 registry 中已经集成的存储驱动,从实用角度上看,它们更为贴合 registry。

RAM 参考 Open Services for Lifecycle Collaboration (OSLC) 中资产管理的标准 (asset management specification),实现了一套 RESTful 的 API。我们将通过这套已经定义的 OSLC API 实现客户端和 RAM 的交互, 如图 2 所示。

图 2.RAM 客户端和 RAM 服务器 (storage)

如此,我们在实现 Storage driver 接口时,通过调用 RAM client 端的方法和对象,就能够组织要发送给 RAM 存储后台的数据和请求,打通 registry 和 RAM storage,如图 3 所示。为了方便理解,图中将存储驱动和 registry 分为两个部件。

图 3.Docker registry 通过 RAM storage driver 和 RAM 存储后台交互

RAM 管理的对象是资产 (Asset),使用 RAM 作为存储后台,我们需要在 registry 的存储对象(镜像文件层、文件摘要等)和资产附件之间映射关系。RAM 中,资产的附件 (Artifact) 支持层级结构,因此我们可以直接使用一个资产来代表一个 registry 的文件系统。本文将直接使用指定资产作为 registry 的存储库,指定资产信息的操作通过 registry 中的配置文件进行。

至此,我们可以开始编写代码, 实现 RAM client 的功能,这之后 RAM client 的功能将作为一个包被随后实现的 storage driver 调用。下面的代码清单给出了 RAM client 中部分接口描述。

点击查看代码清单

//初始化新的 RAM Session, 并返回相应的指。RAM session 负责与远端的 RAM 服务器沟通,
//相应的初始化参数将通过 registry 配置文件设置
func NewRAMSession(_url, _uid, _pwd string) (*RAMSession, error)

//向 RAM 服务器提交 PUT 请求, 用于提交新资产附件,对应于 storage driver 的//PutContent,WriteStream 接口
func (ram *RAMSession) PutArtifactContent(id *AssetIdentification, subPath string, header map[string]string, rc io.ReadCloser) (int, error)

//向 RAM 服务器提交 HEAD 请求, 可用于判断所请求的附件是否存在
func (ram *RAMSession) HeadArtifactContent(id *AssetIdentification, subPath string, header map[string]string) (int, *http.Header, error)

//向 RAM 服务器提交 GET 请求, 用户下载附件。若附件存在,将通过 ReadCloser 返回附件。方法对应于
//storage driver 的 GetContent,ReadStream 接口
func (ram *RAMSession) GetArtifactContent(id *AssetIdentification, subPath string, header map[string]string) (io.ReadCloser, error)

//向 RAM 服务器提交 POST 请求, 用于更新资产附件,对应于 storage driver 的//PutContent,WriteStream,Move 接口
func (ram *RAMSession) PostArtifactContent(id *AssetIdentification, subPath string, header map[string]string, rc io.ReadCloser) (int, error)

//向 RAM 服务器提交 DELETE 请求, 用于删除资产附件,对应于 storage driver 的
//Delete,Move 接口
func (ram *RAMSession) DeleteArtifactContent(id *AssetIdentification, subPath string, header map[string]string) (int, error)

//向 RAM 服务器请求指定资产的元数据,用于 storage driver 的初始化
func (ram *RAMSession) GetAsset(asset_id *AssetIdentification) (interface{}, error)

至此,我们实现了可用于 RAM 远端与 registry 交互的 API。基于这个 API,在下一部分中我们描述 storage driver 的实现。


实例演示——RAM registry driver

搭建开发环境

正式开发之前,我们首先需要搭建 registry 的开发环境。目前的版本 registry 代码完全由 golang 实现,并且项目改名为 distribution。Distribution 项目中包含了 Docker registry 在内的其他组件,更为详细的信息可以参考 Docker distribution in GitHub

请读者参考并结合文档中的建立开发环境的指引。由于过程相对简单,在此只给出主要流程和需要注意的地方,其他步骤可以参考文档。本文中所涉及的代码编写和案例开发基于 windows7 操作系统环境。

  1. Golang 开发环境:

    registry 基于 Golang 开发,因此我们首先需要安装配置 Golang 编译器以及类库,请参考 Golang 的文档中关于安装的文档完成配置。

  2. IDE:

    集成开发环境的选择因人而异,没有特别的要求或限制,本文中使用 ntelliJ IDEA Community Edition 14.1.5 + Golang 插件。

  3. 导入 registry 源代码:

    按照 registry 文档的相关章节,使用 go 命令导入 registry 源代码。导入完成之后,整个项目位于%GOAPTH%/src/github.com/docker/distribution/路径。

  4. 修改配置文件:

    Registry 的配置通过 yaml 配置文件完成。源代码中已经为我们提供了一个例子,位于/distribution/cmd/registry/config-example.yml。文件中指定的存储驱动为文件系统 (filesystem),Windows 用户在直接使用该配置文件之前,需要修改 storage/filesystem/rootdirectory 的值,本文使用 C:/ram_registry。

上述步骤之后,我们得到了一个完整的开发环境。试着运行一下,在 distribution 目录下打开命令行,输入命令启动 registry:

> go build
> %GOPATH%binregistry.exe .cmdregistryconfig-example.yml

更为推荐的方法是在 IDE 中建立一个运行配置,方便以后直接使用。

控制台出现如下日志输出:

点击查看代码清单

……
time="2016-04-13T11:01:19+08:00" level=info msg="Starting upload purge in 13m0s" go.version=go1.5.1 instance.id=3a4880b3-6430-4ea3-9caf-c9d9da709422 version="v2.1.0+unknown"
time="2016-04-13T11:01:19+08:00" level=info msg="listening on [::]:5000, tls" go.version=go1.5.1 instance.id=3a4880b3-6430-4ea3-9caf-c9d9da709422 version="v2.1.0+unknown"

至此,storage driver 开发环境完成了配置。

RAM storage driver

registry storage driver 的代码位于 distribution/registry/driver 路径下,我们在该路径下可以查看 storagedriver.go 中存储驱动接口的定义以及已经实现的存储驱动。我们要创建的新驱动的代码也位于该路径之下,建立文件/ram/ram.go。

出于演示目的,在 ram storage driver 的实现上,使用了一种较简单的方式。图 4 描述了 RAM storage driver 与 RAM repository 交互方法。RAM storage driver 使用本地文件系统作为缓存,这种设计的便利之处在于:避免了 storage driver 频繁访问 RAM repository,减少开销,提高存储效率。此外,使用本地文件系统作为缓存,在某些 storage driver 接口的实现上,我们可以直接借鉴已有的实现(如 filesystem storage driver)。当然缺点也是明显的,如此一来,镜像文件需要在本地文件系统和 RAM repository 之间进行同步,会造成额外的开销。

图 4.RAM storage driver 与 RAM repository 交互方法

Storage driver 代码

关于 storage driver 的部分实现代码以及注释如下,完整的代码实现请参考附件。附件中包含RAM storage driver 以及访问RAM所依赖的客户端代码。

点击查看代码清单

package ram

//省略 import 部分......

//存储驱动名称
const driverName = "ram"

//构造函数,注册存储驱动的方法工厂
func init(){
factory.Register(driverName, &ramDriverFactory{})
}

//驱动工厂结构体
type ramDriverFactory struct{}

//RAM 存储驱动工厂的 Create() 方法实现,方法返回 RAM 存储驱动
func (factory *ramDriverFactory) Create(parameters map[string]interface{}) (storagedriver.StorageDriver, error) {
return FromParameters(parameters), nil
}

//RAM 存储驱动的结构体,包含了用于和 RAM repository 交互的 RAMSession 指针,
//包含资产信息的 AssetIdentification 指针,远程 RAM repository 的 URL, 以及
//本地附件缓存的目录
type driver struct {
ram_url string
session *ram_data.RAMSession
asset *ram_data.AssetIdentification
rootDir string
}

type baseEmbed struct {
base.Base
}

//Driver 结构体,内部包装了 driver 结构体
type Driver struct {
baseEmbed
}

//FromParamters 方法通过 regsitry 配置文件的信息初始化存储驱动的成员
func FromParameters(parameters map[string]interface{}) *Driver {
var ram_url_str, uid_str, pwd_str, guid_str, version_str, rootDir_str string
if parameters != nil {
ram_url, ok := parameters["ram_repo_url"]
if ok {
ram_url_str = fmt.Sprint(ram_url)
}
//代码省略……
return New(ram_url_str, uid_str, pwd_str, guid_str, version_str, rootDir_str)
}
return nil
}

//RAM storage driver 的构造函数
func New(_ram_url, _uid, _pwd, guid, version, _rootDir string) *Driver {
_session, _ := ram_data.NewRAMSession(_ram_url, _uid, _pwd)
return &Driver{
baseEmbed: baseEmbed{
Base: base.Base{
StorageDriver: &driver{
ram_url: _ram_url,
session: _session,
asset: ram_data.NewAssetId(guid, version),
rootDir: _rootDir,
},
},
},
}
}

//代码省略……

//RAMReaderCloser 结构体, 包装了 byte 数组
type RAMReaderCloser struct {
reader *bytes.Reader
}

func (ramrc *RAMReaderCloser) Read(p []byte) (n int, err error){
return ramrc.reader.Read(p)
}

func (ramrc *RAMReaderCloser) Close() (err error){
return nil
}

//PutContent 方法实现, 通过 ReadStream 方法实现
func (d *driver) PutContent(ctx context.Context, subPath string, contents []byte) error {
if _, err := d.WriteStream(ctx, subPath, 0, bytes.NewReader(contents)); err != nil {
return err
}
return os.Truncate(d.fullPath(subPath), int64(len(contents)))
}

//WriteStream 方法实现
func (d *driver) WriteStream(ctx context.Context, subPath string, offset int64, reader io.Reader) (nn int64, err error) {
//省略代码……
return nn, err
}

//GetContent 方法实现, 通过 ReadStream 方法实现
func (d *driver) GetContent(ctx context.Context, path string) ([]byte, error) {
rc, err := d.ReadStream(ctx, path, 0)
//省略代码……
return p, nil
}

//内部函数,检测指定路径是否存在本地文件系统上
func (d *driver) exists(path string) (bool, error) {
//省略代码……
return true, err
}

//ReadStream 方法实现
func (d *driver) ReadStream(ctx context.Context, subPath string, offset int64) (io.ReadCloser, error) {
//省略代码……
return file, nil
}

//Stat 方法实现,仅返回本地文件系统中的文件信息
func (d *driver) Stat(ctx context.Context, subPath string) (storagedriver.FileInfo, error) {
//省略代码……
return fileInfo{
path: subPath,
FileInfo: fi,
}, nil
}

//List 方法实现,仅返回本地文件系统中的文件列表
func (d *driver) List(ctx context.Context, subPath string) ([]string, error) {
//省略代码……
return keys, nil
}

//Move 方法实现,在本地缓存文件上进行移动。RAM repository 上实现 1. 使用 sourcePath 更新//destPath;2. 删除远端的 sourcePath
func (d *driver) Move(ctx context.Context, sourcePath string, destPath string) error {
//省略代码……
}

//本地文件系统上的文件移动函数 move,被 Move 方法调用
func (d *driver) moveInfs(ctx context.Context, sourcePath string, destPath string) error {
//省略代码……
}

//Delete 方法实现,包括本地缓存以及 RAM 远端保存的附件
func (d *driver) Delete(ctx context.Context, subPath string) error {
//省略代码……
}

//可选,本例不实现
func (d *driver) URLFor(ctx context.Context, path string, options map[string]interface{}) (string, error) {
fmt.Printf("URLFor: path %sn", path)
return "", storagedriver.ErrUnsupportedMethod
}

测试

启动 registry 之前,我们需要修改配置文件,使 registry 将 Storage Driver 切换到 ram storage driver。我们修改 config-example.yml 的 storage 部分,如下:

storage:
filesystem:
ram_repo_url: http://{RAM CONTEXT}/ram.ws
ram_user_id: {userid}
ram_password: {password}
asset_guid: 05D870A0-81AE-232B-826B-10465A1A558A
asset_version: "1.0"
rootdirectory: C:/ram_registry

此外,为了触发 ram storage driver 的初始化函数,我们在/distribution/cmd/registry/main.go 中引用 ram 包:

_ "github.com/docker/distribution/registry/storage/driver/ram"

测试环境中,我们使用运行在 Ubuntu 上的 Docker engine,对运行在开发环境上的 registry 进行 pull 和 push 测试。Docker engine 在和 registry 交互时,需要通过 TLS 进行认证,本次测试用例使用自认证的证书,具体的配置步骤请参考文档中的描述

运行 docker push 命令,上传镜像至 registry,Docker engine 输出如图 5。镜像文件 busybox 被上传到 registry。

图 5. Docker engine 在 docker pull 时的输出
图 5. Docker engine 在 docker pull 时的输出

Registry 在 docker push 时的部分日志:

……
POST: /docker/registry/v2/repositories/busybox/_manifests/revisions/sha256/72df8
7de1d513d6e3d8bc8164cce5d2b77c535f7e280b7411f911ebe032949c7/signatures/sha256/5f
fbec9f30ee352c2f332bf38c80ca3cb2aebe6364d228df1491ec20647136a7/link
POST: http://9.110.74.108:9080/ram.ws7.5.2.2/oslc/assets/05D870A0-81AE-232B-826B
-10465A1A558A/1.0/artifacts
Resp: 201
PutContent: subpath /docker/registry/v2/repositories/busybox/_manifests/tags/lat
est/index/sha256/72df87de1d513d6e3d8bc8164cce5d2b77c535f7e280b7411f911ebe032949c
7/link
WriteStream: path /docker/registry/v2/repositories/busybox/_manifests/tags/lates
t/index/sha256/72df87de1d513d6e3d8bc8164cce5d2b77c535f7e280b7411f911ebe032949c7/
link, offsert: 0
……

接下来是 Docker pull 测试,在删除 Docker engine 本地的 busybox 镜像文件之后,我们运行 docker pull 命令,docker engine 输出如下图。

图 6. Docker engine 在 docker pull 时的输出
图 6. Docker engine 在 docker pull 时的输出
Registry 在 docker pull 时的部分日志:
……
GetContent: path /docker/registry/v2/repositories/busybox/_layers/sha256/385e281
300cc6d88bdd155e0931fbdfbb1801c2b0265340a40481ee2b733ae66/link
ReadStream: path /docker/registry/v2/repositories/busybox/_layers/sha256/385e281
300cc6d88bdd155e0931fbdfbb1801c2b0265340a40481ee2b733ae66/link
Stat: subpath /docker/registry/v2/blobs/sha256/a3/a3ed95caeb02ffe68cdd9fd8440668
0ae93d633cb16422d00e8a7c22955b46d4/data
……

访问 RAM 上的资产附件,registry 文件存在于 RAM 远端。

图 7.registry 远端
图 7.registry 远端

总结

在本文中,我们通过一个简单的实现展示了 registry storage driver 的开发流程。用户可以以此为基础,参照 distribution 项目中已实现的存储驱动进行开发。


下载

描述名字大小
示例代码ram.zip4k
示例代码ram_api_go.zip69k


网友评论已有0条评论, 我也要评论

发表评论

*

* (保密)

Ctrl+Enter 快捷回复