原创 吴就业 252 0 2023-07-15
本文为博主原创文章,未经博主允许不得转载。
本文链接:https://wujiuye.com/article/64338c7ff9b742b28653660ad2bef13c
作者:吴就业
链接:https://wujiuye.com/article/64338c7ff9b742b28653660ad2bef13c
来源:吴就业的网络日记
本文为博主原创文章,未经博主允许不得转载。
很多企业内部为了不与云厂商绑定,避免上云容易下云难的尴尬,以及企业内部可能也会做私有云,或者封装一个混合云平台,因此不能直接用云厂商提供的provider。
我们实现了基于内部混合云平台提供的基础设施接口,自研terraform provider,但由于前期投入人手不足,通过接口申请基础设施资源实际还是通过工单的方式由人工操作完成,尽管是“人工”智能,至少能够将申请基础设施这件事情代码化,并且能够通过代码记录下来,减少很多沟通成本。这也有利于业务随着公司战略跨地域迁移机房,以及异地多活的实现。
假设我们已经存在一个类似阿里云的混合云平台,并且为各种基础设施资源都提供了申请、删除、查询的http接口,现在我们需要编写实现terraform provider插件,能够通过编写HCL代码,执行terraform apply命令申请IaC资源、执行terraform destroy命令销毁IaC资源。
terraform cli是terraform客户端命令行工具,包括了terraform核心引擎。
mac上安装Terraform CLI:
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
验证是否安装成功:
terraform version
以VPC资源为例,假设申请vpc资源,我们需要提供ip总数入参,而出参为vpc的id。
假设vpc的创建、删除、查询接口如下:
POST /mycloud/vpc/create HTTP/1.1
Authorization: xxxxxxxxx
Host: mycloud.wujiuye.com
Content-Type: application/json
{"name":"test-vpc","ipCapacity":16}
// 响应
{"id":"12345","name":"test-vpc","ipCapacity":16}
GET /mycloud/vpc/12345 HTTP/1.1
Authorization: xxxxxxxxx
Host: mycloud.wujiuye.com
// 响应
{"id":"12345","name":"test-vpc","ipCapacity":16}
GET /mycloud/vpc/query?name=test HTTP/1.1
Authorization: xxxxxxxxx
Host: mycloud.wujiuye.com
// 响应
[{"id":"12345","name":"test-vpc1","ipCapacity":16},
{"id":"12346","name":"test-vpc2","ipCapacity":16}]
DELETE /mycloud/vpc/12345 HTTP/1.1
Authorization: xxxxxxxxx
Host: mycloud.wujiuye.com
根据terraform官方文档:https://developer.hashicorp.com/terraform/plugin/framework/providers
,开发一个terraform-provider。
首先下载模版项目:https://github.com/hashicorp/terraform-provider-scaffolding-framework
。
将项目名改成自己的provider项目名,例如terraform-provider-mycloud,同时还需要修改go.mod文件的module名(这里是:github.com/wujiuye/terraform-provider-mycloud
),以及main.go相对应的import。
go generate
命令来自动生成文档,当代码有变更后,应该执行一次该命令以确保文档保持更新。go generate
命令根据这个目录下的data-source和resource来生成使用文档。我们参照example的代码去实现我们自己的provider、resource和data-source,实现完后再删除example代码。现在我们需要先了解example代码。
每个提供商必须实现一个gRPC服务,并导出所有的resource和data-source,由terraform核心引擎通过gRPC调用管理resource和data-source。 main函数通过providerserver.Serve方法启动一个gRPC服务,第二个参数是传递一个创建实现provider.Provider接口的实例的方法。
providerserver.Serve(context.Background(), provider.New(version), opts)
provider.go声明ScaffoldingProvider类,实现了provider.Provider接口。 provider.Provider接口定义了五个方法:
func (p *ScaffoldingProvider) Metadata(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) {
resp.TypeName = "scaffolding"
}
type ScaffoldingProviderModel struct {
Endpoint types.String `tfsdk:"endpoint"`
}
func (p *ScaffoldingProvider) Schema(ctx context.Context, req provider.SchemaRequest, resp *provider.SchemaResponse) {
resp.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{
"endpoint": schema.StringAttribute{
MarkdownDescription: "Example provider attribute",
Optional: true,
},
},
}
}
func (p *ScaffoldingProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) {
var data ScaffoldingProviderModel
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
client := http.DefaultClient
resp.DataSourceData = client
resp.ResourceData = client
}
func (p *ScaffoldingProvider) Resources(ctx context.Context) []func() resource.Resource {
return []func() resource.Resource{
NewExampleResource,
}
}
func (p *ScaffoldingProvider) DataSources(ctx context.Context) []func() datasource.DataSource {
return []func() datasource.DataSource{
NewExampleDataSource,
}
}
一个demo 基础设施资源的resource实现。 一个resource需要实现resource.ResourceWithConfigure接口,该接口定义了如下方法。
func (r *ExampleResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_example"
}
func (r *ExampleResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
// This description is used by the documentation generator and the language server.
MarkdownDescription: "Example resource",
Attributes: map[string]schema.Attribute{
"configurable_attribute": schema.StringAttribute{
MarkdownDescription: "Example configurable attribute",
Optional: true,
},
"defaulted": schema.StringAttribute{
MarkdownDescription: "Example configurable attribute with default value",
Optional: true,
Computed: true,
Default: stringdefault.StaticString("example value when not configured"),
},
"id": schema.StringAttribute{
Computed: true,
MarkdownDescription: "Example identifier",
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
},
}
}
func (r *ExampleResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
client, _ := req.ProviderData.(*http.Client)
r.client = client
}
一个demo 基础设施资源的data-source实现。 一个data-source需要实现datasource.DataSourceWithConfigure接口,该接口定义了如下方法。
func (d *ExampleDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_example"
}
func (d *ExampleDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) {
resp.Schema = schema.Schema{
// This description is used by the documentation generator and the language server.
MarkdownDescription: "Example data source",
Attributes: map[string]schema.Attribute{
"configurable_attribute": schema.StringAttribute{
MarkdownDescription: "Example configurable attribute",
Optional: true,
},
"id": schema.StringAttribute{
MarkdownDescription: "Example identifier",
Computed: true,
},
},
}
}
func (r *ExampleResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
client, _ := req.ProviderData.(*http.Client)
r.client = client
}
假设我们的私有云提供商名称为mycloud,访问mycloud的api需要提供api服务地址以及访问的token。
创建一个MyCloudProvider,代码如下。
type MyClient struct {
Endpoint string
Token string
}
var _ provider.Provider = &MyCloudProvider{}
type MyCloudProvider struct {
// version is set to the provider version on release, "dev" when the
// provider is built and ran locally, and "test" when running acceptance
// testing.
version string
}
type MyCloudProviderModel struct {
Endpoint types.String `tfsdk:"endpoint"`
Token types.String `tfsdk:"token"`
}
func (p *MyCloudProvider) Metadata(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) {
resp.TypeName = "mycloud"
resp.Version = p.version
}
func (p *MyCloudProvider) Schema(ctx context.Context, req provider.SchemaRequest, resp *provider.SchemaResponse) {
resp.Schema = schema.Schema{
Attributes: map[string]schema.Attribute{
"endpoint": schema.StringAttribute{
MarkdownDescription: "endpoint",
Required: true,
},
"token": schema.StringAttribute{
MarkdownDescription: "token",
Required: true,
},
},
}
}
func (p *MyCloudProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) {
var data MyCloudProviderModel
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
client := &MyClient{
Endpoint: data.Endpoint.ValueString(),
Token: data.Token.ValueString(),
}
resp.DataSourceData = client
resp.ResourceData = client
}
func (p *MyCloudProvider) Resources(ctx context.Context) []func() resource.Resource {
return []func() resource.Resource{
NewVpcResource,
}
}
func (p *MyCloudProvider) DataSources(ctx context.Context) []func() datasource.DataSource {
return []func() datasource.DataSource{
NewVpcDataSource,
}
}
func NewMyCloud(version string) func() provider.Provider {
return func() provider.Provider {
return &MyCloudProvider{
version: version,
}
}
}
然后修改main代码:
err := providerserver.Serve(context.Background(), provider.NewMyCloud(version), opts)
vpc-resource的Metadata、Schema、Configure的实现。
func NewVpcResource() resource.Resource {
return &VpcResource{}
}
type VpcResource struct {
client *MyClient
}
type VpcResourceModel struct {
Id types.String `tfsdk:"id"`
Name types.String `tfsdk:"name"`
IpCapacity types.Int64 `tfsdk:"ip_capacity"`
}
func (r *VpcResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_vpc"
}
func (r *VpcResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "vpc resource",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
MarkdownDescription: "Vpc identifier",
PlanModifiers: []planmodifier.String{
stringplanmodifier.UseStateForUnknown(),
},
},
"name": schema.StringAttribute{
Required: true,
MarkdownDescription: "Name",
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
},
"ip_capacity": schema.Int64Attribute{
Required: true,
MarkdownDescription: "ip capacity",
PlanModifiers: []planmodifier.Int64{
int64planmodifier.RequiresReplace(),
},
},
},
}
}
func (r *VpcResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
client, ok := req.ProviderData.(*MyClient)
if !ok {
resp.Diagnostics.AddError(
"Unexpected Resource Configure Type",
fmt.Sprintf("Expected *MyCliet, got: %T. Please report this issue to the provider developers.", req.ProviderData),
)
return
}
r.client = client
}
资源创建的实现:
拿到api的服务地址和访问token,发起http请求。
type VpcDto struct {
Id string `json:"id"`
Name string `json:"name"`
IpCapacity int64 `json:"ip_capacity"`
}
func (r *VpcResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var data *VpcResourceModel
// Read Terraform plan data into the model
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
vpc := &VpcDto{
Name: data.Name.ValueString(),
IpCapacity: data.IpCapacity.ValueInt64(),
}
vpcBytes, _ := json.Marshal(vpc)
// 发送post请求
httpReq, _ := http.NewRequest("POST",
fmt.Sprintf("%s/vpc/create", r.client.Endpoint),
bytes.NewReader(vpcBytes))
// 添加授权信息
httpReq.Header.Add("Authorization", r.client.Token)
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to create vpc, error: %s", err))
return
}
if httpResp.StatusCode != 200 {
resp.Diagnostics.AddError("Server Error", "status "+httpResp.Status)
return
}
// 读响应body
respBodyBytes, _ := io.ReadAll(httpResp.Body)
dto := &VpcDto{}
err = json.Unmarshal(respBodyBytes, dto)
if err != nil {
resp.Diagnostics.AddError("Server Error", fmt.Sprintf("unmarshal err:%s", err))
return
}
data.Id = types.StringValue(dto.Id)
data.Name = types.StringValue(dto.Name)
data.IpCapacity = types.Int64Value(dto.IpCapacity)
// Write logs using the tflog package
// Documentation: https://terraform.io/plugin/log
tflog.Trace(ctx, "created a resource")
// Save data into Terraform state
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
资源更新的实现:
func (r *VpcResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
// 暂时不考虑更新的情况。
}
资源的读取,根据id查询:
拿到api的服务地址和访问token,发起http请求。
func (r *VpcResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var data *VpcResourceModel
// Read Terraform prior state data into the model
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// 发送get请求
httpReq, _ := http.NewRequest("GET",
fmt.Sprintf("%s/vpc/%s", r.client.Endpoint, data.Id.ValueString()),
bytes.NewReader([]byte{}))
// 添加授权信息
httpReq.Header.Add("Authorization", r.client.Token)
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read vpc, got error: %s", err))
return
}
if httpResp.StatusCode != 200 {
resp.Diagnostics.AddError("Server Error", "status "+httpResp.Status)
return
}
// 读响应body
respBodyBytes, _ := io.ReadAll(httpResp.Body)
dto := &VpcDto{}
err = json.Unmarshal(respBodyBytes, dto)
if err != nil {
resp.Diagnostics.AddError("Server Error", fmt.Sprintf("unmarshal err:%s", err))
return
}
data.Name = types.StringValue(dto.Name)
data.IpCapacity = types.Int64Value(dto.IpCapacity)
// Save updated data into Terraform state
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
资源删除的实现:
拿到api的服务地址和访问token,发起http请求。
func (r *VpcResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var data *VpcResourceModel
// Read Terraform prior state data into the model
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// 发送del请求
delReq, _ := http.NewRequest("DELETE",
fmt.Sprintf("%s/vpc/%s", r.client.Endpoint, data.Id.ValueString()),
bytes.NewReader([]byte{}))
// 添加授权信息
delReq.Header.Add("Authorization", r.client.Token)
delResp, err := http.DefaultClient.Do(delReq)
if err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete vpc, delete error: %s", err))
return
}
if delResp.StatusCode != 200 {
resp.Diagnostics.AddError("Server Error", "status "+delResp.Status)
return
}
}
然后修改provider,注册vpc资源的resource的New函数:
func (p *MyCloudProvider) Resources(ctx context.Context) []func() resource.Resource {
return []func() resource.Resource{
NewVpcResource,
}
}
vpc-data-source的Metadata、Schema、Configure的实现。
func NewVpcDataSource() datasource.DataSource {
return &VpcDataSource{}
}
type VpcDataSource struct {
client *MyClient
}
type VpcDataSourceModel struct {
Name types.String `tfsdk:"name"`
List []VpcResourceModel `tfsdk:"list"`
}
func (d *VpcDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_vpc"
}
func (d *VpcDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) {
resp.Schema = schema.Schema{
MarkdownDescription: "Vpc data source",
Attributes: map[string]schema.Attribute{
"name": schema.StringAttribute{
MarkdownDescription: "Vpc name",
Optional: true,
},
"list": schema.ListNestedAttribute{
Computed: true,
NestedObject: schema.NestedAttributeObject{
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
MarkdownDescription: "Vpc identifier",
},
"name": schema.StringAttribute{
Required: true,
MarkdownDescription: "Name",
},
"ip_capacity": schema.Int64Attribute{
Required: true,
MarkdownDescription: "ip capacity",
},
},
},
},
},
}
}
func (d *VpcDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
client, ok := req.ProviderData.(*MyClient)
if !ok {
resp.Diagnostics.AddError(
"Unexpected Data Source Configure Type",
fmt.Sprintf("Expected *MyClient, got: %T. Please report this issue to the provider developers.", req.ProviderData),
)
return
}
d.client = client
}
资源查询的实现:
拿到api的服务地址和访问token,发起http请求。
func (d *VpcDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
var data VpcDataSourceModel
// Read Terraform configuration data into the model
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// 发送get请求
httpReq, _ := http.NewRequest("GET",
fmt.Sprintf("%s/vpc/query?name=%s", d.client.Endpoint, data.Name.ValueString()),
bytes.NewReader([]byte{}))
// 添加授权信息
httpReq.Header.Add("Authorization", d.client.Token)
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to query vpc, query error: %s", err))
return
}
if httpResp.StatusCode != 200 {
resp.Diagnostics.AddError("Server Error", "status "+httpResp.Status)
return
}
// 读响应body
respBodyBytes, _ := io.ReadAll(httpResp.Body)
dto := &[]VpcDto{}
err = json.Unmarshal(respBodyBytes, dto)
if err != nil {
resp.Diagnostics.AddError("Server Error", fmt.Sprintf("unmarshal err:%s", err))
return
}
data.List = []VpcResourceModel{}
for _, vpc := range *dto {
model := VpcResourceModel{}
model.Id = types.StringValue(vpc.Id)
model.Name = types.StringValue(vpc.Name)
model.IpCapacity = types.Int64Value(vpc.IpCapacity)
data.List = append(data.List, model)
}
// Write logs using the tflog package
// Documentation: https://terraform.io/plugin/log
tflog.Trace(ctx, "read a data source")
// Save data into Terraform state
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
然后修改provider,注册vpc资源的data-source的New函数。
func (p *MyCloudProvider) DataSources(ctx context.Context) []func() datasource.DataSource {
return []func() datasource.DataSource{
NewVpcDataSource,
}
}
开发完成后我们在项目跟目录下创建example/resources和example/datasource目录,在resources目录下创建resource示例代码和datasource目录下创建datasource示例代码。
编写resource示例如下:
terraform {
required_providers {
mycloud = {
source = "registry.terraform.io/wujiuye/mycloud"
}
}
}
provider "mycloud" {
endpoint = "http://127.0.0.1:8080"
token = "123456"
}
variable "name" {
type = string
default = "test-vpc"
}
variable "ip_capacity" {
type = number
default = 64
}
resource "mycloud_vpc" "example" {
name = var.name
ip_capacity = var.ip_capacity
}
output "id" {
value = mycloud_vpc.example.id
}
使用 go run . -debug
命令启动调试版的provider,或者在idea中配置程序参数--debug=true
,然后使用idea的run或debug启动。provider启动gRPC服务后会提供以下输出:
{"@level":"debug","@message":"plugin address","@timestamp":"2023-07-04T11:20:37.028907+08:00","address":"/var/folders/t5/6dn6b58j52d9jfcqxn_b0y_h0000gn/T/plugin3194206314","network":"unix"}
Provider started. To attach Terraform CLI, set the TF_REATTACH_PROVIDERS environment variable with the following:
TF_REATTACH_PROVIDERS='{"registry.terraform.io/wujiuye/mycloud":{"Protocol":"grpc","ProtocolVersion":6,"Pid":88617,"Test":true,"Addr":{"Network":"unix","String":"/var/folders/t5/6dn6b58j52d9jfcqxn_b0y_h0000gn/T/plugin3194206314"}}}'
复制其中环境变量的输出TF_REATTACH_PROVIDERS
这一整行,切换到example/resource/vpc目录,执行
TF_REATTACH_PROVIDERS='{"registry.terraform.io/wujiuye/mycloud":{"Protocol":"grpc","ProtocolVersion":6,"Pid":88617,"Test":true,"Addr":{"Network":"unix","String":"/var/folders/t5/6dn6b58j52d9jfcqxn_b0y_h0000gn/T/plugin3194206314"}}}' terraform apply
此时terraform会将对provider的请求重定向到我们的测试程序,这样就可以调试了。
编写datasource示例如下:
terraform {
required_providers {
mycloud = {
source = "registry.terraform.io/wujiuye/mycloud"
}
}
}
provider "mycloud" {
endpoint = "http://127.0.0.1:8080"
token = "123456"
}
data "mycloud_vpc" "example" {
name = "test"
}
output "vpc_id" {
value = data.mycloud_vpc.example.list[0].id
}
以同resource测试的方式,先go run . -debug
启动调试版的provider,拿到TF_REATTACH_PROVIDERS环境变量,再执行terraform apply命令。
声明:公众号、CSDN、掘金的曾用名:“Java艺术”,因此您可能看到一些早期的文章的图片有“Java艺术”的水印。
KubeVela是面向混合云环境的应用交付控制面,不与任何云产商绑定。KubeVela通过提供插件扩展机制,打通了应用交付涉及的基础设施即代码-terraform等能力。编写一个符合OAM模型的application.yaml就能实现将应用部署起来,包括申请基础设施。实现了声明式部署,且一次编写,到处部署。
“部署即代码”即用代码描述一个应用的部署计划。KubeVela就是实现这一目标的平台,让我们可以编写一个符合OAM模型的yaml来描述应用的部署。
Go sdk本地开发调试sdk依赖问题;关于复杂嵌套结构体的schema声明;状态死循环监听,以及terraform命令终止时如何终止死循环;资源创建接口的默认可选字段不填遇到的坑;HCL代码输入变量的复杂校验。
通常申请基础设施,我们需要向运维描述我们需要什么基础设施、什么规格,运维根据我们的描述去检查是否已经申请过这样的资源,有就会直接给我们使用基础设施的信息,没有再帮我们申请,然后告诉我们使用基础设施的信息,例如mysql的jdbc和用户名、密码。如果将描述代码化,基础设施的申请自动化,就能实现“基础设施即代码”。而terraform就是实现“将描述代码化”的工具软件。
在Job场景,如果Job达到backoffLimit还是失败,而且backoffLimit值很小,很快就重试完,没能及时的获取到容器的日记。而达到backoffLimit后,job的controller会把pod删掉,这种情况就没办法获取pod的日记了,导致无法得知job执行失败的真正原因,只能看到job给的错误:"Job has reached the specified backoff limit"。
kubebuilder使用helm代替kustomize;代码改了但似乎没生效-镜像拉取问题; 使用ConfigMap替代Apollo配置中心的最少改动方案;环境变量的注入以及传递;Kubebuilder单测跑不起来;Helm chart和finalizer特性冲突问题。
订阅
订阅新文章发布通知吧,不错过精彩内容!
输入邮箱,提交后我们会给您发送一封邮件,您需点击邮件中的链接完成订阅设置。