• Jenkins Pipeline详细教程



    jenkins2.x支持Pipeline as code,可以通过Jenkinsfile用代码来部署流水线,使用jenkinsfile比界面操作的方式的好处:

    • 更好的版本化,可以将Jenkinsfile提交到版本管理工具中(git、svn),进行版本控制;
    • 更方便多人协作,也可以对流水线代码审查;
    • 增加部署流水线脚本的重用。

    1、Jenkinsfile语法选择

    1.1 脚本式语法

    使用Groovy语法实现pipeline,脚本式语法比较灵活和方便扩展,但是需要熟悉groovy语法。

    node{
        stage("编译打包"){
            // groovy语法
            try{
                
            }
            catch(err){
                // 异常处理代码
            }
        }
        stage("部署"){
    
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    1.2 声明式语法

    def getversion(){
        def version = '1.0.0'
        return version;
    }
    pipeline{
        agent any
    
        tools{
            maven 'MAVEN_HOME'
        }
        options {
            
        }
        environment{{
        
        }
        parameters {
            
        }
        triggers{
            
        }
        stages {
            stage('编译打包') {
                environment {
                    DEBUG_FLAGS = '-g'
                }
                echo '编译打包'
            }
            stage('部署') {
                steps {
                    echo '部署'
                }
            }
        }
    
        post {
            always {
                
            }
            success {
                
            }
            failure{
                
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48

    声明式语法更简单和结构化。

    2、流水线语法介绍

    插件的集成语法参考:https://www.jenkins.io/doc/pipeline/steps/
    Jenkins pipeline是基于Groovy语言实现的DSL,用于描述流水线如何进行,包括编译、打包、部署、测试等等步骤
    完整Jenkinsfile支持的内容

    2.1 agent,执行位置

    agent 指定了整个流水线或特定的阶段, 会在Jenkins环境中执行的位置(master节点或其它从节点运行)。可以在 pipeline 块的顶层被定义, 也可以在 stage 内定义。
    any
    在任何可用的代理上执行流水线或阶段。例如: agent any

    none
    当在 pipeline 块的顶部没有全局代理, 该参数将会被分配到整个流水线的运行中并且每个 stage 部分都需要包含他自己的 agent 部分。比如: agent none

    label
    在提供了标签的 Jenkins 环境中可用的代理上执行流水线或阶段。 例如: agent { label ‘my-defined-label’ }

    node
    agent { node { label ‘labelName’ } } 和 agent { label ‘labelName’ } 一样, 但是 node 允许额外的选项 (比如 customWorkspace )。

    2.2 tool

    Global Tool Configuration(全局工具配置)中配置的工具, tools指令能帮助我们自动下载并安装所指定的构建工具,并将其加入 PATH 变量中。这样,我们就可以在sh步骤里直接使用了。但在agent none的情况下不会生效。
    tools指令默认支持3种工具:JDK、Maven、Gradle。通过安装插件,tools 指令还可以支持更多的工具。
    tools {
    git ‘Default’
    jdk ‘JAVA_HOME’
    maven ‘MAVEN_HOME’
    }

    2.3 environment

    设置环境变量,可在在 pipeline中 或 stage配置

    • 在 pipeline 中定义 environment, 表示 pipeline 全局使用的环境变量
    • 在 stage 中定义 environment, 表示当前 stage 的环境变量

    有三种引用方式:

    • ${env.BUILD_NUMBER} 方式一,推荐使用
    • $env.BUILD_NUMBER 方式二,
    • ${BUILD_NUMBER} 方式三,不推荐使用

    内置的环境变量:

    使用环境变量
    Jenkins 流水线通过全局变量 env 提供环境变量,它在 Jenkinsfile 文件的任何地方都可以使用。Jenkins 流水线中可访问的完整的环境变量列表记录在 ``${YOUR_JENKINS_URL}/pipeline-syntax/globals#env``,并且包括:
    
    BUILD_ID
    当前构建的 ID,与 Jenkins 版本 1.597+ 中创建的构建号 BUILD_NUMBER 是完全相同的。
    
    BUILD_NUMBER
    当前构建号,比如 “153”。
    
    BUILD_TAG
    字符串 ``jenkins-${JOB_NAME}-${BUILD_NUMBER}``。可以放到源代码、jar 等文件中便于识别。
    
    BUILD_URL
    可以定位此次构建结果的 URL(比如 http://buildserver/jenkins/job/MyJobName/17/ )
    
    EXECUTOR_NUMBER
    用于识别执行当前构建的执行者的唯一编号(在同一台机器的所有执行者中)。这个就是你在“构建执行状态”中看到的编号,只不过编号从 0 开始,而不是 1。
    
    JAVA_HOME
    如果你的任务配置了使用特定的一个 JDK,那么这个变量就被设置为此 JDK 的 JAVA_HOME。当设置了此变量时,PATH 也将包括 JAVA_HOME 的 bin 子目录。
    
    JENKINS_URL
    Jenkins 服务器的完整 URL,比如 https://example.com:port/jenkins/ (注意:只有在“系统设置”中设置了 Jenkins URL 才可用)。
    
    JOB_NAME
    本次构建的项目名称,如 “foo” 或 “foo/bar”。
    
    NODE_NAME
    运行本次构建的节点名称。对于 master 节点则为 “master”。
    
    WORKSPACE
    workspace 的绝对路径。
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32

    全局变量可以在搭建好的jenkins服务上查看,访问地址:http://jenkins访问地址/pipeline-syntax/globals#env

    2.4 options 用于配置Pipeline本身

    可用配置

    **buildDiscarder**  
    为最近的流水线运行的特定数量保存组件和控制台输出。例如: options { buildDiscarder(logRotator(numToKeepStr: '1')) }
    
    **disableConcurrentBuilds**  
    不允许同时执行流水线。 可被用来防止同时访问共享资源等。 例如: options { disableConcurrentBuilds() }
    
    **overrideIndexTriggers**  
    允许覆盖分支索引触发器的默认处理。 如果分支索引触发器在多分支或组织标签中禁用, options { overrideIndexTriggers(true) } 将只允许它们用于促工作。否则, options { overrideIndexTriggers(false) } 只会禁用改作业的分支索引触发器。
    
    **skipDefaultCheckout**  
    在`agent` 指令中,跳过从源代码控制中检出代码的默认情况。例如: options { skipDefaultCheckout() }
    
    **skipStagesAfterUnstable** 
    一旦构建状态变得UNSTABLE,跳过该阶段。例如: options { skipStagesAfterUnstable() }
    
    **checkoutToSubdirectory** 
    在工作空间的子目录中自动地执行源代码控制检出。例如: options { checkoutToSubdirectory('foo') }
    
    **timeout**  
    设置流水线运行的超时时间, 在此之后,Jenkins将中止流水线。例如: options { timeout(time: 1, unit: 'HOURS') }
    
    **retry**  
    在失败时, 重新尝试整个流水线的指定次数。 For example: options { retry(3) }
    
    **timestamps**  
    预谋所有由流水线生成的控制台输出,与该流水线发出的时间一致。 例如: options { timestamps() }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    2.5 parameters 流水线参数(参数化构建)

    2.5.1 普通参数

    参数类型包括:单行文本、布尔、 多行文本、选项、密码

    parameters {
    // 单行文本参数
    string(name: 'user', defaultValue: '', description: '用户参数')
    // 布尔参数
    booleanParam(name: 'is_build', defaultValue: true, description: '是否构建')
    // 多行文本参数
    text(name: 'welcome_text', defaultValue: 'One\nTwo\nThree\n', description: '多行文本参数')
    // 选项参数
    choice(name: 'select', choices:['请选择','编译打包','部署','启动','关闭','回滚'
                ],description: '操作(请选择操作:编译打包、部署、启动、关闭、状态)')
    // 密码参数
    password(name: 'psw', defaultValue: '123456', description: '密码')
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    其它参数可以通过安装插件实现,比如Extended Choice Parameter(复选框插件)。

    extendedChoice(description: '服务器(仅在部署、启动、关闭、状态操作时需要选择)', value: '192.168.1.100,192.168.1.101',
                descriptionPropertyValue: '192.168.1.100(测试),192.168.1.101(正式)',
                multiSelectDelimiter: ',', name: 'servers', quoteValue: false, saveJSONParameterToFile: false, type: 'PT_CHECKBOX', visibleItemCount: 5)
    
    • 1
    • 2
    • 3

    在阶段中调用上边设置的参数

    stage('编译打包') {
        steps {
            script {
                if (params.is_build == true) {
                    build()
                }
                else{
                    echo "=====跳过编译打包"
                }
            }
        }
    }
    stage('部署') {
        steps {
            script {
                if ("${params.select}".trim() == "部署") {
                   for(ip in servers.tokenize(",")){
                         deploy(ip)
                   }
                }
                
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    2.5.2 特殊参数input

    input会暂停Pipeline的执行,直到用户输入参数

    ## 配置选项
    message(必需):提示的文本内容;
    id(可选):可选标识符,默认为stage名称。
    ok(可选):"ok" 按钮的显示的文本。
    submitter(可选):允许操作的用户ID或角色名,默认允许任何用户。
    submitterParameter(可选):环境变量的可选名称。如果存在,用 submitter 名称设置。
    parameters(可选):可选的参数列表。
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    stages {
        stage('部署') {
            input '是否确认部署或停止?'
            steps {
                deploy(ip)
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    input支持内部使用普通参数(单行文本、布尔、选项参数等)

    stages {
            stage('Example') {
                stages {
                    stage('部署') {
                        message "是否确认部署或停止?"
                        ok '部署'
                        submitter "alice,bob"
                        parameters {
                            extendedChoice(name: 'servers', description: '服务器', value: '192.168.1.100,192.168.1.101',
                                    descriptionPropertyValue: '192.168.1.100(测试),192.168.1.101(正式)',
                                    multiSelectDelimiter: ',', quoteValue: false, saveJSONParameterToFile: false, type: 'PT_CHECKBOX', visibleItemCount: 5)
                            string(name: 'myparam', defaultValue: 'dev', description: '自定义参数')
                        }
                        steps {
                            deploy(ip)
                        }
                    }
                }
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    2.6 triggers触发器

    Jenkins内置支持cron、pollSCM、upstream三种方式

    2.6.1 定时执行

    定时执行就像cronjob一样,一到时间点就执行。Jenkins采用了UNIX任务调度工具CRON的配置方式,用5个字段代表5个不同的时间单位(中间用空格隔开),语法如下:

    字段*****
    含义分钟小时日期月份星期
    取值范围0-590-231-311-120-7
    pipeline{
        ......
        triggers{
            cron('0 * * * *')
        }
        ......
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    2.6.2 轮询代码仓库:pollSCM

    定期到代码仓库轮询代码是否有变化,如果有变化就执行。

    pipeline{
        ......
        triggers{
            pollSCM('H/1 * * * *')
        }
        ......
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    2.6.3 事件触发

    其它事件触发了pipeline执行,这个事件可以是在界面上手动触发、其它job触发、Http API Webhook触发等。

    2.6.3.1 由上游任务触发:upstream

    可以利用上游Job的运行状态来进行触发

    pipeline{
        agent any
        //说明:当test_1或者test_2运行成功的时候,自动触发
        triggers { upstream(upstreamProjects: 'test_1,test_2', threshold: hudson.model.Result.SUCCESS) }
        stages{
            stage("stage1"){
                steps{
                    echo "hello"
                }
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    hudson.model.Result是一个枚举,包括以下值:

    ABORTED 任务被手动中止
    FAILURE 构建失败
    SUCCESS 构建成功
    UNSTABLE 存在一些错误,但不至于构建失败
    NOT_BUILT 在多阶段构建时,前面阶段的问题导致后面阶段无法执行
    
    • 1
    • 2
    • 3
    • 4
    • 5

    注意:这种需要手动构建当前任务一次,让jenkins加载pipeline后,trigger指令才生效

    2.6.3.2 Gitlab事件触发

    当gitlab发现源代码有变化时,触发jenkins执行构建。
    (1)需要安装插件

    • GitLab Plugin(https://plugins.jenkins.io/gitlab-plugin)
    • Git plugin(https://plugins.jenkins.io/git/)

    (2)在pipeline中实现Gitlab trigger

    pipeline {
        agent any
        triggers {
            gitlab(triggerOnPush: true,
            triggerOnMergeRequest: true,
            branchFilterType: "All",
            secretToken: "rvgtcxwufgbcsr56lftzr5a74vhjko0")
        }
        stages {
            stage('pull') {
                steps {
                    echo '拉取代码'
                }
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    (3)进入GitLab项目的配置页,找到设置->集成选项,把Jenkins暴露webhook及secretToken填入相应选项。

    3、post,根据stages执行结果预定义的执行条件

    post支持的条件块

    • always: 无论stage的执行结果如何,此块中的预置操作都会执行。
    • changed:只有当stage的执行后,当前状态与之前发生了改变时,此块中的预置操作才会执行。
    • fixed:上一次运行为不稳定或者失败状态,本次运行成功时,此块中的预置操作才会执行。
    • regression:上一次运行成功,本次运行为失败、不稳定、中止状态时,此块中的预置操作才会执行。
    • aborted:当手动中止运行时,此块中的预置操作才会执行。
    • failure:当stage的状态为失败时,此块中的预置操作才会执行。
    • success:当stage的状态为成功时,此块中的预置操作才会执行。
    • unstable: 当前stage的状态为不稳定时,此块中的预置操作才会执行。
    • unsuccessful:当前stage的状态不是成功时,此块中的预置操作才会执行。
    • cleanup:无论stage的状态为何种状态,在post中的其他的条件预置操作执行之后,此块中的预置操作就会执行。

    3.1、企业微信通知

    需要安装插件Qy Wechat Notification,唯一不足当前插件不支持自定义文本。

    post {
        always {
            qyWechatNotification mentionedId: '', mentionedMobile: '', webhookUrl: 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxxxxxx'
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    3.2、Http请求通知

    需要安装插件HTTP Request,与第三方系统集成的常用方式

    post {
        always {
            httpRequest requestBody: '{\'build\':\'${env.BUILD_ID}\'}', responseHandle: 'NONE', url: 'http://192.168.1.100:8080/noti'
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    4、流水线支持的步骤

    Pipeline支持的基本步骤(内置步骤),详细可查看(https://www.jenkins.io/doc/pipeline/steps/workflow-basic-steps/):

    catchError: 捕捉错误和设置构建结果为失败。
    deleteDir: 在工作区内递归删除当前目录
    dir: 设置当前工作目录。
    echo: 打印信息
    error: 设置构建结果和阶段结果为异常,并打印设置的信息,可以中断流水线的执行,也可以抛出新的Exception(),但是这个步骤不会打印堆栈信息。
    fileExists: 检查文件是否存在,返回true|false。
    isUnix: 检查是否运行在类unix节点上
    mail: 邮件通知
    pwd: 以字符串形式返回当前目录路径。
    readFile: 从相对路径读取文件(通常是工作空间),并以普通字符串的形式返回其内容。
    retry: 重试内部代码N次。
    sleep: 暂停管道构建,直到给定的时间过期。
    stash: 在构建的阶段中把文件保存起来,这个文件可以给当前构建中的其它阶段使用。
    step: 一般的构建步骤
    timeout: 设置执行超时时间,如果块内的代码执行时间超出限制,会抛出异常。
    tool: 使用预定义的工具安装中的工具,参考2.2。
    unstable: 设置构建结果和阶段结果设置为不稳定。并打印日志信息。
    unstash: 取出之前stash保存的文件。
    waitUntil: 反复运行它的内部代码块,直到返回true。如果返回false,等待一段时间并再次尝试。
    warnError: 捕获错误并将构建和阶段结果设置为不稳定
    withEnv: 设置环境变量
    wrap: 一般构建包装
    writeFile: 将给定的内容写入当前目录中。  
    archive: 把构建中输出的内容存档,供以后其它构建使用。Jenkins 2.x后,提供archiveArtifacts代替archive,该步骤已弃用。
    getContext: G从内部api获取上下文对象。
    unarchive: 将存档的工件复制到工作区中。
    withContext: 在块内部使用上下文对象 API。
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    5、其它支持Jenkinsfile的插件

    5.1 文件上传

    支持SSH文件上传的插件目前主要有Publish Over SSH、SSH Pipeline Steps,

    5.1.1 Publish Over SSH

    需要先在全局配置中配置远程服务器信息
    在这里插入图片描述
    Jenkinsfile中脚本代码:

    stage('部署和启动') {
              sshPublisher(
                    publishers: [
                            sshPublisherDesc(
                                    configName: "192.168.28.129_test1",
                                    transfers: [
                                            sshTransfer(cleanRemote: false,
                                                    excludes: '',
                                                    execCommand: """
                                                            cd ${deploy_dir}         // 远程执行的shell
                                                    """,
                                                    execTimeout: 120000,
                                                    flatten: false,
                                                    makeEmptyDirs: false,
                                                    noDefaultExcludes: false,
                                                    patternSeparator: '[, ]+',
                                                    remoteDirectory: "upload",        // 远程目录
                                                    remoteDirectorySDF: false,
                                                    removePrefix: "penneo/",          // 删掉的去缀
                                                    sourceFiles: "penneo/test.zip")   // 上传的文件
                                    ],
                                    usePromotionTimestamp: false,
                                    useWorkspaceInPromotion: false,
                                    verbose: true)
                    ])
            }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    5.1.2 SSH Pipeline Steps
    node {
    def remote = [:]
    remote.name = ‘test’
    remote.host = ‘192.168.28.129’
    remote.user = ‘penngo’
    remote.password = ‘123456’
    remote.allowAnyHosts = true
    stage(‘Remote SSH’) {
    writeFile file: ‘abc.sh’, text: ‘ls’
    sshCommand remote: remote, command: ‘ls -al’
    sshPut remote: remote, from: ‘abc.sh’, into: ‘.’
    sshGet remote: remote, from: ‘abc.sh’, into: ‘bac.sh’, override: true
    sshScript remote: remote, script: ‘abc.sh’
    sshRemove remote: remote, path: ‘abc.sh’
    }
    }
    也可以结合withCredentials使用
    配置凭证信息,分别配置了ssh key和帐号密码两种方式
    在这里插入图片描述

        stages {
            stage('部署') {
                steps {
                        script{
                            def ip = '192.168.28.132'
                            withCredentials([sshUserPrivateKey(credentialsId: "${ip}_pk", keyFileVariable: 'identity', usernameVariable: 'username')]) {
                                def remote = [:]
    
                                remote.name = ip
                                remote.host = ip
                                remote.allowAnyHosts=true
                                remote.user = username
                                remote.identityFile = identity
                                sshCommand remote: remote, command: """
                                    pwd
                                    ls -al
                                """
                                // 把远程主机文件下载到本地工作区
                                sshGet remote: remote, from: 'build_id.txt', into: 'build2_id.txt', override: true
                                // 本地工作区文件上传到远程主机目录
                                sshPut remote: remote, from: 'JApiTest.zip', into: '/data/upload/'
                            }
    
    
                            withCredentials([usernamePassword(credentialsId: "${ip}", passwordVariable: 'password', usernameVariable: 'username')]) {
                                def remote = [:]
                                remote.name = ip
                                remote.host = ip
                                remote.user = username
                                remote.password = password
                                remote.allowAnyHosts=true
                                sshCommand remote: remote, command: """
                                    cd /usr/local
                                    pwd
                                    ls -al
                                """
                                sshGet remote: remote, from: 'build_id.txt', into: 'build2_id.txt', override: true
                                sshPut remote: remote, from: 'JApiTest.zip', into: '/data/upload/'
    //                            sshScript remote: remote, script: 'server.sh'
                                sshRemove remote: remote, path: '/root/ttt_test'
                            }
                        }
                }
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45

    Jenkins Pipelin支持的所有步骤列表:https://www.jenkins.io/doc/pipeline/steps/

  • 相关阅读:
    ssm小型物流信息系统毕业设计源码071146
    物联网数据采集网关连接设备与云平台的关键桥梁
    DSPE-PEG-Silane,DSPE-PEG-SIL,磷脂-聚乙二醇-硅烷修饰二氧化硅颗粒用
    MES系统物料管理的五大功能,建议收藏
    Gin 打包vue或react项目输出文件到程序二进制文件
    java八股文面试[数据库]——MySQL中事务的特性
    第2-1-5章 docker安装MinIO实现文件存储服务-springboot整合minio-minio全网最全的资料
    在Qt使用QTcpServer和QTcpSocket及多线程时安全释放内存的几个注意点
    加权平均的重要作用
    【前后缀技巧】2022牛客多校3 A
  • 原文地址:https://blog.csdn.net/penngo/article/details/126457364