2024年2月

看了博友的
C# 实现刘谦春晚魔术
很好,改成了delphi版的。

1 programProject1;2 
3 {$APPTYPE CONSOLE}
4 {$R *.res}
5 
6 uses
7 System.SysUtils,8 System.Classes;9 
10 var
11 list, removeElement: TStringList;12 i, NameCount, insertIndex, directCount, sexCount: Integer;13 tmp, element, hiddenEle1, hiddenEle2: String;14 
15 procedureFun1(NameCount: Integer);16 begin
17   if NameCount < list.Count then
18   begin
19     for i := 0 to NameCount - 1 do
20     begin
21       element := list[0]; //获取第0个元素
22       list.Delete(0); //移除第0个元素
23       list.Add(element); //将第0个元素添加到最后
24     end;25 
26   end;27 end;28 
29 procedureFun2(forCount: Integer);30 begin
31   //被删除掉的三张牌
32   removeElement := TStringList.Create;33   for i := 0 to forCount - 1 do
34   begin
35     removeElement.Add(list[0]);36     list.Delete(0);37   end;38   Writeln(Format('被删除掉的%D张牌:%s', [forCount, removeElement.CommaText]));39   Writeln('删除之后:' +list.CommaText);40 
41   //把拿到的三张牌插到中间
42 Randomize;43   insertIndex := Random(list.Count - 1) + 1; //生成一个介于第一个和倒数第二个位置之间的随机插入位置
44   Writeln('插入位置:' +insertIndex.ToString);45   for i := removeElement.Count - 1 downto 0 do
46   begin
47 list.Insert(insertIndex, removeElement[i]);48   end;49 
50 end;51 
52 procedureFun3();53 begin
54   //藏起来第一张牌
55   hiddenEle1 := list.Strings[0];56   list.Delete(0);57 end;58 
59 procedureFun4(Count: Integer);60 begin
61   for i := 0 to Count - 1 do
62   begin
63     list.Delete(0);64   end
65 end;66 
67 procedureFun5();68 begin
69   for i := 0 to 6 do
70   begin
71     element := list[0];72     list.Delete(0);73 list.Add(element);74   end;75 end;76 
77 function Fun6(sexCount: Integer): string;78 var
79 forCount: Integer;80 begin
81   forCount := 0;82   if sexCount = 1 then
83     forCount := 5 //男生要5次
84   else
85     forCount := 4;86   for i := 0 to forCount - 1 do
87   begin
88     //把第一张牌放到数组最后面 (好运留下来)
89     element := list[0];90     list.Delete(0);91 list.Add(element);92 
93     //烦恼丢出去
94     list.Delete(0);95   end;96   result := list[0];97 end;98 
99 begin
100   try
101     try
102       list := TStringList.Create;103       list.CommaText := 'A,B,C,D,A,B,C,D';104       Writeln('初始牌组:' +list.CommaText);105       Writeln('请输入你的名字有几个字:');106 ReadLn(tmp);107       NameCount :=tmp.ToInteger;108 
109 Fun1(NameCount);110       Writeln('变化之后:' +list.CommaText);666666 
112       Fun2(3);113       Writeln('插入三张牌后:' +list.CommaText);114 Fun3();115       Writeln('藏起来第一张牌后:' +list.CommaText);116 
117       Writeln('北方-1 南方-2 不南不北-3,请输入对应数字:');118 ReadLn(tmp);119       directCount :=tmp.ToInteger;120 Fun2(directCount);121       Writeln('南北方人后:' +list.CommaText);122 
123       Writeln('男-1 女-2,请输入对应数字:');124 ReadLn(tmp);125       sexCount :=tmp.ToInteger;126 Fun4(sexCount);127       Writeln('男女生后:' +list.CommaText);128 
129 Fun5();130 
131       Writeln('见证奇迹后:' +list.CommaText);132 
133       Writeln('开始好运留下了 烦恼丢出去');134       hiddenEle2 :=Fun6(sexCount);135       Writeln(Format('牌1:%s  牌2:%s', [hiddenEle1, hiddenEle2]));136 
137 ReadLn;138     except
139       on E: Exception do
140         Writeln(E.ClassName, ':', E.Message);141     end;142 
143   finally
144 removeElement.Free;145 list.Free;146 
147   end;148 
149 end.

单例模式五种实现方式以及在JDK中的体现

一、五种实现方式

1、饿汉式

  • 构造私有
  • 提供一个静态私有的成员常量,类型就是单例类型,值是用私有构造创造出来的唯一实例
  • 提供公共的静态方法获取上述的静态成员常量
public class Singleton1 implements Serializable {
    //(1)构造私有
    private Singleton1(){
        //添加if判断防止单例模式被破坏
        if(INSTANCE != null) {
            throw new RuntimeException("单例构造器禁止反射调用");
        }
        System.out.println("private Singleton1()");
    }
    //(2)提供一个静态私有的成员常量,类型就是单例类型,值是用私有构造创造出来的唯一实例
    private static final Singleton1 INSTANCE = new Singleton1();
    //(3)提供公共的静态方法获取上述的静态成员常量
    public static Singleton1 getInstance() {
        return INSTANCE;
    }
}

注意:
对应所有的单例模式实现方式,如果实现Serializable接口,可能会被反射,反序列化等操作破坏单例,

2、枚举饿汉式

  • 枚举类,只定义一个变量
  • 构造私有
  • 提供公共的静态方法获取枚举常量
public enum Singleton2 {
    //(1)枚举类,只定义一个变量
    INSTANCE;
    //(2)构造私有
    private Singleton2(){
        System.out.println("private Singleton3()");
    }
    //(3)获取枚举常量
    private static Singleton2 getInstance(){
        return INSTANCE;
    }

}

3、懒汉式

  • 构造私有
  • 提供一个静态私有的成员常量,类型就是单例类型,值为空
  • 当调用该方法时才new
  • 提供公共的静态方法获取上述的静态成员常量
public class Singleton3 {
    //(2)声明静态变量为空
    private static Singleton3 INSTANCE = null;
    //(1)构造私有
    private Singleton3(){
        System.out.println("private Singleton1()");
    }
    //(3)提供公共的静态方法获取上述的静态成员变量
    //添加synchronized保证在多线程情况下运行正常
    public static synchronized Singleton3 getInstance() {
        //(4)调用该方法时再new,通过判断是否为空可控制只创建一个实例
        if(INSTANCE == null) {
            INSTANCE=new Singleton3();
        }
        return INSTANCE;
    }
}

注意:
如果在多线程下,要防止多次创建实例,则在静态方法上添加synchronized,但其实我们只要在第一次调用时保证线程安全就可以,因此此方式的效率比较低,如果要提高效率,可以参考下述两种实现方式。

4、DCL懒汉式-双检索懒汉式

DCL懒汉式是为了保证多线程下运行正常,同时提高效率,也就是在静态方法中加锁之前判断是否已经创建了实例。同时静态变量使用volatile修饰

public class Singleton4 {
    //(2)声明静态变量为空
    private static volatile Singleton4 INSTANCE = null;//可见性,有序性
    //(1)构造私有
    private Singleton4(){
        System.out.println("private Singleton1()");
    }
    //(3)提供公共的静态方法获取上述的静态成员变量,调用该方法时再new
    public static Singleton4 getInstance() {
        //在加锁之前进行判断
        if(INSTANCE == null) {//一次检索
            synchronized (Singleton4.class){
                if (INSTANCE == null){//二次检索
                    INSTANCE=new Singleton4();
                }
            }
        }
        return INSTANCE;
    }
}

5、懒汉式内部类单例模式

使用内部类的方式可以保证线程安全,建议使用

public class Singleton5 {
    //构造私有
    private Singleton5(){
        System.out.println("private Singleton5()");
    }
    //创建内部类,在内部类中新建实例
    private static class Holder{
        static Singleton5 INSTANCE=new Singleton5();
    }
    private static Singleton5 getInstance(){
        return Holder.INSTANCE;
    }
}

二、单例模式在jdk中的体现

  • Runtime类是单例模式,饿汉式单例实现
  • System类中的有一个Console类型的cons变量,采用DCL懒汉式方式实现
  • Collections类中有大量单例模式实现,采用内部类懒汉式实现

前言

大家好,这里是白泽。
《Go语言的100个错误以及如何避免》
是最近朋友推荐我阅读的书籍,我初步浏览之后,大为惊喜。就像这书中第一章的标题说到的:“
Go: Simple to learn but hard to master
”,整本书通过分析100个错误使用 Go 语言的场景,带你深入理解 Go 语言。

我的愿景是以这套文章,在保持权威性的基础上,脱离对原文的依赖,对这100个场景进行篇幅合适的中文讲解。所涉内容较多,总计约 8w 字,这是该系列的第六篇文章,对应书中第48-54个错误场景。


简介

git-commit-id-maven-plugin 是一个maven 插件,用来在打包的时候将git-commit 信息打进jar中。

这样做的好处是可以将发布的某版本和对应的代码关联起来,方便查阅和线上项目的维护。至于它的作用,用官方说法,这个功能对于大型分布式项目来说是无价的。

功能

你是否经常遇到这样的问题:

  • 测试提交了一个bug,开发人员无法确认是哪个版本有这个问题,当前测试环境部署的是某个版本吗?生产环境会不会也有这个问题?

  • 公司内部的项目,总共几十、几百个服务,每天都有服务的生产环境部署,一个服务甚至一天上线好几次,对于项目管理来说无法清晰了解某一时刻某个服务的版本

  • 如何验证我的代码是否已经上线?

  • 。。。。。。

以上种种,都有一个共同的诉求,就是我希望在打包的时候将最后一次 git commit id 和当前 jar 关联起来并可试试查询jar对应的git commit id 。

实践

引入插件

本例SpringBoot版本为 2.7.6,java版本为11

此插件已经上传到中央仓库(
https://central.sonatype.com/artifact/io.github.git-commit-id/git-commit-id-maven-plugin?smo=true)

在项目pom.xml 中引入如下插件



<project>
    ......
    <build>
        <plugins>
            <!--  git-commit-id-maven-plugin :打包的时候携带git提交信息  -->
            <plugin>
                <groupId>io.github.git-commit-id</groupId>
                <artifactId>git-commit-id-maven-plugin</artifactId>
                <version>5.0.0</version>
                <executions>
                    <execution>
                        <id>get-the-git-infos</id>
                        <goals>
                            <goal>revision</goal>
                        </goals>
                        <phase>initialize</phase>
                    </execution>
                </executions>
                <configuration>
                    <generateGitPropertiesFile>true</generateGitPropertiesFile>
                    <generateGitPropertiesFilename>${project.build.outputDirectory}/git.</generateGitPropertiesFilename>
                    <includeOnlyProperties>
                        <includeOnlyProperty>^git.build.(time|version)$</includeOnlyProperty>
                        <includeOnlyProperty>^git.commit.id.(abbrev|full)$</includeOnlyProperty>
                    </includeOnlyProperties>
                    <format>txt</format>
                    <commitIdGenerationMode>full</commitIdGenerationMode>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>



  • generateGitPropertiesFilename:用于指定生成的gitCommitInfo存放到哪个位置,后缀可以任意指定,如果不指定将使用format的值
  • format:指定文件后缀,一般为 properties , json
  • commitIdGenerationMode:记录完整信息,若format为json,此值必须为full

此外为了能成功打出jar包,还需要如下插件的配合:



<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <version>${spring-boot.version}</version>
    <configuration>
        <includeSystemScope>true</includeSystemScope>
        <excludes>
            <exclude>
                <groupId>org.projectlombok</groupId>
                <artifactId>lombok</artifactId>
            </exclude>
        </excludes>
    </configuration>
    <executions>
        <execution>
            <id>repackage</id>
            <goals>
                <goal>repackage</goal>
            </goals>
        </execution>
    </executions>
</plugin>


使用maven执行 clean and package ,将在target\classes下生成 git.json文件,内容如下:


#Generated by Git-Commit-Id-Plugin
git.build.time=2024-02-21T10\:41\:24+0800
git.build.version=0.0.1-SNAPSHOT
git.commit.id.abbrev=3fc9c80
git.commit.id.full=3fc9c8009a48e22ef171c98a97398005e9f30a4a

同时,如果我们反编译生成的jar包,将在BOOT-INF/classes下看到git.json 文件

GitCommitIdMavenPlugin插件有丰富的配置选项,更多配置参考:



<configuration>
    <!--
                    git文件记录,默认是
                    ${project.basedir}/.git
                    如果非默认,可以指定,例如: ${project.basedir}/../.git
                -->
    <dotGitDirectory>${project.basedir}/.git</dotGitDirectory>
    <!--
                    属性前缀,可以理解为namespace,默认是git, 例如 `${configured-prefix}.commit.id`.
                   
                    更多信息可以参考 (see
                    https://github.com/git-commit-id/git-commit-id-maven-plugin/issues/137#issuecomment-418144756
                    for a full example).
                -->
    <prefix>git</prefix>
    <!-- @since 2.2.0 -->
    <!--
                    默认的日期格式,使用方式(e.g. `git.commit.time` and `git.build.time`).
                 -->
    <dateFormat>yyyy-MM-dd'T'HH:mm:ssZ</dateFormat>
    <!-- @since 2.2.0 -->
    <!--
                    时区(java.util.TimeZone.getDefault().getID()).
                    *Note*: 指定时区可以如下 `MAVEN_OPTS=-Duser.timezone=UTC mvn clean package`, `mvn clean package -Duser.timezone=UTC`
                   或者使用 Asia/Shanghai 直接指定,该属性会使用在
                    (e.g. `git.commit.time` and `git.build.time`).
                -->
    <dateFormatTimeZone>${user.timezone}</dateFormatTimeZone>
    <!--
                    默认false,构建时打印信息
                -->
    <verbose>false</verbose>
    <!--
                    默认false, 如果是true, 会生成properties 文件(填充文件中的属性值),文件配置在 generateGitPropertiesFilename 中, 构建时间使用如下
                    ``` git.build.time=${git.build.time}
                    ```
                -->
    <generateGitPropertiesFile>true</generateGitPropertiesFile>
    <!--
                    默认文件
                    ${project.build.outputDirectory}/git.properties
                    该路径可以使用相对于${project.basedir}的相对路径(e.g. target/classes/git.properties),或者全路径(e.g. ${project.build.outputDirectory}/git.properties)
                -->
    <generateGitPropertiesFilename>${project.build.outputDirectory}/git.properties</generateGitPropertiesFilename>
    <!--
                   文件格式,默认properties,可以使用json
如果将其设置为“json”,则还应该签出关于 `commitIdGenerationMode` 而且设置
                    `<commitIdGenerationMode>full</commitIdGenerationMode>`.
                -->
    <format>properties</format>
    <!--
                    默认是true,如果打包是pom(e.g. `<packaging>pom</packaging>`),则运行该插件
                -->
    <skipPoms>true</skipPoms>
    <!-- @since 2.1.4 -->
    <!--
                    告诉maven git commit id将git属性注入到所有reactor项目中,而不仅仅是现在的那个。默认情况下,属性设置为“false”,以防止重写可能与项目无关的属性。如果需要公开git属性对于另一个maven模块(例如maven antrun plugin),您需要将其设置为“true”。
                -->
    <injectAllReactorProjects>false</injectAllReactorProjects>
    <!-- @since 2.0.4 -->
    <!-- 默认false, 指定在找不到.git目录时插件是否应失败。当设置为“false”并且没有找到.git目录时,插件将跳过执行。
                -->
    <failOnNoGitDirectory>true</failOnNoGitDirectory>
    <!-- @since 2.1.5 -->
    <!--
                    默认true,默认情况下,如果插件无法获取足够的数据来完成,跳过执行该插件。
                -->
    <failOnUnableToExtractRepoInfo>true</failOnUnableToExtractRepoInfo>
    <!-- @since 2.1.8 -->
    <!--
                    默认false, 当设置为“true”时,插件执行将完全跳过。这对于配置文件激活的插件调用或使用属性来启用/禁用pom功能。在版本*2.2.3*中,您还可以使用命令行选项跳过插件`-Dmaven.gitcommitid.skip=true`
                -->
    <skip>false</skip>
    <!-- @since 3.0.1 -->
    <!--
                   默认false,当设置为“true”时,插件将不会尝试联系任何远程存储库。任何操作都将只使用回购的本地状态。如果设置为“false”,它将执行“git fetch”操作,例如确定“ahead”和“behind”分支信息。
                -->
    <offline>false</offline>
    <!-- @since 2.1.12 -->
    <!--
默认 false,如果为true,只在一个模块中运行一次。这意味着插件的效果对执行图中的第一个项目执行一次
                -->
    <runOnlyOnce>false</runOnlyOnce>
    <!-- @since 2.1.9 -->
    <!--
                    排除属性                    
                -->
    <excludeProperties>
        <!-- <excludeProperty>git.user.*</excludeProperty> -->
    </excludeProperties>
    <!-- @since 2.1.14 -->
    <!--
                    只包含某类属性,和excludeProperties相对
                -->
    <includeOnlyProperties>
        <!-- <includeOnlyProperty>^git.commit.id.full$</includeOnlyProperty> -->
    </includeOnlyProperties>
    <!-- @since 2.2.3 -->
    <!--
                    属性替换,匹配到规则的属性值在某个阶段替换为另外的属性值
                -->
    <replacementProperties>
        <!--
                      example:
                      apply replacement only to the specific property git.branch and replace '/' with '-'
                      see also [issue 138](https://github.com/git-commit-id/git-commit-id-maven-plugin/issues/138)
                  <replacementProperty>
                    <property>git.branch</property>
                    <propertyOutputSuffix>something</propertyOutputSuffix>
                    <token>^([^\/]*)\/([^\/]*)$</token>
                    <value>$1-$2</value>
                    <regex>true</regex>
                    <forceValueEvaluation>false</forceValueEvaluation>
                    <transformationRules>
                      <transformationRule>
                        <apply>BEFORE</apply>
                        <action>UPPER_CASE</action>
                      </transformationRule>
                      <transformationRule>
                        <apply>AFTER</apply>
                        <action>LOWER_CASE</action>
                      </transformationRule>
                    </transformationRules>
                  </replacementProperty>
                  -->
    </replacementProperties>
    <!-- @since 2.1.10 -->
    <!--
                    默认false,此插件附带自定义的“jgit”实现,用于获取所有相关信息。如果设置为“true”,则此插件将使用本机“git”二进制文件而不是自定义的“jgit”, 也可以使用以下命令开启 `-Dmaven.gitcommitid.nativegit=true`
                -->
    <useNativeGit>false</useNativeGit>
    <!-- @since 3.0.0 -->
    <!--
                    默认情况下,此超时设置为30000(30秒),允许指定使用本机获取信息的超时(毫秒)
                -->
    <nativeGitTimeoutInMs>30000</nativeGitTimeoutInMs>
    <!-- @since v2.0.4 -->
    <!--
默认7,配置缩写git提交id的长度(`git.commit.id.abbrev`)到长度至少为N。`0'具有特殊含义(签出git/git文档描述-描述.md)对于特殊情况,缩写为0)。最大值为“40”,因为最大SHA-1长度。
                 -->
    <abbrevLength>7</abbrevLength>
    <!-- @since v2.2.0 -->
    <!--
                    目前,交换机允许两种不同的选择:1默认情况下,此属性设置为“flat”,并将生成以前已知的财产`git.commit.id`就像以前版本的插件一样。保持默认情况下,它将“flat”保留向后兼容性,不需要进一步的操作最终用户调整。2如果将此开关设置为“full”,则插件将导出以前已知的属性`git.commit.id`作为`git.commit.id.full`因此将生成完全有效的导出机制中的json对象。
                -->
    <commitIdGenerationMode>flat</commitIdGenerationMode>
    <!-- @since 2.1.0 -->
    <!--
                    可以用作非常强大的版本控制助手, 可以参考https://git-scm.com/docs/git-describe
                -->
    <gitDescribe>
        <!--
                        默认false, 如果true,则不使用该配置
                    -->
        <skip>false</skip>
        <!--
                       默认true,
在某些情况下,在提交附近找不到标记(例如,通常在执行浅克隆)。如果将其设置为“true”,并且未找到标记,则此属性将改为回退到提交的id(当“true”时,此属性不会变为空)
                    -->
        <always>true</always>
        <!--
                        在describe输出中,哈希的对象id总是缩写为N个字母(默认为7)
                    -->
        <abbrev>7</abbrev>
        <!--
                        Default (optional):
                        -dirty
                        在处于“脏状态”(未提交)的存储库上运行“描述”时更改),说明输出将包含一个附加后缀
                    -->
        <dirty>-dirty</dirty>
        <!--
                        默认:*,包含所有信息,
Git describe可能包含标记名的信息。将此配置设置为仅考虑与给定模式匹配的标记。这可以用来避免从存储库泄漏私有标记。
                    -->
        <match>*</match>
        <!--
默认false,运行git describe时,默认情况下只查找*带注释的标记*。如果您希望在描述中也考虑*轻量级标记*,则需要把这个转换成'true'。
                        depth here: https://github.com/git-commit-id/git-commit-id-maven-plugin/#git-describe-and-a-small-gotcha-with-tags
                    -->
        <tags>false</tags>
        <!--
默认情况下,如果当前提交被标记,git descripe只返回标记名。将此选项设置为“true”以强制它使用典型的describe格式化输出格式(“${tag name}-${committes_from_tag}-g${commit_id-maybe_dirty}”),即使是“on”标记
                    -->
        <forceLongFormat>false</forceLongFormat>
    </gitDescribe>
    <!-- @since 2.2.2 -->
    <!--
附加的验证实用程序,可用于验证项目属性是否设置
                -->
    <validationProperties>
        <validationProperty>
            <!--
用于识别验证的描述性名称,不匹配(将显示在错误消息中)
                        -->
            <name>validating project version</name>
            <!--
                             需要验证的值*注意*:为了能够验证在pom本身您可能需要设置配置`<injectAllReactorProjects>true</injectAllReactorProjects>`。
                        -->
            <value>${project.version}</value>
            <!--
                            the expected value
                        -->
            <shouldMatchTo><![CDATA[^.*(?<!-SNAPSHOT)$]]></shouldMatchTo>
        </validationProperty>
        <!-- the next validationProperty you would like to validate -->
    </validationProperties>
    <!-- @since 2.2.2 -->
    <!--
                    默认true,如果有与预期不符,则校验失败
                -->
    <validationShouldFailIfNoMatch>true</validationShouldFailIfNoMatch>
    <!-- @since 2.2.4 -->
    <!--默认值(可选):默认情况下,此属性只需设置为“HEAD”,它应该引用最新的在存储库中提交。
说明:
允许告诉插件应该使用什么提交作为生成属性来自。
一般情况下,可以将此属性设置为“HEAD^1”或指向分支或标记名称。为了支持任何类型或用例,也可以设置此配置整个提交哈希或它的缩写版本。
                -->
    <evaluateOnCommit>HEAD</evaluateOnCommit>
    <!-- @since 3.0.0 -->
    <!--
默认true,当设置为“true”时,此插件将尝试使用生成环境中的分支名称。
                -->
           useBranchNameFromBuildEnvironment>true</useBranchNameFromBuildEnvironment>

<!-- @since 3.0.0 -->
<!--
默认true,说明:
当设置为“true”时,此插件将尝试将生成的属性公开到`System.getProperties()`. 设置为{@code'false'}以避免此曝光。 -->
<injectIntoSysProperties>true</injectIntoSysProperties>
</configuration>



查询gitCommitInfo

通过编写一个接口,用来查询生成的GitCommitInfo,核心代码如下:


import lombok.extern.slf4j.Slf4j;
import org.springframework.util.ResourceUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.*;

@Slf4j
@RestController
@RequestMapping("/version")
public class VersionController {

    /**
     * 获取 git.json 中的内容
     * @return
     * @throws IOException
     */
    @GetMapping("/gitCommitId")
    public String getGitCommitId() throws IOException {
        //git.json  or  git.properties
        File file = ResourceUtils.getFile("classpath:git.json");
        if (file.exists()) {
            String s = "";
            InputStreamReader in = new InputStreamReader(new FileInputStream(file), "UTF-8");
            BufferedReader br = new BufferedReader(in);
            StringBuffer content = new StringBuffer();
            while ((s = br.readLine()) != null) {
                content = content.append(s);
            }
            return content.toString();
        } else {
            return "";
        }
    }
}

 

兼容性

与java的兼容性

  • java8:插件版本=4.x
  • java11:插件版本>=5

与maven的兼容性

  • maven3:插件版本=4.x
  • maven3.2.x:插件版本>=7

引用

BentoML 是一个开源的大语言模型(LLM) AI 应用的开发框架和部署工具,致力于为开发者提供最简单的构建大语言模型 AI 应用的能力,其开源产品已经支持全球数千家企业和组织的核心 AI 应用。

当 BentoML 在 Serverless 环境中部署模型时,其中一个主要挑战是冷启动慢,尤其在部署大型语言模型时更为明显。由于这些模型体积庞大,启动和初始化过程耗时很长。此外,由于 Image Registry 的带宽较小,会让大体积的 Container Image 进一步加剧冷启动缓慢的问题。为了解决这一问题,BentoML引入了JuiceFS。

JuiceFS 的 POSIX 兼容性和数据分块使我们能够按需读取数据,读取性能接近 S3 能提供的性能 的上限,有效解决了大型模型在 Serverless 环境中冷启动缓慢的问题。
使用 JuiceFS 后,模型加载速度由原来的 20 多分钟缩短至几分钟。
在实施 JuiceFS 的过程中,我们发现实际模型文件的读取速度与预期基准测试速度存在差异。通过一系列优化措施,如改进数据缓存策略和优化读取算法,我们成功解决了这些挑战。在本文中,我们将详细介绍我们面临的挑战、解决方案及相关优化。

01 BentoML 简介以及 Bento 的架构

在介绍模型部署环节的工作之前,首先需要对 BentoML 是什么以及它的架构做一个简要的介绍。

BentoML 是一个高度集成的开发框架,采用简单易用的方式,支持以开发单体应用的方式进行开发,同时以分布式应用的形式进行部署。这意味着开发者可以用很低的学习成本来快速开发一个高效利用硬件资源的大语言模型 AI 应用。BentoML 还支持多种框架训练出来的模型,包括 PyTorch、TensorFlow 等常用 ML 框架。起初,BentoML 主要服务于传统 AI 模型,但随着大型语言模型的兴起,如 GPT 的应用,BentoML 也能够服务于大语言模型。

BentoML 产生的制品称为 Bento,Bento 的角色类似于 Container Image,是用于 AI 应用部署的最基本单位,一个 Bento 可以轻松部署在不同的环境中,比如 Docker、EC2、AWS Lambda、AWS SageMaker、Kafka、Spark、Kubernetes。

一个 Bento 包含了业务代码、模型文件、静态文件,同时我们抽象出来了 API Server 和 Runner 的概念,API Server 是流量的入口,主要承载一些 I/O 密集型的任务,Runner 通常是执行模型的推理工作,主要承载一些 GPU/CPU 密集型的任务,从而可以将一个 AI 应用中不同硬件资源需求的任务进行轻松解耦。

BentoCloud 是一个使 Bento 可以部署在云上的平台,一般开发任务分为三个阶段:

  • 第一阶段:开发阶段

当项目使用 BentoML 进行 AI App 开发时,产生制品 Bento。此阶段 BentoCloud 的角色是 Bento Registry。

  • 第二阶段:集成阶段

若要将 Bento 部署到云环境中,需要一个 OCI 镜像(Container Image)。在这个阶段,我们有一个组件称为 yatai-image-builder,负责将 Bento 构建成 OCI 镜像,以便后续应用。

  • 第三阶段:部署阶段,也是本文的重点内容

这其中一个关键组件是 yatai-serverless。在这个阶段,yatai-serverless 负责将上一阶段构建的 OCI 镜像部署到云上。

02 Serverless 平台部署大模型的挑战

  • 挑战 1:冷启动慢

对于 Serverless 平台而言,冷启动时间至关重要。想象一下,当请求到达时,从零开始增加副本,这可能需要超过 5 分钟。在这段时间内,前面的某些 HTTP 基础设施可能认为已经超时,对用户体验不利。特别是对于大语言模型,其模型文件通常很大,可能达到十几到二十几 GB 的规模,导致在启动时拉取和下载模型的阶段非常耗时,从而显著延长冷启动时间。

  • 挑战 2:数据一致性问题

这是 Serverless 平台中特有的问题。我们的平台通过对 Bento 的一些建模解决了这些问题。

  • 挑战 3:数据安全性问题

这是将 Bento 部署到云上的主要原因之一,也是 BentoML 提供给用户的核心价值之一。众所周知,OpenAI 以及国内的一些大语言模型会提供一些 HTTP API 供用户使用,但由于许多企业或应用场景对数据安全性有极高的要求,因此他们不可能将敏感数据传递给第三方平台的 API 进行处理。他们希望将大型语言模型部署到自己的云平台上,以确保数据的安全性。

03 为什么使用 JuiceFS ?

接下来将详细探模型部署这一关键阶段的具体工作。下图展示了我们最初采用的架构,即将所有模型文件,包括 Python 代码、Python 依赖和扩展,都打包成一个 Container Image,然后在 Kubernetes 上运行。然而,这一流程面临着以下挑战:

  • 首先,一个 Container Image 由一系列 Layer 组成,因此 Container Image 最小的下载和缓存单位是 Layer,虽然在下载 Container Image 时,Container Image 的 Layer 是并行下载的,但 Layer 在解压的时候是串行的。当解压到模型所在的 Layer 时速度会减慢,同时占用大量的 CPU 时间。

  • 另一个挑战是当不同的 Bento 使用相同的模型时。这种架构会浪费多份相同的空间,并且被打包到不同的 Image 中,作为不同 Layer 存在,导致了多次下载和解压,这是极大的资源浪费。因此,这种架构无法共享模型。

在解决这个问题时,我们首选了 JuiceFS,主要因为它具有以下三个优势。

  • 首先,它采用 POSIX 协议,无需再加一层抽象就使我们能够以连贯的方式读取数据。

  • 其次,它可以达到很高的吞吐,可以接近整个 S3 或 GCS 的带宽。

  • 第三,它能够实现良好的共享模型。当我们将模型存储在 JuiceFS 中时,不同实例可以共享同一个大型语言模型。

下图是我们集成 JuiceFS 后的架构。在构建 Container Image 时,我们将模型单独提取并存储到 JuiceFS 中。Container Image 中仅包含用户的 Python 业务代码和 Python 运行所需的依赖和基础环境,这样的设计带来的好处是可以同时下载模型和运行,无需在本地解压模型。整体解压过程变得非常迅速,下载的数据量也大大减少,从而显著提升了下载性能。

此外,我们进一步优化了下载和缓存的颗粒度,不仅每个模型都有自己的缓存颗粒度,而且 JuiceFS 对大文件分割成了一系列 chunk,以 chunk 为单位进行下载和缓存,利用这个特性可以实现类似于大模型的 Stream Loading 的效果。

我们还充分利用了 GKE 的 Image Streaming 技术。通过 Model Streaming 和 Image Streaming 同时进行数据拉取,我们成功降低了启动时间,提升了整体性能。

04 集成 JuiceFS 时遇到的挑战

  • 挑战 1:无缝集成

在引入 JuiceFS 这一新组件时,必须处理如何与已有组件实现无缝集成的问题。这种情况是在任何较为成熟的平台引入新组件时都会遇到的普遍挑战。为了更好地继承 JuiceFS, 我们采用了 AWS MemoryDB,以代替自己运维的 Redis,从而降低架构的复杂度。

  • 挑战 2: 引入新组件对业务逻辑的影响

引入 JuiceFS 可能导致业务逻辑的变化。之前,Bento 的容器镜像包含了完整的模型,而现在的 Bento 容器镜像则不再携带模型。在 yatai-serverless 平台的部署中,我们必须在代码层面确保这两种不同的镜像在业务逻辑上实现相互兼容。为此,我们使用不同的 label 来区分不同版本的 bento,然后在代码逻辑里做向前兼容。

  • 挑战 3: JuiceFS 下载速度问题

在测试 JuiceFS 时发现,使用 JuiceFS 下载模型的速度非常慢,甚至比直接从镜像中下载还要慢。通过 JuiceFS 团队的协助,我们发现我们的 Boot Disk 是网络磁盘,所以我们一直使用网络磁盘作为 JuiceFS 的缓存盘,这就会导致一个奇怪的现象:不命中缓存时速度更快,一旦命中缓存就变慢。为了解决这个问题,我们为我们的 GKE 环境都添加了 Local NVME SSD,并将 Local NVMe SSD 作为 JuiceFS 的缓存盘,从而完美地解决了这一问题。

05 展望

在未来,我们将深入进行更多的可观测性工作,以确保整个架构保持良好的运行状态,并获得足够的指标以便更好地优化配置,尽量避免再次出现类似的问题。

希望可以高效利用 JuiceFS 自带的缓存能力。例如,将模型提前种植到 JuiceFS 后,这意味着在业务集群中,可以提前在节点中预热模型的缓存,从而进一步提升缓存和冷启动时间的性能。

希望这篇内容能够对你有一些帮助,如果有其他疑问欢迎加入
JuiceFS 社区
与大家共同交流。