初识Argo(一)

 


1|0Kubernetes扩展神器Argo

2|0一、关于Argo

Argo是一个开源的项目,其项目宗旨为:(Get stuff done with Kubernetes.:把Kubernetes的事情搞定。),为Kubernetes提供container-native工作流程,其主要通过Kubernetes CRD实现。

我的理解有两个意思:

  • 一是通过Argo能够更好地把应用运行在Kubernetes平台
  • 二是扩展Kubernetes的原生功能,实现原生Kubernetes没有完成的事。

特点如下:

  • 工作流的每一步都是一个容器
  • 将多步骤工作流建模为一系列任务,或者使用有向无环图(DAG)描述任务之间的依赖关系
  • 可以在短时间内轻松运行用于机器学习或数据处理的计算密集型作业
  • 在Kubernetes上运行CI/CD Pipeline,无需复杂的软件配置

2|11.1 Argo基本概念

    再熟悉下 Argo 的核心概念:

  • Workflow
    • Template:可以看作是function,Argo中的Template有两类:

      • 定义Template(具体的工作流)
        • container:最常用的模板类型,也是Argo的优势,它将调度一个container,其模板规范和K8S的容器规范相同
        • script:基于container,你可以写点什么,是Container的另一种包装实现,其定义方式和Container相同,只是增加了source字段用于自定义脚本;脚本的输出结果会根据调用方式自动导出到{{tasks..outputs.result}}或{{steps..outputs.result}}中
        • resouce:直接操作k8s的任何resource,在K8S集群上执行集群资源操作,可以 get, create, apply, delete, replace, patch集群资源
        • suspend:暂停一段时间,等同于 Thread.wait(int time),主要用于暂停,可以暂停一段时间,也可以手动恢复,命令使用argo resume进行恢复
          • Artifact:Argo跟perfer使用S3来存储artifact
      • 调用Template提供并行控制
        • steps:直接调用其他Template,主要是通过定义一系列步骤来定义任务,其结构是"list of lists",外部列表将顺序执行,内部列表将并行执行
        • dag:以DAG的方式调用其他Template,主要用于定义任务的依赖关系,可以设置开始特定任务之前必须完成其他任务,没有任何依赖关系的任务将立即执行;什么是dag?也就是 Directed Acyclic Graph 有向无环图,DAG 最后一个字母 G 指的就是 Graph 图,那 D 和 A 是什么意思呢? D 对应单词是 Directed 有向,也就是有明确的方向的意思。A 节点中有指向 B 节点的指针,而 B 节点中是没有指向 A 新节点的指针的,如果画出来就是一个从 A 到 B 的单向的箭头。在 DAG 中,一个节点到另外一个节点的指向是单向的,这就是 D 有向的含义。再说 A ,A 对应的单词是 Acyclic 无环,意思是整张图上不允许出现沿着箭头从一个节点出发最后能又回到起点的情况。总结起来,DAG 就是一个从任何节点出发,只要按照指针方向走,无论选择哪种路径都不能回到起点的图。
    • entrypoint:main function,第一个执行的 Template,因为之后的workflow可能会有多个模板,互相嵌套,那么设置一个entrypoint模板让工作流知道从哪个模板开始执行,类似于main函数,非entrypoint模板的就作为类似于函数的作用

      • variables:变量

1|0Container

- name: whalesay container: image: docker/whalesay command: [cowsay] args: ["hello world"]

1|0Script

- name: gen-random-int script: image: python:alpine3.6 command: [python] source: | import random i = random.randint(1, 100) print(i)

1|0Resouce

- name: k8s-owner-reference resource: action: create manifest: | apiVersion: v1 kind: ConfigMap metadata: generateName: owned-eg- data: some: value

1|0Suspend

- name: delay suspend: duration: "20s"

1|0Steps

- name: hello-hello-hello steps: - - name: step1 template: prepare-data - - name: step2a template: run-data-first-half - name: step2b template: run-data-second-half #其中step1和step2a是顺序执行,而step2a和step2b是并行执行 #还可以通过When来进行条件判断。如下: apiVersion: argoproj.io/v1alpha1 kind: Workflow metadata: generateName: coinflip- spec: entrypoint: coinflip templates: - name: coinflip steps: - - name: flip-coin template: flip-coin - - name: heads template: heads when: "{{steps.flip-coin.outputs.result}} == heads" - name: tails template: tails when: "{{steps.flip-coin.outputs.result}} == tails" - name: flip-coin script: image: python:alpine3.6 command: [python] source: | import random result = "heads" if random.randint(0,1) == 0 else "tails" print(result) - name: heads container: image: alpine:3.6 command: [sh, -c] args: ["echo \"it was heads\""] - name: tails container: image: alpine:3.6 command: [sh, -c] args: ["echo \"it was tails\""] #除了使用When进行条件判断,还可以进行循环操作,示例代码如下: apiVersion: argoproj.io/v1alpha1 kind: Workflow metadata: generateName: loops- spec: entrypoint: loop-example templates: - name: loop-example steps: - - name: print-message template: whalesay arguments: parameters: - name: message value: "{{item}}" withItems: - hello world - goodbye world - name: whalesay inputs: parameters: - name: message container: image: docker/whalesay:latest command: [cowsay] args: ["{{inputs.parameters.message}}"]

1|0Dag

apiVersion: argoproj.io/v1alpha1 kind: Workflow metadata: generateName: dag-diamond- spec: entrypoint: diamond templates: - name: diamond dag: tasks: - name: A template: echo arguments: parameters: [{name: message, value: A}] - name: B dependencies: [A] template: echo arguments: parameters: [{name: message, value: B}] - name: C dependencies: [A] template: echo arguments: parameters: [{name: message, value: C}] - name: D dependencies: [B, C] template: echo arguments: parameters: [{name: message, value: D}] - name: echo inputs: parameters: - name: message container: image: alpine:3.7 command: [echo, "{{inputs.parameters.message}}"] #其中A会立即执行,B和C会依赖A,D依赖B和C。 #提交workflow。 argo submit -n argo dag.yam --watch

1|0Variables

apiVersion: argoproj.io/v1alpha1 kind: Workflow metadata: generateName: hello-world-parameters- spec: entrypoint: whalesay arguments: parameters: - name: message value: hello world templates: - name: whalesay inputs: parameters: - name: message container: image: docker/whalesay command: [ cowsay ] args: [ "{{inputs.parameters.message}}" ] #首先在spec字段定义arguments,定义变量message,其值是hello world,然后在templates字段中需要先定义一个inputs字段,用于templates的输入参数,然后在使用"{{}}"形式引用变量。 #变量还可以进行一些函数运算,主要有: filter:过滤 asInt:转换为Int asFloat:转换为Float string:转换为String toJson:转换为Json #例子: filter([1, 2], { # > 1}) asInt(inputs.parameters["my-int-param"]) asFloat(inputs.parameters["my-float-param"]) string(1) toJson([1, 2])

1|0Artifact

apiVersion: argoproj.io/v1alpha1 kind: Workflow metadata: generateName: artifact-passing- spec: entrypoint: artifact-example templates: - name: artifact-example steps: - - name: generate-artifact template: whalesay - - name: consume-artifact template: print-message arguments: artifacts: - name: message from: "{{steps.generate-artifact.outputs.artifacts.hello-art}}" - name: whalesay container: image: docker/whalesay:latest command: [sh, -c] args: ["sleep 1; cowsay hello world | tee /tmp/hello_world.txt"] outputs: artifacts: - name: hello-art path: /tmp/hello_world.txt - name: print-message inputs: artifacts: - name: message path: /tmp/message container: image: alpine:latest command: [sh, -c] args: ["cat /tmp/message"] #其分为两步: 首先生成制品 然后获取制品

2|21.2 目前Argo包含多个子项目:

  • Argo Workflows:基于容器的任务编排工具。
  • Argo Rollouts:支持金丝雀以及蓝绿发布的应用渐进式发布工具。
  • Argo Events:事件驱动工具。
  • Argo CD:基于GitOps声明的持续交付工具。

本文接下来将分别介绍如上4个工具。

3|0二、Job编排神器Argo Workflow

3|12.1 Kubernetes Job的问题

    Kubernetes平台主要运行一些持续运行的应用,即daemon服务,其中最擅长的就是无状态服务托管,比如Web服务,滚动升级rollout和水平扩展scale out都非常方便。

    而针对基于事件触发的非持续运行的任务,Kubernetes原生能力可以通过Job实现,不过,Job仅解决了单一任务的执行,目前Kubernetes原生还没有提供多任务的编排能力,无法解决多任务的依赖以及数据交互问题。

    比如启动一个测试任务,首先需要从仓库拉取最新的代码,然后执行编译,最后跑批单元测试。这些小的子任务是串行的,必须在前一个任务完成后,才能继续下一个任务。

    如果使用Job,不太优雅的做法是每个任务都轮询上一个任务的状态直到结束。或者通过initContainers实现,因为initContainer是按顺序执行的,可以把前两个任务放initContainer,最后单元测试放到主Job模板中,这样Job启动时前面的initContainer任务保证是成功执行完毕。

    不过initContainer只能解决非常简单的按顺序执行的串行多任务问题,无法解决一些复杂的非线性任务编排,这些任务的依赖往往形成一个复杂的DAG(有向图),比如:

    图中B、C任务依赖于A,必须等待A完成之后才能继续,A完成后B、C两个任务是可以并行的,因为彼此并无依赖,但D必须等待B、C都完成后才能继续。

    这种问题通过Kubernetes的原生能力目前还不能很好的解决。

    以一个实际场景为例,我们需要实现iPaaS中间件在公有云上自动部署,大致为两个过程,首先通过Terraform创建虚拟机,然后通过Ansible实现中间件的自动化部署和配置。如果使用Kubernetes Job,需要解决两个问题:

    Terraform创建虚拟机完成后如何通知Ansible?
    Terraform如何把虚拟机的IP、公钥等信息传递给Ansible,如何动态生成inventory?
显然如果单纯使用Kubernetes Job很难完美实现,除非在容器中封装一个很复杂的逻辑,实现一个复杂的编排engine,这就不是Job的问题了。

3|22.2 Argo workflow介绍

    Argo workflow专门设计解决Kubernetes工作流任务编排问题,这个和OpenStack平台的Mistral项目作用类似。
Workflow是Argo中最重要的资源,其主要有两个重要功能:

  • 它定义要执行的工作流
  • 它存储工作流程的状态

要执行的工作流定义在Workflow.spec字段中,其主要包括templates和entrypoint,如下:

apiVersion: argoproj.io/v1alpha1 kind: Workflow metadata: generateName: hello-world- # Workflow的配置名称 spec: entrypoint: whalesay # 表示第一个执行的模板名称,让工作流知道从哪个模板开始执行,类似于 main 函数 templates: # 以下是模板内容 - name: whalesay # 定义whalesay templates名称,和entrypoint保持一致 container: # 定义一个容器,输出"helloworld" image: docker/whalesay # 调用 docker/whalesay 镜像 command: [cowsay] # 调用 cowsay 命令 args: ["hello world"] # 执行内容

上面的任务可以很轻易地通过Workflow编排:

apiVersion: argoproj.io/v1alpha1 kind: Workflow metadata: generateName: steps- # Workflow 的名称前缀 spec: entrypoint: hello-hello-hello # 表示第一个执行的模板名称,让工作流知道从哪个模板开始执行,类似于 main 函数 # 该templates中有两个模板,分别是:hello-hello-hello和whalesay templates: - name: hello-hello-hello # 第一个模板 hello-hello-hello steps: # template 的类型是 steps # 一个 template 有多种类型,分别为:container、script、dag、steps、resource、suspend - - name: hello1 # 在 steps 类型中,[--] 表示顺序执行,[-] 表示并行执行 template: whalesay # 引用 whalesay 模板 arguments: # 传递给函数的参数 parameters: # 声明参数 - name: message # Key value: "hello1" # value - - name: hello2a # [--] 顺序执行 template: whalesay arguments: parameters: - name: message value: "hello2a" - name: hello2b # [-] 表示跟上一步并行运行 template: whalesay arguments: parameters: - name: message value: "hello2b" - name: whalesay # 第二个模板 whalesay inputs: # input、output 实现数据交互 parameters: - name: message container: image: docker/whalesay # 镜像名称 command: [cowsay] # 执行命令 args: ["{{inputs.parameters.message}}"] # 参数引用

    steps定义任务的执行步骤,其中--表示与前面的任务串行,即必须等待前面的任务完成才能继续。而-表示任务不依赖于前一个任务,即可以与前一个任务并行。

    因为Workflow实现了Kubernetes的CRD,因此提交workflow任务可以直接通过kubectl apply,当然也可以通过argo submit提交。

argo submit step-demo.yaml

查看任务状态:

从状态图中也可以看出hello2a和hello2b是并行执行的。

3|32.3 DAG图

    通过steps可以很方便的定义按顺序执行的线性任务,不过如果任务依赖不是线性的而是多层树依赖,则可以通过dag进行定义,dag即前面介绍的DAG有向无环图,每个任务需要明确定义所依赖的其他任务名称。

dag: tasks: - name: hello1 template: whalesay arguments: parameters: - name: message value: "hello1" - name: hello2 dependencies: [hello1] template: whalesay arguments: parameters: - name: message value: "hello2" - name: hello3 dependencies: [hello1] template: whalesay arguments: parameters: - name: message value: "hello3" - name: hello4 dependencies: [hello2, hello3] template: whalesay arguments: parameters: - name: message value: "hello4" - name: hello5 dependencies: [hello4] template: whalesay arguments: parameters: - name: message value: "hello5"

dag中的tasks通过dependencies明确定义依赖的任务,如上DAG如图:

3|42.4 分支、循环与递归

    除了正向依赖关系,Workflow还支持分支、循环、递归等,以官方的一个硬币分支为例:

templates: - name: coinflip steps: - - name: flip-coin template: flip-coin - - name: heads template: heads when: "{{steps.flip-coin.outputs.result}} == 1" - name: tails template: tails when: "{{steps.flip-coin.outputs.result}} == 0" - name: flip-coin script: image: python:alpine3.6 command: [python] source: | import random print(random.randint(0,1)) - name: heads container: image: alpine:3.6 command: [sh, -c] args: ["echo \"it was heads\""] - name: tails container: image: alpine:3.6 command: [sh, -c] args: ["echo \"it was tails\""]

如上flip-coin通过Python随机生成0或者1,当为1时heads任务执行,反之tails任务执行:

如上由于结果为0,因此heads没有执行,而tails执行了,并且输出了it was tails。

3|52.5 input与output

    任务之间除了定义依赖关系,还可以通过input、output实现数据交互,即一个task的output可以作为另一个task的input。

templates: - name: output-parameter steps: - - name: generate-parameter template: whalesay - - name: consume-parameter template: print-message arguments: parameters: - name: message value: "{{steps.generate-parameter.outputs.parameters.hello-param}}" - name: whalesay container: image: docker/whalesay:latest command: [sh, -c] args: ["echo -n hello world > /tmp/hello_world.txt"] outputs: parameters: - name: hello-param valueFrom: path: /tmp/hello_world.txt - name: print-message inputs: parameters: - name: message container: image: docker/whalesay:latest command: [cowsay] args: ["{{inputs.parameters.message}}"]

如上generate-parameter通过whalesay输出hello world到/tmp/hello_world.txt上并作为outputs输出。而print-message直接读取了generate-parameter outputs作为参数当作inputs。

3|62.6 Artifacts

    除了通过input和output实现数据交互,对于数据比较大的,比如二进制文件,则可以通过Artifacts制品进行共享,这些制品可以是提前准备好的,比如已经存储在git仓库或者s3中,也可以通过任务生成制品供其他任务读取。

如下为官方的一个例子:

apiVersion: argoproj.io/v1alpha1 kind: Workflow metadata: generateName: artifact-example- spec: entrypoint: main templates: - name: main steps: - - name: generate-artifact template: whalesay - - name: consume-artifact template: print-message arguments: artifacts: - name: message from: "{{steps.generate-artifact.outputs.artifacts.hello-art}}" - name: whalesay container: image: docker/whalesay:latest command: [sh, -c] args: ["sleep 1; cowsay hello world | tee /tmp/hello_world.txt"] outputs: artifacts: - name: hello-art path: /tmp/hello_world.txt - name: print-message inputs: artifacts: - name: message path: /tmp/message container: image: alpine:latest command: [sh, -c] args: ["cat /tmp/message"]

    如上generate-artifact任务完成后output输出一个名为hello-art的制品,这个制品会把/tmp/hello_world.txt这个文件打包后上传到制品库中,默认制品库可以通过configmap配置,通常是放在S3上。

print-message会从制品库中读取hello-art这个制品内容并输出。

3|72.7 其他功能

    前面涉及的任务都是非持续运行任务,Workflow也支持后台Daemon任务,但是一旦所有的任务结束,即整个workflow完成,这些Daemon任务也会自动删除,这种场景主要用于自动化测试,比如产品API测试,但是API可能依赖于数据库,此时可以通过Workflow的task先启动一个数据库,然后执行自动化测试,测试完成后会自动清理环境,非常方便。

    另外,Workflow的template中container和Pod的Container参数基本类似,即Pod能使用的参数Workflow也能用,比如PVC、env、resource request/limit等。

3|82.8 总结

    Job解决了在Kubernetes单次执行任务的问题,但不支持任务的编排,难以解决多任务之间的依赖和数据共享。Argo Workflow弥补了这个缺陷,支持通过yaml编排Job任务,并通过input/output以及artifacts实现Job之间数据传递。

4|0三、Deployment扩展之Argo Rollout

4|13.1 Kubernetes应用发布

    早期Kubernetes在还没有Deployment时,可以认为应用是不支持原地滚动升级的,虽然针对ReplicationController,kubectl看似有一个rolling-update的自动升级操作,但这个操作的步骤其实都是客户端实现的,比如创建新版本ReplicationContrller,增加新版本副本数减少老版本副本数都是客户端通过调用api-server实现,如果本地网络故障或者kubectl进程异常退出,则会导致升级失败,使RC处于半升级异常状态。

    而后Deployment出现,ReplicationContrller废弃被Replicasets替代,Kubernetes应用渐进式滚动升级完美解决,整个步骤都是由Deployment Controller负责的,无需客户端干预,并且还支持了应用的版本管理,可以很方便的回滚到任意版本上。

    Deployment还支持配置maxSurge、maxUnavailable控制渐进式版本升级过程,但目前原生还不支持版本发布策略,比如常见的金丝雀发布、蓝绿发布等。

    当然你可以通过手动创建一个新的Deployment共享一个Service来模拟金丝雀和蓝绿发布,不过这种方式只能手动去维护应用版本和Deployment资源,而集成外部工具比如Spinnaker则会比较复杂。

4|23.2 Argo Rollout

    Argo Rollout可以看做是Kubernetes Deployment的功能扩展,弥补了Deployment发布策略的功能缺失部分,支持通过.spec.strategy配置金丝雀或者蓝绿升级发布策略。

把一个Deployment转化成Rollout也非常简单,只需要:

apiVersion由apps/v1改成argoproj.io/v1alpha1。
kind由Deployment改成Rollout。
在原来的.spec.strategy中增加canary或者bluegreen配置。

4|33.3 金丝雀发布

以Kubernetes经典教程的Kubernetes-bootcamp为例:

apiVersion: argoproj.io/v1alpha1 kind: Rollout metadata: labels: app: canary-demo name: canary-demo spec: replicas: 5 selector: matchLabels: app: canary-demo strategy: canary: steps: - setWeight: 20 - pause: {} - setWeight: 40 - pause: {duration: 10m} - setWeight: 60 - pause: {duration: 10m} - setWeight: 80 - pause: {duration: 10m} template: metadata: labels: app: canary-demo spec: containers: - image: jocatalin/kubernetes-bootcamp:v1 name: kubernetes-bootcamp

    字段配置和Deployment基本完全一样,主要关注.spec.strategy,这里定义了金丝雀canary策略,发布共分为8个步骤,其中pause为停止,如果没有指定时间则会一直处于停止状态,直到有外部事件触发,比如通过自动化工具触发或者用户手动promote。

    第一步设置weight为20%,由于一共5个副本,因此升级20%意味着只升级一个副本,后续的40%、60%、80%依次类推。

    创建完后我们直接通过kubectl edit修改镜像为jocatalin/kubernetes-bootcamp:v2,此时触发升级。

我们查看rollout实例如下:

    我们发现新版本有一个副本,占比20%。

    由于我们没有通过canaryService以及stableService,因此Service没有做流量分割,大概会有20%的流量会转发给到新版本。当然这种流量切割粒度有点粗略,如果想要更细粒度的控制流量,可以通过ingress或者istio实现基于权值的流量转发策略。

    如果在.spec.strategy中指定了canaryService以及stableService,则升级后会做流量分割,canaryService只会转发到新版本流量,而stableService则只转发到老版本服务,这是通过修改Service的Selector实现的,升级后会自动给这两个Service加上一个Hash。

手动执行promote后进入下一步,此时新版本为40%:

由此可见,我们可以通过定义canary策略,使用rollout渐进式的发布我们的服务。

4|43.4 蓝绿发布

    与金丝雀发布不一样,蓝绿发布通常同时部署两套完全一样的不同版本的服务,然后通过负载均衡进行流量切换。

rollout支持blueGreen策略,配置也非常简单,如下:

apiVersion: argoproj.io/v1alpha1 kind: Rollout metadata: labels: app: bluegreen-demo name: bluegreen-demo spec: replicas: 5 selector: matchLabels: app: bluegreen-demo strategy: blueGreen: activeService: bluegreen-active previewService: bluegreen-preview autoPromotionEnabled: false template: metadata: labels: app: bluegreen-demo spec: containers: - image: jocatalin/kubernetes-bootcamp:v1 name: kubernetes-bootcamp

    如上配置了blueGreen策略,相比canary配置会更简单,其中配置了两个Service,分别为activeService和previewService,分别负责老版本和新版本的流量转发。

我们修改image为v2后,查看rollout信息如下:

    我们发现同时部署了一个新版本和老版本,通过不同的Service访问不同的版本,基本可以等同于部署了两个Deployment。

执行promote后老版本默认会在30秒后自动销毁,并自动把active指向新版本。

4|53.5 Analysis

    无论是采用何种发布策略,在新版本正式上线前,通常都需要进行大量的测试,只有测试没有问题之后才能安全地切换流量,正式发布到新版本。

    测试既可以手动测试,也可以自动测试。前面我们的canary和bluegreen Demo都是手动promote发布的,这显然不是效率最高的方法,事实上rollout提供了类似Kayenta的自动化测试分析的工具,能够在金丝雀或者蓝绿发布过程中自动进行分析测试,如果新版本测试不通过,则升级过程会自动终止并回滚到老版本。

测试的指标来源包括:

prometheus: 通过prometheus的监控指标分析测试结果,比如服务如果返回5xx则测试不通过。
kayenta: 通过kayenta工具分析。
web: web测试,如果结果返回OK则测试通过,可以使用服务的healthcheck接口进行测试。
Job: 自己定义一个Job进行测试,如果Job返回成功则测试通过。
这里以Job为例,配置Analysis模板为例:

apiVersion: argoproj.io/v1alpha1 kind: AnalysisTemplate metadata: name: analysis-demo spec: metrics: - name: analysis-demo interval: 10s failureLimit: 3 provider: job: spec: backoffLimit: 0 template: spec: containers: - name: test image: busybox imagePullPolicy: IfNotPresent command: - sh - -c - '[[ $(expr $RANDOM % 2) -eq 1 ]]' restartPolicy: Never

这个Job没有意义,只是随机返回成功和失败,如果失败次数超过3则认为整个分析过程失败。

我们仍然以前面的金丝雀发布为例,加上Analysis如下:

apiVersion: argoproj.io/v1alpha1 kind: Rollout metadata: labels: app: canary-demo name: canary-demo spec: replicas: 5 selector: matchLabels: app: canary-demo strategy: canary: analysis: templates: - templateName: analysis-demo # 引用analysis模板 steps: - setWeight: 20 - pause: {duration: 2m} - setWeight: 40 - pause: {duration: 2m} - setWeight: 60 - pause: {duration: 2m} - setWeight: 80 - pause: {duration: 2m} template: metadata: labels: app: canary-demo spec: containers: - image: jocatalin/kubernetes-bootcamp:v1 imagePullPolicy: IfNotPresent name: kubernetes-bootcamp

部署如上应用并通过kubectl edit修改image为kubernetes-bootcamp:v2,查看rollout信息如下:

当失败次数超过3时,发布失败,自动降级回滚:

4|63.6 总结

Argo Rollout可以认为是Deployment的扩展,增加了蓝绿发布和金丝雀发布策略配置,并且支持通过自动测试实现服务发布或者回滚。


__EOF__

本文作者_安阳
本文链接https://www.cnblogs.com/msfyang/p/16145210.html
关于博主:评论和私信会在第一时间回复。或者直接私信我。
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
声援博主:如果您觉得文章对您有帮助,可以点击文章右下角推荐一下。您的鼓励是博主的最大动力!
posted @   _安阳  阅读(665)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?
点击右上角即可分享
微信分享提示