介绍 Jenkins Pipeline

介绍 Jenkins 流水线(Pipeline)

什么是 Jenkins 流水线

  • 用于实现持续交付流水线的一套插件
  • 覆盖应用构建、测试、交付和部署各阶段
  • Pipeline-as-Code:使用“领域特定语言(Domain Specfic Language, DSL)”描述流水线过程

定义流水线的方式

  • 通过 Blue Ocean 创建
  • 通过传统的用户界面
  • 在版本控制系统中手写一个 Jenkinsfile

Jenkinsfile 是一个文本文件,其定义了 Jenkins 的流水线。文本支持两种语法:声明式和脚本式。将 Jenkinsfile 放入项目源代码进行版本管理的好处:

  • 为所有分支和 PR 自动化创建流水线构建进程
  • 在流水线上进行代码审查和集成
  • 流水线审计跟踪
  • 流水线的单一可信数据源,可供项目内成员共同查看和编辑

流水线中的名词解释

Pipeline

流水线:整个持续交付的流水线。Pipeline 的代码定义了整个构建、测试、交付的流程。

pipeline 代码块是声明式语法中的关键部分。

Agent

代理:由 master 节点控制的一台机器或容器,负责执行任务。

Node

节点:一个 Node 就是一个执行 Pipeline 的 Jenkins 机器。

node 代码块是脚本式语法中的关键部分。

Stage

阶段:stage 代码块定义了 Pipeline 中不同的任务子集(比如 “Build”、“Test”、“Deploy” 阶段)。

Step

步骤:在 Stage 中的单个任务,定义了 Jenkins 在特定的步骤做的工作,比如使用 sh 步骤执行 Shell 命令 make

两种语法:声明式和脚本式

声明式语法

pipeline {
    agent any
    stages {
        stage('Build') {
            steps {
                //
            }
        }
        stage('Test') {
            steps {
                //
            }
        }
        stage('Deploy') {
            steps {
                //
            }
        }
    }
}
  1. agent any:在任一代理上执行此 Pipeline
  2. stage('Build'):定义“Build”阶段
  3. steps {}:“Build”阶段执行的步骤。当我们说一个插件扩展了 Pipeline DSL,其含义就是这个插件定义了一种新的步骤。

脚本式语法

node {
    stage('Build') {
        //
    }
    stage('Test') {
        //
    }
    stage('Deploy') {
        //
    }
}
  1. node:在任一可用的代理上执行 Pipeline。
  2. stage('Build') {}:定义“Build”阶段。在脚本式语法中 stage 代码块是可选的。不过明确写上的话可以让每个阶段的任务更清晰。

声明式语法的例子

pipeline {
    agent any
    options {
        skipStagesAfterUnstable()
    }
    stages {
        stage('Build') {
            steps {
                sh 'make'
            }
        }
        stage('Test'){
            steps {
                sh 'make check'
                junit 'reports/**/*.xml'
            }
        }
        stage('Deploy') {
            steps {
                sh 'make publish'
            }
        }
    }
}

声明式语法相对于脚本式的优势:

  • 提供更丰富的语法特性
  • 专门为编写和读取流水线代码而设计

在许多独立的模块/步骤中,两种语法的写法一样。

扩展阅读:Pipeline 语法(官网)

多分支流水线

多分支流水线可以在仓库的每个分支上检测 Jenkinsfile 并分别运行流水线。这就允许你在不同的分支上配置不同的流水线,以满足不同环境、场景的需要。

在流水线中使用 Docker

从 2.5 版本开始,流水线内建支持与 Docker 交互。

自定义执行环境

可以在单个阶段(Stage)也可以在整个流水线上将 Docker 作为执行环境。

// Jenkinsfile(声明式)
pipeline {
    agent {
        docker { image 'node:7-alpine' }
    }
    stages {
        stage('Test') {
            steps {
                sh 'node --version'
            }
        }
    }
}

流水线运行时,Jenkins 自动启动相应的容器并在容器中执行步骤(Steps):

[Pipeline] stage
[Pipeline] { (Test)
[Pipeline] sh
[guided-tour] Running shell script
+ node --version
v7.4.0
[Pipeline] }
[Pipeline] // stage
[Pipeline] }

为容器缓存数据

许多构建工具会从外部下载依赖包并缓存到本地以备复用。但是因为容器是以“干净”的文件系统启动,所以会因为不能复用缓存而使后续的流水线执行效率大打折扣。

流水线支持添加传递给 Docker 的自定义参数,允许用户挂载外部的 Docker Volume 来缓存数据。下面的示例缓存了 ~/.m2 目录,避免了 Maven 在多次运行流水线期间反复下载相同的依赖包。

// Jenkinsfile(声明式)
pipeline {
    agent {
        docker {
            image 'maven:3-alpine'
            args '-v $HOME/.m2:/root/.m2'
        }
    }
    stages {
        stage('Build') {
            steps {
                sh 'mvn -B'
            }
        }
    }
}

使用多个容器

程序依赖多种不同的技术栈很常见。在流水线中使用 Docker 可以让 Jenkins 在不同阶段调用多种技术类型的容器。下面的例子展示了同时使用 Java 作为后端和 JavaScript 作为前端的情况:

// Jenkinsfile(声明式)
pipeline {
    agent none
    stages {
        stage('Back-end') {
            agent {
                docker { image 'maven:3-alpine' }
            }
            steps {
                sh 'mvn --version'
            }
        }
        stage('Front-end') {
            agent {
                docker { image 'node:7-alpine' }
            }
            steps {
                sh 'node --version'
            }
        }
    }
}

使用 Dockerfile

流水线支持从仓库的 Dockerfile 构建并运行容器。使用 agent { dockerfile true } 语句会从 Dockerfile 中构建一个新的 Docker 镜像:

# Dockerfile
FROM node:7-alpine
RUN apk add -U subversion
// Jenkinsfile(声明式)
pipeline {
    agent { dockerfile true }
    stages {
        stage('Test') {
            steps {
                sh 'node --version'
                sh 'svn --version'
            }
        }
    }
}

可以在 Pipeline Syntax 查看更多 agent 语句的用法。

提供 Docker 标签

默认情况下,Jenkins 认为任何代理服务器都可以运行基于 Docker 的流水线。但是当某些代理服务器是 macOS、Windows 这类无法运行 Docker 守护进程的系统时,这个默认的设置就会有问题。在 Manage Jenkins > Configure System 的页面找到 Pipeline Model Definition 配置块,其中有个 Docker Label 的配置项。在这里可以定义具有哪些标签的代理服务器可以运行基于 Docker 的流水线。

脚本式流水线的高级用法

运行“Sidecar”容器

假定一个集成测试套件依赖于本地的 MySQL 数据库。使用脚本式流水线中的 withRun 方法,Jenkinsfile 就可以将一个 MySQL 作为 Sidecar 容器运行了:

node {
    checkout scm
    /* 为与 MySQL 服务器通信,这个流水线将 3306 端口映射到了宿主机 */
    docker.image('mysql:5').withRun('-e "MYSQL_ROOT_PASSWORD=my-secret-pw" -p 3306:3306') { c ->
        /* 等待 MySQL 服务启动 */
        sh 'while ! mysqladmin ping -h0.0.0.0 --silent; do sleep 1; done'
        /* 运行依赖 MySQL 的测试 */
        sh 'make check'
    }
}

这个例子可以扩展为同时使用两个容器:一个运行 MySQL 的“Sidecar”容器和另一个提供执行环境的容器,两个容器之间通过 Docker container link 连接。

node {
    checkout scm
    docker.image('mysql:5').withRun('-e "MYSQL_ROOT_PASSWORD=my-secret-pw"') { c ->
        docker.image('mysql:5').inside("--link ${c.id}:db") {
            /* 等待 MySQL 服务启动 */
            sh 'while ! mysqladmin ping -hdb --silent; do sleep 1; done'
        }
        docker.image('centos:7').inside("--link ${c.id}:db") {
            /* 运行依赖 MySQL 的测试,并且假设 MySQL 主机名是 `db` */
            sh 'make check'
        }
    }
}

上面这个例子用 withRun 生成一个对象,这个对象的 id 属性就是所运行容器的 ID。流水线通过把 ID 作为 link 参数传递给 inside 方法成功地将两个容器联系起来。

id 属性也可以用在流水线退出前导出容器日志的场景下:

sh "docker logs ${c.id}"
构建镜像

build() 方法可以从仓库的 Dockerfile 文件构建镜像。

使用 docker.build("my-image-name") 语句的好处是,脚本式流水线可以将这个语句的返回值用于后续流水线的调用。比如说:

node {
    checkout scm

    def customImage = docker.build("my-image:${env.BUILD_ID}")

    customImage.inside {
        sh 'make test'
    }
}

返回值也可以用 push() 方法将 Docker 镜像发布到 Docker Hub 或自定义仓库:

node {
    checkout scm
    def customImage = docker.build("my-image:${env.BUILD_ID}")
    customImage.push()
}

push() 方法还可以添加一个可选的 tag 参数,使推送的镜像 tag 与本地构建的不同:

node {
    checkout scm
    def customImage = docker.build("my-image:${env.BUILD_ID}")
    customImage.push()

    customImage.push('latest')
}

默认情况下,build() 方法以当前目录下的 Dockerfile 进行构建。通过将一个目录作为 build() 方法的第二个参数可以改变这个行为。在下面这个例子中 Dockerfile 的路径是 ./dockerfiles/test/Dockerfile

node {
    checkout scm
    def testImage = docker.build("test-image", "./dockerfiles/test")

    testImage.inside {
        sh 'make test'
    }
}

build() 方法的第二个参数还可以是 docker build 命令的其他参数。用这种方式传递参数的时候,字符串的最后一个值必须是 Dockerfile 的路径。

下面这个例子通过使用 -f 选项改写了默认的 Dockerfile 文件名:

node {
    checkout scm
    def dockerfile = 'Dockerfile.test'
    def customImage = docker.build("my-image:${env.BUILD_ID}", "-f ${dockerfile} ./dockerfiles")
}

流水线会使用 ./dockerfiles/Dockerfile.test 构建镜像 my-image:${env.BUILD_ID}

使用远程 Docker 服务器

默认情况下,Docker Pipeline 插件通过 /var/run/docker.sock 与本机的 Docker 进程通讯。要想与其他的 Docker 服务器通讯,可以使用 withServer() 方法。

node {
    checkout scm

    docker.withServer('tcp://swarm.example.com:2376', 'swarm-certs') {
        docker.image('mysql:5').withRun('-p 3306:3306') {
            /* do things */
        }
    }
}

withServer() 的第一个参数是远程 Docker 服务器的 URI。第二个参数是预先在 Jenkins 凭据中配置好的 Docker Host Certificate Authentication 类型凭据的 ID,这个参数根据实际情况可以无需添加。

使用自定义仓库

默认的镜像仓库是 Docker Hub。要使用自定义的仓库,在脚本式流水线中可以将步骤放在 withRegistry() 方法中:

node {
    checkout scm

    docker.withRegistry('https://registry.example.com', 'credentials-id') {

        def customImage = docker.build("my-image:${env.BUILD_ID}")

        /* 推送镜像到自定义仓库 */
        customImage.push()
    }
}

该方法可以接受两个参数。第一个参数是自定义仓库的 URL 地址;‘第二个参数是 Jenkins 凭据中 Username/Password 类型的凭据 ID,是可选的。

共享库

当组织内的项目日渐增多时,流水线就会出现一些相同的模式。在项目间共享部分流水线可以降低代码的冗余,保持其 “DRY”原则

流水线支持创建“共享库”,保存到源码仓库以供其他流水线加载。

定义共享库

共享库由一个名称、一个源码获取方式(比如通过 SCM)以及一个可选的默认版本共同定义。名称应该简短,因为它会在脚本中被用到。

版本可以是任何 SCM 理解的东西,比如对于 Git 来说,分支、Tag 标签、Commit 哈希都是可行的。你可以声明流水线脚本是否需要显式请求共享库。此外,如果你在 Jenkins 配置中提供了一个版本,你可以禁止脚本选择不同的版本。

目录结构
(root)
+- src                     # Groovy 源码文件
|   +- org
|       +- foo
|           +- Bar.groovy  # org.foo.Bar 类
+- vars
|   +- foo.groovy          # 全局变量 foo
|   +- foo.txt             # 全局变量 foo 的帮助文件
+- resources               # 资源文件(仅限外部库)
|   +- org
|       +- foo
|           +- bar.json    # org.foo.Bar 的静态帮助数据
  • src 目录类似标准的 Java 源码目录结构。这个目录在执行流水线的时候会加到 classpath 中。

  • vars 目录保存作为流水线变量的脚本文件。文件名就是流水线中的变量名。所以如果你有一个文件叫 vars/log.groovy,其中定义了函数 def info(message)…,你就可以在流水线中通过 log.info "hello world" 使用这个函数。你可以在文件中添加任意数量的函数。

    每个 .groovy 文件的 basename 是一个 Groovy 标识符(类似 Java),按惯例用驼峰法则命名,如 camelCased。如果有对应的 .txt 文档,其内容可以是系统配置好的标记格式(所以尽管后缀是 .txt,实际内容可以是 HTML、Markdown 等)。这个文档只会在 Jenkins 的全局变量参考页面(${YOUR_JENKINS_URL}/pipeline-syntax/globals)显示。

  • resources 目录允许 libraryResource 步骤从外部库载入相关的非 Groovy 文件。

全局共享库

根据不同的使用场景,可以有多种定义共享库的方式。

全局共享库可以给系统中所有流水线使用,通过 Manage Jenkins > Configure System > Global Pipeline Libraries 添加和管理。

全局共享库被认为是“可信的”:可以运行 Java、Groovy、Jenkins 内部 API、第三方库的任何方法。这就让你可以将单独的不安全 API 封装到更高级别的安全 API 中来定义库。请注意,任何可以推送提交到这个源码仓库的人可以不受限制地访问 Jenkins。你需要 Overall/RunScripts 权限来配置这些库(一般分配给 Jenkins 管理员)。

文件夹级共享库

每个文件夹都可以有关联的共享库。

文件夹级共享库是“不可信的”:它们和常规的流水线一样运行在 Groovy 沙盒中。

自动化共享库

某些插件提供了在运行中定义库的方式。比如 Github Branch Source 插件提供了一个“Github Organization Folder”项,该项允许脚本使用不受信任的库,例如 github.com/someorg/somerepo,无需任何其他配置。在这种情况下,将使用匿名签出指定 GitHub 仓库的 master 分支。

使用共享库

标记了 Load implicitly 的共享库可以允许流水线立即使用这个库中定义的类或全局变量。要获取其他的共享库,Jenkinsfile 需要使用 @Library 标注库名称:

@Library('my-shared-library') _
/* 使用版本标识, 如分支、标签等 */
@Library('my-shared-library@1.0') _
/* 单条语句访问多个共享库 */
@Library(['my-shared-library', 'otherlib@abc1234']) _

标注可以写在 Groovy 脚本允许的任何位置。当引用类库时(有 src/ 目录)通常使用 import 语句:

@Library('somelib')
import com.mycorp.pipeline.somelib.UsefulClass

对于仅定义全局变量(vars/)的共享库或仅需要全局变量的 Jenkinsfile,标注的模式 @Library('my-shared-library') _可以保持代码简洁。符号 _ 用于取代不必要的 import 语句。

不建议 import 全局变量/函数,因为这会强制编译器将字段和方法解析为 static,即使它们是实例。在这种情况下,Groovy 编译器会抛出奇怪的错误信息。

库会在脚本执行前的编译阶段解析并载入。这允许 Groovy 编译器理解静态类型检查中使用的符号的含义,并允许它们在脚本中的类型声明中使用,例如:

@Library('somelib')
import com.mycorp.pipeline.somelib.Helper

int useSomeLib(Helper helper) {
    helper.prepare()
    return helper.count()
}

echo useSomeLib(new Helper('some text'))

不过全局变量是在运行时解析的。

动态载入库

在 2.7 以上版本的 Pipeline Shared Groovy Libraries Plugin 插件中有一个用于在脚本中加载(非隐式)库的新选项: library 步骤可以在构建的任何时候动态载入一个库。

如果只想载入全局变量或函数(从 /vars 目录),那么语法相当简单:

library 'my-shared-library'

这样,这个库的所有全局变量都将对此脚本可用。

使用 /src 目录中的类要复杂些。尽管 @Library 标注在编译前准备了脚本的“classpath”,但是在 library步骤运行的时候脚本已经编译好了。因此你不能 import 或“静态”引用库中的类型。

然而你可以动态使用库的类(无需类型检查),从 library 步骤的返回值通过完全限定名称访问它们。static 方法可以用类似 Java 的语法调用:

library('my-shared-library').com.mycorp.pipeline.Utils.someStaticMethod()

你也可以访问 static 域,调用构造函数,就像它们是名为 new 的静态方法一样:

def useSomeLib(helper) { // dynamic: cannot declare as Helper
    helper.prepare()
    return helper.count()
}

def lib = library('my-shared-library').com.mycorp.pipeline // preselect the package

echo useSomeLib(lib.Helper.new(lib.Constants.SOME_TEXT))
库版本

当勾选“Load implicitly”时,或流水线只通过名称引用库时(比如 @Library('my-shared-library') _),共享库会使用“Default version”。如果没有定义“Default version”,流水线应提供一个版本,比如 @Library('my-shared-library@master') _

如果启用了“Allow default version to be overridden(允许覆盖默认版本)”,那么 @Library 标注就可以覆盖库定义的默认版本。这也就允许勾选了“Load implicitly”的库按需载入不同的版本。

使用 library 步骤的时候也可以提供版本:

library 'my-shared-library@master'

由于这是一个常规的步骤,版本可以计算出来,而不像标注一样只能使用常量,比如:

library "my-shared-library@$BRANCH_NAME"
获取方式

最好使用 Modern SCM 选项,可以选择支持检出任意名称版本的 SCM 插件。最新版本的 Git 和 Subversion 插件都支持这个选项。

动态获取

如果在 library 步骤只提供了库名(后面可能有 @ 版本),Jenkins 会根据名称查找预定义的库(或者在 github.com/owner/repo 的例子中会载入自动化库)。

你也可以动态提供获取方式,这种情况下库可以不用在 Jenkins 中预定义。比如:

library identifier: 'custom-lib@master', retriever: modernSCM(
  [$class: 'GitSCMSource',
   remote: 'git@git.mycorp.com:my-jenkins-utils.git',
   credentialsId: 'my-private-key'])

最好参考Pipeline Syntax以获取 SCM 的精确语法。

注意在这些情况下必须提供库版本。

编写库

最基本的,合法的 Groovy 代码 都可以使用。

访问步骤

库的类不能直接调用如 shgit 之类的步骤。不过它们可以在封闭类的范围之外实现方法,后者再调用流水线的步骤。比如:

// src/org/foo/Zot.groovy
package org.foo

def checkOutFrom(repo) {
  git url: "git@github.com:jenkinsci/${repo}"
}

return this

然后可以在流水线中这样调用:

def z = new org.foo.Zot()
z.checkOutFrom(repo)

这种方法有限制,比如无法声明超类。

或者可以使用 this 显式传递 steps 给库类、构造函数或方法:

package org.foo
class Utilities implements Serializable {
  def steps
  Utilities(steps) {this.steps = steps}
  def mvn(args) {
    steps.sh "${steps.tool 'Maven'}/bin/mvn -o ${args}"
  }
}

像上面这样,在类上保存状态时,类必须实现 Serializable 接口。这确保了使用该类的流水线(如下例所示)可以在 Jenkins 中正确挂起和恢复。

@Library('utils') import org.foo.Utilities
def utils = new Utilities(this)
node {
  utils.mvn 'clean package'
}

如果库需要访问全局变量,比如 env,应该以类似的方式显式地将这些变量传递到库类或方法中。

而不是将许多变量从脚本化流水线传递到库中,

package org.foo
class Utilities {
  static def mvn(script, args) {
    script.sh "${script.tool 'Maven'}/bin/mvn -s ${script.env.HOME}/jenkins.xml -o ${args}"
  }
}

上面的示例显示了被传递给一个 static 方法的脚本,由脚本式流水线以下面的方式调用:

@Library('utils') import static org.foo.Utilities.*
node {
  mvn this, 'clean package'
}
定义全局变量

在内部,vars 目录中的脚本按需实例化为单例。为了方便起见,可以在一个 .groovy 文件中定义多个方法。

// vars/log.groovy
def info(message) {
    echo "INFO: ${message}"
}

def warning(message) {
    echo "WARNING: ${message}"
}
// Jenkinsfile
@Library('utils') _

log.info 'Starting'
log.warning 'Nothing to do!'

注意,如果你希望将全局中的字段用于某个状态,请如下添加标注:

@groovy.transform.Field
def yourField = [:]

def yourFunction....

声明式流水线不允许对 “script” 块之外的对象进行方法调用(JENKINS-42360)。上面的方法需要放到 script 命令内部调用:

// Jenkinsfile

@Library('utils') _

pipeline {
    agent none
    stage ('Example') {
        steps {
            /* 这个方法会调用失败 */
            // log.info 'Starting'
            script { 
                log.info 'Starting'
                log.warning 'Nothing to do!'
            }
        }
    }
}
定义自定义步骤

共享库还可以定义全局变量,其行为类似于内置步骤,例如 shgit。共享库中定义的全局变量必须以全小写或“驼峰式”(camelCased)命名。

比如要定义 sayHello,那么需要创建文件 vars/sayHello.groovy 并实现 call 方法。call 方法允许全局变量以下面这种像步骤一样的方式调用:

// vars/sayHello.groovy
def call(String name = 'human') {
    /* 这里可以调用步骤 */
    echo "Hello, ${name}."
}

流水线就能调用这个全局变量:

sayHello 'Joe'
sayHello()       // 使用默认参数调用

如果用块调用,call 方法可以接受一个 Closure。应该明确定义类型以阐明该步骤的意图,比如:

// vars/windows.groovy
def call(Closure body) {
    node('windows') {
        body()
    }
}

流水线可以像使用内置支持块的步骤一样使用这个变量:

windows {
    bat "cmd /?"
}
定义更结构化的 DSL

如果你有很多相似的流水线,那么全局变量的机制就是一个很好用的构建高级 DSL的工具。比如说,所有 Jenkins 插件以相同的方式构建和测试,所以我们可以写一个名为 buildPlugin 的步骤:

// vars/buildPlugin.groovy
def call(Map config) {
    node {
        git url: "https://github.com/jenkinsci/${config.name}-plugin.git"
        sh 'mvn install'
        mail to: '...', subject: "${config.name} plugin build", body: '...'
    }
}

假设脚本以全局共享库文件夹级共享库加载,那么 Jenkinsfile 将相当简单:

buildPlugin name: 'git'

还有一个使用 Groovy 的 Closure.DELEGATE_FIRST 的“构建模式”,可以让 Jenkinsfile 更像配置文件而不是程序,但这种方式更复杂更易犯错,因此不推荐。

使用第三方库

@Grab 标注可以用于使用第三方 Java 库,一般可以在 Maven Central 的信任库中找到。

@Grab('org.apache.commons:commons-math3:3.4.1')
import org.apache.commons.math3.primes.Primes
void parallelize(int count) {
  if (!Primes.isPrime(count)) {
    error "${count} was not prime"
  }
  // …
}

参考 Grape 文档 查看更详细的用法。

第三方库默认缓存在 Jenkins master 的 ~/.groovy/grapes/ 目录。

载入资源

外部库可以使用 libraryResource 步骤加载 resources/ 目录中的附件。参数是一个相对路径,和 Java 资源加载类似:

def request = libraryResource 'com/mycorp/pipeline/somelib/request.json'

该文件作为字符串加载,适合传递给某些 API 或使用 writeFile 保存到工作区。

预测试库变更

如果你在使用不可信库进行构建的过程中发现一个问题,只需要点击 Replay 链接编辑一个或多个库的资源文件,然后看构建结果是否符合预期。当你对结果满意时,点击构建状态页面的 diff 链接,将 diff 应用到库的仓库并提交。

定义声明式流水线

你可以在共享库中定义声明式流水线。下面的例子会根据构建编号的奇偶来执行不同的声明式流水线:

// vars/evenOrOdd.groovy
def call(int buildNumber) {
  if (buildNumber % 2 == 0) {
    pipeline {
      agent any
      stages {
        stage('Even Stage') {
          steps {
            echo "The build number is even"
          }
        }
      }
    }
  } else {
    pipeline {
      agent any
      stages {
        stage('Odd Stage') {
          steps {
            echo "The build number is odd"
          }
        }
      }
    }
  }
}
// Jenkinsfile
@Library('my-shared-library') _

evenOrOdd(currentBuild.getNumber())

目前只能在 vars/*.groovy 文件的 call() 方法中定义完整的流水线。在一个构建中,只能使用一个声明式流水线,使用第二个声明式流水线的尝试将以失败而告终。

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值