写点什么

我在云上试了下流行的十二要素开发方法论

2019 年 12 月 11 日

我在云上试了下流行的十二要素开发方法论

开发人员正在把应用程序迁移到云上,于是他们在设计及部署云原生应用程序方面也积累了越来越多的经验。从这种经验中出现了一组最佳实践(俗称十二要素)。考虑这些要素来设计应用程序,可以让我们在把应用程序部署到云上时,能有更高的可移植性和弹性,相比之下,部署到内部环境中的应用程序则需要花更长的时间来提供新资源。



本文描述了流行的十二要素APP方法论,以及开发运行于谷歌云平台(Google Cloud Platform,简称 GCP)上的应用程序时如何应用它。我们使用这种方法可以开发出可扩展和具有弹性的应用程序,这些应用程序能够以最大灵活性持续地部署。本文旨在供那些熟悉 GCP、版本控制、持续集成和容器技术的开发人员参考。


十二要素

十二要素设计还有助于解耦应用程序的组件,从而可以轻松地替换每个组件,或无缝地上下扩展。因为这些要素是独立于任何编程语言或软件堆栈的,所以十二要素设计可以应用于各种各样的应用程序。


1. 代码库(Codebase)

应该在版本控制系统中(如 Git 或 Mercurial)跟踪应用程序的代码。处理应用时,可以在本地开发环境中确认代码。在版本控制系统中存储代码可以提供对代码更改的审计跟踪、解决合并冲突的系统方法及将代码回滚到以前版本的能力,从而让团队合作开发。它还提供了一个进行持续集成(continuous integration,简称 CI)和持续部署(continuous deployment,简称 CD)的地方。


尽管开发人员可能在他们的开发环境中处理不同版本的代码,但在任何给定的时间,事实来源都是版本控制系统中的代码。存储库中的这些代码是构建、测试和部署的内容,并且,存储库的数量与环境的数量无关。存储库中的代码用于产生单个构建,和特定于环境的配置结合在一起用于产生一个不可变的版本(无法修改,包括对配置的修改),然后,它可以被部署到一个环境中(这个版本所需的任何更改都将导致一个新版本的产生。)


云源代码存储库让我们能够在一个功能齐全、可扩展的私有 Git 存储库中协作和管理我们的代码。它具有跨所有存储库的代码搜索功能。我们还可以连接到其他 GCP 产品,例如云构建(Cloud Build)应用引擎(App Engine)Starkdriver云发布/订阅(Cloud Pub/Sub)


2. 依赖项

在谈到十二要素 APP 的依赖项时,有两个注意事项:依赖项声明和依赖项隔离。


十二要素 APP 应该永远没有隐式依赖性。我们应该显式声明任何依赖项并让它们进入版本控制中。这使我们能够用可重复的方式快速地开始使用代码,并更容易跟踪依赖项的更改。很多编程语言提供一种显式声明依赖项的方式,比如 Python 的 pip 和 Ruby 的 Bundler。


我们还应该把应用程序及其依赖项封装到容器中,从而把它们隔离开来。容器使我们能够把应用程序及其依赖项与其环境隔离开来,这样无论开发和运行环境有任何不同,都能确保应用程序一致地工作。


容器注册表(Container Registry )是供团队管理映像及进行漏洞分析的唯一去处。它还提供了对容器映像的细粒度访问,让我们决定谁可以访问什么。由于容器注册表使用云存储桶作为服务容器映像的后端,因此,我们可以通过调整该云存储桶的权限,控制谁可以访问容器注册表映像。


现有的 CI/CD 集成还让我们设置全自动管道,以获得快速反馈。我们可以推送映像到它们的注册表,然后使用 HTTP 端点从任何机器上来拉取映像,无论这些机器是计算引擎实例( Compute Engine instance)还是我们自己的硬件。接着,容器分析可以为容器注册表中的映像提供漏洞信息。


3. 配置

每个现代应用程序都需要某种形式的配置。通常,我们对每个环境(如开发环境、测试环境和生产环境)都有不同的配置。这些配置经常包括服务账户凭据和数据库等支持服务的资源句柄。


每个环境的配置都应该在代码的外部,并且不应该进入版本控制。每个人只处理代码的一个版本,但我们有多个配置。部署环境决定使用哪个配置。这可以让二进制代码的一个版本部署到每个环境中,其中唯一的不同是运行时配置。一个检查配置是否已经正确地外部化的简单方法是,检查是否可以在不泄露任何凭据的情况下公开代码。


配置外部化的方法之一是创建配置文件。然而,配置文件通常特定于编程语言或开发框架。


一个更好的方法是在环境变量中存储配置。这些环境变量容易在运行时针对每个环境进行更改,它们不太可能进入版本控制,并且,它们与编程语言和开发框架无关。在谷歌 Kubernetes 引擎(Google Kubernetes Engine,简称 GKE)中,我们可以使用ConfigMaps。这让我们可以在运行时将环境变量、端口号、配置文件、命令行参数以及其他配置工件绑定到 pod 容器和系统组件 。


4. 支持系统

应用程序正常操作时使用的每个服务(如文件系统、数据库、缓冲系统和消息队列)都应该作为服务被访问并在配置中外部化。我们应该考虑把这些支持服务作为底层资源的抽象。比如,当应用程序把数据写入存储时,把存储作为支持服务对待,可以让我们无缝地更改底层存储类型,这是因为它已与应用程序分离。这样一来,我们就可以执行一个更改,如从本地 PostgreSQL 数据库切换到Cloud SQL的PostgreSQL,而无需更改应用程序的代码。


5. 构建、发布、运行

重要的是要把软件部署过程分成三个截然不同的阶段:构建、发布及运行。每个阶段都应该形成一个唯一可识别的工件。每个部署都应该链接到一个特定版本,它是环境配置和内部版本结合形成的结果。这使回滚变得更加容易,每个产品部署历史都有一个可见的审计踪迹。


我们可以手动触发构建阶段,但在我们提交通过了所有要求的测试的代码时,通常会自动触发该阶段。构建阶段获取代码、获取所需的库和资源,并把这些打包到一个自包含的二进制文件或容器中。构建阶段的结果就是构建工件。


构建阶段完成时,发布阶段把构建工件和特定环境的配置结合在一起。这会生成一个版本。该版本可以通过持续部署应用程序自动地部署到环境中。或者可以通过同一个持续部署应用程序触发该版本。


最后,运行阶段推出该版本并启动之。比如,如果我们要部署到 GKE,那么云构建(Cloud Build)可以调用 gke-deploy 构建步骤以部署到我们的 GKE 集群中。云构建可以使用 YAML 或 JSON 格式的构建配置文件,跨多种编程语言和环境,管理并自动化构建、发布和运行阶段。


6. 进程

可以把十二要素 APP 作为一个或更多进程运行在环境中。这些进程应该是无状态的,相互之间不应该共享数据。这使得这些应用可以通过复制其进程来扩展。创建无状态应用还使进程可跨计算基础设施移植。


如果我们已习惯“粘性”会话的概念,那么就需要我们改变对处理和持久化数据的看法。这是因为进程可以随时消失,而我们无法依赖本地存储的可用内容,否则任何后续请求将由同一进程处理。因而,我们必须明确保留所有需要在外部支持服务(如数据库)中重用的数据。


如果需要持久化数据,那么可以使用Cloud Memorystore,把它作为支持服务以缓存我们应用程序的状态,并在进程之间共享公共数据,以鼓励松散耦合。


7. 端口绑定

在非云环境中,web 应用程序常常被编写成在应用程序容器中运行,这些容器包括 GlassFish、Apache Tomcat 和 Apache HTTP Server。与之相反,十二要素 APP 不依赖外部应用程序容器,而是绑定 webserver 库,使之成为该应用程序本身的一部分。


服务公开由PORT环境变量指定的端口号是一种体系结构的最佳实践。


使用平台即服务模型时,导出端口绑定的应用程序能够在外部使用端口绑定信息(作为环境变量)。在 GCP 中,可以在平台服务上部署应用程序,这些平台服务包括计算引擎(Compute Engine)、GKE、应用引擎(App Engine)或云运行(Cloud Run)。


在这些服务中,路由层把请求从面向公共的主机名路由到端口绑定的 web 进程。比如,在把应用程序部署到应用引擎时,需要声明给应用程序添加 webserver 库的依赖项,如 Express(用于 Node.js)、Flask 和 Gunicorn(用于 Python)或 Jetty(用于 Java)。我们不应该在代码中硬编码端口号。相反,我们应该在环境中提供端口号,比如在一个环境变量中提供。这使得我们的应用程序在 GCP 上运行时具有可移植性。


由于 Kubernetes 具有内置的服务发现(service discovery)功能,在 Kubernetes 中,我们可以把服务端口映射到容器来抽象端口绑定。服务发现使用内部 DNS 名来完成。


与硬编码 webserver 侦听的端口相反,配置使用了环境变量。以下所示的代码截自一个应用引擎应用程序,显示了如何接收在环境变量中传递的端口值。


const express = require('express')const request = require('got')const app = express()app.enable('trust proxy')const PORT = process.env.PORT || 8080app.listen(PORT, () => {  console.log('App listening on port ${PORT}')  console.log('Press Ctrl+C to quit.')})
复制代码


8. 并发性

我们应该基于进程类型(如后台、web、工作进程),把应用程序分解成独立的进程。这使我们的应用程序根据单个工作负载要求进行上下扩展。大多数云原生的应用程序允许我们按需扩展。我们应该把应用程序设计为多个分布式进程,这些进程能够独立地执行工作块,并通过添加更多的进程进行扩展。


以下内容描述了一些结构,以便应用程序扩展变得可行。用可处置性和无状态性的原则为核心构建的应用程序可以很好地从这些水平扩展的结构中受益。


使用应用引擎


我们可以使用应用引擎把我们的应用程序托管到 GCP 的托管基础设施上。实例是计算单元,这些计算单元是应用引擎用来自动扩展应用程序的。在任何给定的时间,我们的应用程序可以运行在一个实例或多个实例上,而请求则被分散到所有这些实例上。


应用引擎调度程序决定如何处理每个新的请求。该调度程序可能使用一个现有的实例(或者是空闲的,或者是接受并发请求的),把该请求放到一个待处理的请求队列中,或为该请求启动一个新的实例。该决定要考虑可用实例的数量、我们的应用程序处理请求的速度(延迟),以及启动一个新的实例所需的时间。


如果我们使用自动扩展,那么,我们可以通过设置目标 CPU 利用率、目标吞吐量和最大并发请求,在性能和成本之间进行平衡。


我们可以在 app.yaml 文件中指定扩展的类型,app.yaml 文件是我们为获取服务版本上传的文件。根据这个配置输入,该应用引擎基础设施将使用动态或常驻实例。关于扩展类型的更多信息,请参考应用引擎文档


使用计算引擎


或者,我们可以在计算引擎上部署和管理应用程序。在这种情况下,我们可以根据 CPU 利用率、正在处理的请求或其他来自应用程序的遥测信号,使用托管实例组(managed instance groups,简称MIG)扩展应用程序来响应可变负载


下图说明了托管实例组提供的关键功能。



使用托管实例组可以让我们的应用程序扩展到传入的需求并具有高可用性。这个概念对于无状态应用程序(如 web 前端)和基于批处理、高性能的工作负载非常适用。


使用云函数(Cloud Function )


云函数是无状态、单一目的的函数,它们运行在 GCP 上,其运行的底层架构是由谷歌为我们管理的。云函数响应事件触发器(如,一次到云存储桶的上传或云发布/订阅消息)。每个函数调用对单个事件或请求做出响应。


云函数通过把传入的请求分配给函数的实例进行处理。当入站请求量超过现有实例的数量时,云函数可能启动新的实例来处理请求。这种自动、完全托管的扩展行为允许云函数并行处理很多请求,每个请求都使用函数的不同实例。


使用 GKE 自动扩展


有些关键 Kubernetes 结构适用于扩展进程:


  • 水平Pod自动扩展(Horizontal Pod Autoscaling,简称HPA)。根据标准或自定义指标,可以将 Kubernetes 配置为扩展运行于集群中 pod 的数量。当我们需要对 GKE 集群上的可变负载做出响应时,这很方便。以下的 HPA YAML 文件例子显示了如何根据平均 CPU 利用率,通过设置最多 10 个 pod,为部署配置扩展。


apiVersion: autoscaling/v2beta2kind: HorizontalPodAutoscalermetadata:  name: my-sample-web-app-hpa  namespace: devspec:  scaleTargetRef:    apiVersion: apps/v1    kind: Deployment    name: my-sample-web-app  minReplicas: 1  maxReplicas: 10  metrics:  - type: Resource    resource:      name: cpu      target:        type: Utilization        averageUtilization: 60
复制代码


  • 节点自动扩展。在需求增长的时候,我们可能需要扩展集群及容纳更多 pod。借助 GKE,我们可以声明性地配置我们的集群进行扩展。启动了自动扩展后,在额外 pod 需要调度且现有节点无法容纳它们时,GKE 自动扩展节点。根据我们配置的阈值,当集群上的负载减少时,GKE 还能够缩减节点。

  • 作业。GKE 支持Kubernetes作业。作业可以广义地定义为任务,需要运行一个或更多 pod 以执行该任务。该作业可能只运行一次或按时间表运行。当作业完成时,运行该作业的 pod 会被丢弃。配置该作业的 YAML 文件指定错误处理、并行处理、如何处理重启等的细节。


9. 一次性

对于运行在云基础设施上的应用程序,我们应该把它们和底层基础设施作为一次性资源来对待。我们的应用程序应该能够处理底层基础设施的暂时丢失,并应该能够正常地关闭和重启。


要考虑的主要原则包括:


  • 适用支持服务解耦功能,如状态管理和事务数据的存储。请参阅该文档中前面的支持服务以获得更多信息。

  • 在应用程序外部管理环境变量,以便在运行时使用它们。

  • 确保启动时间最短。这意味着,我们必须决定,在使用虚拟机(如公共映像和自定义映像)时,在映像中要构建多少层。这个决定特定于每个应用程序,并且应该基于启动脚本执行的任务。例如,如果我们在下载几个软件包或二进制文件,在启动阶段将它们初始化,启动时间的很大一部分将致力于完成这些任务。

  • 使用 GCP 的原生功能以执行基础设施任务。例如,我们可以在 GKE 中使用滚动更新,使用云密钥管理服务(Cloud Key Management Service,简称 Cloud KMS)来管理安全密钥。

  • 使用 SIGTERM 信号(当其可用时)来启动一个正常的关机。比如,当应用程序引擎 Flex 关闭一个实例时,它通常发送一个 STOP(SIGTERM)信号给应用程序容器。我们的应用程序可以在容器关闭前使用该信号来执行任何清理操作。(我们的应用程序不需要响应 SIGTERM 事件。)在正常情况下,系统最多等待 30 秒钟以让应用程序停止,然后发送一个 KILL(SIGKILL)信号。


以下的代码段来自一个应用引擎应用程序,展示了我们如何拦截 SIGTERM 信号以关闭打开的数据库连接。


const express = require('express')const dbConnection = require('./db')// Other business logic related codeapp.listen(PORT, () => {  console.log('App listening on port ${PORT}')  console.log('Press Ctrl+C to quit.')})process.on('SIGTERM', () => {  console.log('App Shutting down')  dbConnection.close()  // Other closing of database connection})
复制代码


10. 环境的等价性

企业应用程序在其开发生命周期中会跨不同的环境迁移。通常,这些环境是开发、测试和预发布(staging)以及生产环境。最好让这些环境尽可能地相似。


环境的等价性是一个大多数开发人员认为已给定的特性。尽管如此,随着企业的成长及其 IT 生态系统的发展,环境的等价性变得越来越难以维持。


由于这几年来,开发人员采用了源码控制、配置管理和模板化配置文件,因此,维持环境的等价性变得轻松了一些。这样一来,把应用程序一致地部署到多个环境中就变得更轻松了。例如,使用 Docker 和 Cocker Compose,我们可以确保应用程序堆栈跨环境保持其形状和工具组合。


下表列出的 GCP 服务和工具,在我们设计运行于 GCP 的应用程序时可以使用它们。这些组件有不同的用途,它们共同帮助我们构建让我们的环境更加一致的工作流。


GCP 组件用途
云资源存储库供团队存储、管理和跟踪代码的单一去处。
云存储、 云资源存储库存储构建工件
云KMS在一个中心云服务中存储我们的加密密钥,供其它云资源和应用程序直接使用。
云存储存储自定义映像,这些映像是我们从资源磁盘、映像、截图或存储在云存储的映像中创建的。我们可以使用这些映像来创建为我们的应用程序量身制定的虚拟机实例
容器注册表存储、管理和保护Docker容器映像。
部署管理器编写灵活的模板和配置文件,并使用它们来创建使用GCP产品变体的部署


11. 日志

日志让我们能够了解应用程序的健康状况。对日志的收集、处理和分析与应用程序核心逻辑的解耦是非常重要的。在我们的应用程序需要动态扩展并运行于公共云上时,解耦日志消除了管理日志存储位置和分布式(通常是临时性的)虚拟机进行聚合的开销,因此特别有用。


GCP 提供了一套工具,以帮助处理日志的收集、处理和结构化分析。在我们的计算引擎虚拟机中安装 Starkdriver Logging Agent 是个很好的方法。(这个代理默认预安装在应用程序引擎和 GKE VM 映像中。)该代理监控一组预先配置的日志记录位置。运行于虚拟机的应用程序生成的日志将被收集并以流的形式被传输到 Stackdriver Logging 中。


当为 GKE 集群启用日志记录时,日志代理被部署到该集群的每个节点上。该代理收集日志,用相关的元数据丰富日志,并在数据存储中将它们持久保存。可以使用 Stackdriver Logging 来查看这些日志。我们可以使用 Fluentd 守护程序集对记录的内容进行更多的控制。请参阅使用Fluentd为谷歌Kubernetes引擎自定义Stackdriver日志以获得更多的信息。


12. 管理流程

管理流程通常包括一次性任务或定时的、可重复的任务,如生成报告、执行批处理脚本、启动数据库备份和迁移模式。十二要素宣言中的管理流程要素是考虑一次性任务而写的。对于云原生应用程序,在创建可重复的任务时,该要素变得越来越重要,本部分的指南针对的是类似任务。


定时触发器常常是作为定时任务(cron job)构建的,并由应用程序本身来处理。该模型有用,但是,它引入了与应用程序紧密耦合的逻辑,需要维护和协调,尤其是当应用程序跨时区分布时。


因此,在为管理流程进行设计时,应该把这些任务的管理与应用程序本身解耦。根据应用程序运行所需的工具和基础设施,可以考虑以下建议:


  • 要在 GKE 上运行应用程序,请为管理任务启动单独的容器。我们可以利用 GKE 中的CronJobs。CronJobs 在临时容器中运行,并且,我们可以控制时间、执行频率,并且如果作业失败或完成时间过长时,可以重试。

  • 对于托管在应用引擎或计算引擎上的应用程序,我们可以外部化触发机制并为要调用的触发器创建端点。与单一用途的端点相比,这种方法有助于定义应用程序的职责范围。云任务(Cloud Tasks)是全托管的、异步任务执行服务,我们可以用以使用应用引擎实施这个模式。我们还可以使用云调度程序(Cloud Scheduler),这是 GCP 上一个企业级、全托管的调度程序,用以触发定时操作。


十二要素以外

本文描述的十二要素为我们应该如何构建云原生应用程序提供了指导。这些应用程序是企业的基础构建块。


一个典型的企业有很多这样的应用程序,它们通常是由几个团队合作开发的,以交付业务功能。重要的是,在应用程序开发生命周期中建立一些其他原则(而不是事后才考虑),以解决应用程序相互之间如何通信和如何保护它们,以及如何进行访问控制。


以下部分概述了在应用程序设计和开发过程中应考虑的一些其他问题。


API

应用程序使用 API 进行通信。当我们在构建应用程序时,要考虑应用程序的生态系统会怎样使用该应用程序,从设计一个 API 策略开始。一个良好的 API 设计可以让应用程序开发人员和外部利益相关者轻松地使用。在实现任何代码前,使用OpenAPI规范记录 API 是一个良好的习惯。


API 抽象了底层的应用程序功能。一个设计良好的 API 端点应该把提供服务的应用程序基础设施与在使用的应用程序隔离并解耦。这种解耦让我们能够独立地改变底层服务及其基础设施,而不会影响到应用程序的使用者。


对我们开发的 API 进行分类、记录和发布是非常重要的,这样 API 的使用者才能够发现并使用这些 API。理想情况下,我们希望 API 使用者自我服务。我们可以通过设置开发人员门户网站来实现。开发人员门户网站为所有 API 使用者作为入口点提供服务,这些 API 使用者包括企业内部的,或像来自合作伙伴生态系统的开发人员这样的外部使用者。


谷歌的 API 管理产品套件Apigee有助于管理API的整个生命周期,从设计,构建一直到发布。


安全性

安全性的范畴很广,包括操作系统、网络和防火墙、数据及数据库安全性、应用程序安全性、身份认证、访问管理。在企业生态系统中,解决安全问题的所有方面是至关重要的。


从应用程序的角度来看,API 提供对企业生态系统中应用程序的访问。因此,我们应该确保在应用程序设计和构建过程中,这些构建块可以解决安全问题。帮助保护对应用程序的访问要考虑的问题如下所示:


  • 传输层安全性(Transport Layer Security,简称 TLS)。使用 TLS 来帮助保护传输中的数据。我们可能想对业务应用程序使用双向的 TLS,如果我们使用服务网格(如在谷歌Kubernetes引擎上的Istio),这会更轻松。对于一些用例来说,基于 IP 地址创建允许列表和拒绝列表作为附加安全层也是常见的。传输安全还涉及保护我们的服务免受 DDoS 和机器人攻击。

  • 应用程序和终端用户安全性。传输安全性有助于为传输中的数据提供安全性并建立信任。但是,最佳实践是添加应用程序级别安全性,以根据应用程序的使用者是谁来控制对应用程序的访问。这些使用者可以是其它应用程序、员工、合作者或企业的终端用户。我们可以使用 API 密钥(针对消费类应用程序)、基于证书的身份验证和授权、JSON Web令牌(JSON Web Tokens,简称JWTs)交换,或安全性声明标记语言(Security Assertion Markup Language,简称SAML)来增强安全性。


安全环境在企业内持续地发展,使我们更难在应用程序中编写安全性结构代码。API 管理产品(如 Apigee)有助于确保在本节中提到的所有层上的API安全


接下来要做的事

  • 查看采用了十二要素 APP 原则并使用 GCP 产品和服务构建的微服务演示应用程序

  • 审查用于日志和监控的 GCP 产品套件,请参阅 Stackdriver文档

  • 尝试谷歌云平台功能,可以参考教学视频


原文链接:Twelve-factor app development on GCP


2019 年 12 月 11 日 11:501784
用户头像
赵钰莹 InfoQ高级编辑

发布了 691 篇内容, 共 405.5 次阅读, 收获喜欢 2257 次。

关注

评论 1 条评论

发布
用户头像
2019 年 12 月 12 日 18:18
回复
没有更多了
发现更多内容

架构师训练营- 第3周作业

cafebaby

中台 | 中台到底是什么?

xcbeyond

中台 中台架构 中台的由来 28天写作

Week13作业

lggl

产品经理训练营笔记-认识产品经理(下)

.nil?

产品经理训练营

第11周-作业1

Mr_No爱学习

Google 搜索引擎是如何对搜索结果进行排序

Mars

从炒作到风口,谁在引领中国区块链浪潮?

CECBC区块链专委会

比特币 区块链

架构师训练营第二期 Week 13 总结

bigxiang

架构师训练营第2期

第11周-学习总结

Mr_No爱学习

又见拉布拉猪

Justin

28天写作 灌水 减压

区块链世界的中心应该是什么?

CECBC区块链专委会

区块链 区块链数字经济

第八周 学习总结

简简单单

重学JS | Proxy与Object.defineProperty的用法与区别

梁龙先森

前端 编程语言 28天写作

第一周作业-产品经理岗位能力要求

林亚超

面试官问我:什么是静态代理?什么是动态代理?注解、反射你会吗?

Java鱼仔

Java 反射 动态代理 java反射

产品训练营第一周作业

懒杨杨

数据应用总结二

Mars

架构师训练营第二期 Week 13 作业

bigxiang

架构师训练营第2期

第八周 性能优化(二) 作业 「架构师训练营 3 期」

feiyun123

架构师训练营第三周作业

跳蚤

有关单例模式的总结

跳蚤

Java 程序经验小结:性能优化手段之避免创建不必要的对象

后台技术汇

28天写作

今天听课想到的小事

Nydia

十三周-作业

水浴清风

产品经理JD调研备忘录

sting

产品

CentOS安装和使用FFmpeg

王坤祥

ffmpeg 视频处理

第一周作业

Geek_ce1551

第一周作业

大熊猫

开创我国区块链定制化制造新时代

CECBC区块链专委会

区块链

第八周 课后作业

简简单单

作业 - 第一章 认识产品经理

hao hao

产品经理训练营

2021年全国大学生计算机系统能力大赛操作系统设计赛 技术报告会

2021年全国大学生计算机系统能力大赛操作系统设计赛 技术报告会

我在云上试了下流行的十二要素开发方法论-InfoQ