您的位置 首页 golang

解读 pkg.go.dev 的设计和实现:设计篇

文章较长,建议收藏,抽完整时间阅读。觉得不错帮转发下!

北京时间 2020 年 6 月 15 日 22 点左右,Go 官方发博文()宣布,pkg.go.dev 开源了。开源代码托管在 Google 自有仓库 ,不过在 GitHub 上提供了镜像:。同时,对于该项目的任何 issue,通过 Go 主仓库进行管理,即 。

Go 作者们可能也没有想到,经过这么多年的发展,Go 被使用最广的竟然是 Web 开发,这可能得益于一开始 Go 就对 http 有很好的支持,也因此涌现出大量的 Web 框架,其中知名的有 Gin、Echo、Beego 等。

运营 Go 语言中文网和 Go 社区 8 年有余,发现广大 Gopher 们都苦于没有实战项目可以练手,很多新手学习完 Go 语法后,因为工作中没有用到,不知道该怎么进行项目实战或用 Go 做点什么。这期间也有很多人问我有无适合新手学习的开源项目。也是在去年初,我因此还创建了《Go项目实战》知识星球。现在,Go 官方开源了 pkg.go.dev 这个 Web 项目,爱学习的你,不应该只是看到了这个消息就完事了,应该做点什么,学点什么。

原计划,我是希望通过这个项目,让大家能够很好的学习 Go 是如何进行实际项目开发的。当我深入研究 pkg.go.dev 源码后,我失望了,无论是设计还是实现,水平都很一般。本想着放弃这一系列,但想想还是继续,一方面尝试指出问题,给出认为正确的做法;另一方面,毕竟是官方的项目,开源了相信它会变得更好。

本文包括的内容(并非大纲):

项目架构

先上一张官方的架构图:

architecture

包含了三个核心组件:

  • Frontend:这是一个面向用户的前端组件,处理用户请求,获取数据并展示给用户;
  • Worker:一个后台程序,负责将新模块的信息写入数据库。新模块的数据来自 Go Module Index[1] ,同时这些模块的内容是从 Go Module Mirror[2] 中下载的;
  • Database:数据库,用于存储站点上提供的所有信息。

图中其他的部分包括:Frontend 组件使用的 Redis 缓存、任务队列和 Scheduler。

Frontend 组件

这是一个简单的 HTTP 服务,它从数据库中获取数据,渲染模板,最后生成 HTML 页面。而搜索功能,是通过 Postgres 的全文搜索实现的,因此没有引入另外的搜索组件,比如:Solr、ElasticSearch。

目前前端做的事情比较简单:从 Postgres 数据库中获取 modules 和 packages 等数据,而 Redis 用于缓存这些数据。

细心的读者会发现这样的设计存在一个问题:更新不及时。前端数据依赖 Worker 组件写入 DB。我临时创建一个 Go 包,用于验证该问题:github.com/polaris1119/testpkg,在 godoc.org 上看到的信息如下:

但 pkg.go.dev 上看到的却是:

虽然这个 404 页面告诉你:如果你认为这是一个有效的包路径,可以通过这里的 说明[3] 尝试获取该包。那这个说明是什么?

可见,这个说明是告诉你怎么将包提交给 proxy.golang.org。即使这样做了,很可能 pkg.go.dev 上还是没有,因为 Frontend 只负责从 DB 获取数据。(几分钟后不出意外应该有了)

然而这样的设计大家肯定接受不了,因此出现了几个这样的 issue: #36811[6] , #37002[7] , #37106[8] 等。为了应对这种情况,所以出现了架构图中的 Frontend Task Queue,用于获取数据库中还不存在的包并将包 master 分支的信息显示给用户,不过截止目前还未实现。不知道到时候以及会如何实现。

这里不得不吐槽了:

  • 这样的问题在设计之初就应该考虑。一个新的系统,不说比之前的 (godoc.org)好,至少不能差。godoc.org 在遇到包没有时会实时获取,而且还支持用户手动刷新;
  • 在访问某个不存在的包时,让用户按照说明操作下,以便 proxy.golang.org 上有这个包。这个过程完全可以程序自动做;不仅如此,还可以考虑自动触发 Worker 进行工作,拉取包数据;

Worker 组件

Worker 的主要工作是下载发现的新模块,进行处理,然后将信息写入数据库以供 Frontend 使用。它提取 README 文件,许可证文件(license)和文档,并将它们写入数据库。它还将与搜索相关的数据写入表(search_documents)。除了直接在模块 zip 中可用的搜索信息外,它还计算每个包的导入者数量。(imports_unique 表保存了每个包导入的其他包)

为了简化处理新模块的工作并利用上限速和重试功能,Worker 使用 Google Cloud Tasks[9] 队列来管理要处理的模块列表。当 Worker 程序在 索引 (index)中找到新模块时,它会将任务添加到队列中。队列以固定的最大速率将任务推送给 Worker。

文档提到:由于 Worker 必须是无状态 HTTP 服务器,因此无法运行后台任务。因此使用 Google Cloud Scheduler[10] 来定期执行任务。这些任务通常每分钟运行一次,它们是:

  • 轮询索引(index)以使新模块进入队列;
  • 对于暂时处理失败的模块,重新进入队列;
  • 更新每个包的导入者数量;

从架构图可以看到,Worker 从 index 获取新模块(默认是 index.golang.org),从 proxy 获取 module 的 zip 文件(默认是 proxy.golang.org),最后将信息、数据写入 postgres 数据库。

吐槽:

为什么 Worker 必须是无状态的 HTTP 服务,还无法运行后台任务?这里完全可以通过类似 这样的库来处理定时任务。竟然设计成启动一个 HTTP 服务,由一个外部定时任务调度器(Google Cloud Scheduler)来调用它提供的接口。

数据库

数据库方面,主要看看表的设计,同时学习下是如何做迁移管理的。

该项目没有使用配置文件,而是通过环境变量来控制。比如 postgres 数据库相关配置信息通过如下环境变量控制:

  • GO_DISCOVERY_DATABASE_USER (default: postgres)
  • GO_DISCOVERY_DATABASE_PASSWORD (default: ”)
  • GO_DISCOVERY_DATABASE_HOST (default: localhost )
  • GO_DISCOVERY_DATABASE_NAME (default: discovery-db)

这些配置信息在 internal /config/config.go 文件中。

表的设计是项目很重要的一个环节。为了看到该项目的表设计,安装好 postgres 后,执行如下脚本(类 Unix 系统)创建 discovery-db 数据库:

 $ devtools/create_local_db.sh  

之后执行迁移操作。

数据库迁移

很多人可能对迁移不了解,这里简单介绍下。

这里说的数据库迁移,主要是指数据库 schema 的变更(当然也包括不同数据源往某个数据库迁移)。我们知道,代码的变更可以通过 Git 进行管理,通过 Git 可以很容易的实现代码的回滚。于是有人就想,代码变更时,很可能数据表的结构也变了,那有没有可能很方便的对数据库的变化进行回滚呢(升降版本)?于是有了 database migrate。就开源项目而言,对数据库自动升降级很有帮助。

然而数据库迁移并没有标准,依赖于具体的工具实现。迁移工具一般分为两种:1)独立的迁移软件,如 Liquibase[11] ;2)依附于具体语言的库,比如 Go 语言的 migrate[12] ,不过这个库也可以作为独立的软件使用。

每一次 schema 的变更,有时包括初始化数据,通常会记录在一个单独的脚本文件中。通常每一次数据库变更,应该生成一个对应的文件。我们通过 migrate 这个库具体学习下迁移的操作。

migrate 学习

该项目是 Go 语言实现的数据库迁移工具,支持 CLI 方式使用,也支持作为库导入使用。Migrate 从源读取迁移,并将迁移以正确的顺序应用于数据库。

目前该工具支持如下数据库:

  • Postgre sql [13]
  • Redshift[14]
  • Ql[15]
  • Cassandra[16]
  • SQLite[17] ( todo #165[18] )
  • MySQL/ MariaDB[19]
  • Neo4j[20]
  • MongoDB[21]
  • CrateDB[22] ( todo #170[23] )
  • Shell[24] ( todo #171[25] )
  • Google Cloud Spanner[26]
  • CockroachDB[27]
  • ClickHouse[28]
  • Firebird[29]
  • MS SQL Server[30]

迁移来源支持如下几种:

  • Filesystem[31] – 从文件系统读取
  • Go-Bindata[32] – 从内嵌的二进制数据读取
  • Github[33] – 从远程 GitHub 仓库读取
  • Github Enterprise[34] – 从远程 Github 企业仓库读取
  • Gitlab[35] – 从远程 Gitlab 仓库读取
  • AWS S3[36] – 从 Amazon Web Services S3 读取
  • Google Cloud Storage[37] – 从 Google Cloud Platform Storage 读取

先安装,以 MacOS 为例:

 $ brew install golang-migrate  

为了方便演示,以上文创建的 为例,clone 下来后,在 testpkg 目录下执行如下命令,创建一个迁移:

 $ migrate create -ext sql -dir migrations -seq initial_schema_from_pg_dump  

成功后会在 migrations 目录下生成两个文件:

 migrations
├── 000001_initial_schema_from_pg_dump.down.sql
└── 000001_initial_schema_from_pg_dump.up.sql  

这里有两个基本概念需要清楚:up 和 down,上面两个文件中有包含。

  • up 表示升级到当前版本;
  • down 表示回退到上一版本;

所以,我们在上面两个文件中填上如下内容:

 // up 文件的内容
CREATE TABLE gopher (
    id serial  PRIMARY  KEY,
    username varchar(31)  NOT  NULL DEFAULT '',
    email varchar(63) NOT NULL DEFAULT '',
    created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);

comment on table gopher is 'gopher用户表';
comment on column gopher.username is '用户名';

// down 文件的内容
DROP TABLE
    gopher;  
  • up 文件的内容是创建表 gopher;
  • down 文件的内容是删除表 gopher;

之后就可以进行迁移操作了(这里假定你本地已经装上 postgres,密码是 123456,同时创建了 testpkg 数据库):

 $ migrate -database "postgres://postgres:123456@localhost:5432/testpkg?sslmode=disable" -source file:migrations up  

这时会发现数据库中多了两个表:gopher 和 schema_migrations。其中 schema_migrations 的内容如下:

versiondirty1FALSE

如果这时再执行如下命令进行“回滚”:

 $ migrate -database "postgres://postgres:123456@localhost:5432/testpkg?sslmode=disable" -source file:migrations down  

为了安全,会如下提示:

 Are you sure you want to apply all down migrations? [y/N]  

选择 y,成功后再看看数据库的变化,发现 gopher 表不见了,schema_migrations 表的内容也清空了,因为上次是版本 1 ,这次回退了。

为了进一步了解细节,我们再创建一个迁移,增加一个表:

 $ migrate create -ext sql -dir migrations -seq add_article_table  

会生成两个文件,现在有 4 个文件了:

 migrations
├── 000001_initial_schema_from_pg_dump.down.sql
├── 000001_initial_schema_from_pg_dump.up.sql
├── 000002_add_article_table.down.sql
└── 000002_add_article_table.up.sql  

注意到没?文件名前缀自动变为了 00002。同样,我们在新生成的两个文件中填上如下内容:

 // up 文件内容
CREATE TABLE article (
    id serial PRIMARY KEY,
    title varchar(127) NOT NULL DEFAULT '',
    content text NOT NULL,
    created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);

// down 文件内容
DROP TABLE
    article;  

之后执行迁移命令:

 $ migrate -database "postgres://postgres:123456@localhost:5432/testpkg?sslmode=disable" -source file:migrations up  

输出:

 1/u initial_schema_from_pg_dump (126.243958ms)
2/u add_article_table (160.978164ms)  

查看数据库,发现 gopher 和 article 表都有了,schema_migrations 表中 version 字段值是 2,符合预期。

明白迁移是怎么回事了吗?现在回过头来看看 pkgsite 的迁移和数据表。

##pkgsite 的数据表

pkgsite 提供了几个脚本,方便使用。比如上文提到的创建数据库的脚本。创建迁移和执行迁移的脚本分别是:devtools/create_migration.sh 和 devtools/migrate_db.sh。我们是研究 pkgsite,自然是执行迁移:(默认 migrate_db.sh 认为 postgres 数据库账号的密码是空,因为我本地设置了密码是 123456,因此需要在 migrate_db.sh 中加上密码)

 $ devtools/migrate_db.sh up  

输出:

 1/u initial_schema_from_pg_dump (220.913916ms)
2/u add_modules_identity (259.674211ms)
3/u add_paths_table (294.30702ms)
4/u redo_golang_search_config (334.652007ms)
5/u change_b_weight (357.625559ms)
6/u add_identity_keys (381.89522ms)
7/u add_readme_package_imports_documentation_tables (420.179973ms)
8/u remove_golang_text_config (441.527849ms)
9/u add_path_tokens_config (462.907176ms)
10/u rename_readme_filename_to_file_path (485.666895ms)
11/u add_packages_index (509.399738ms)
12/u add_modules_series_path_index (318.179716ms)
13/u add_version_map_indexes (304.326151ms)
14/u add_paths_module_id_index (293.308189ms)
15/u add_package_version_states_module_path_version_index (278.168938ms)
16/u add_module_version_states_num_packages (277.566312ms)
17/u add_module_version_states_num_packages_index (276.640912ms)
18/u add_module_version_states_status_index (260.810518ms)
19/u add_imports_unique_index (268.141396ms)
20/u add_search_documents_module_path_index (272.004077ms)
21/u add_version_map_go_mod_path_column (270.42236ms)  

一共 21 个版本。这时在 discovery-db 数据库中生成了一系列表。我们拿其中一个的表:packages ,看看它的设计:

 CREATE TABLE packages (
    path text,
    module_path text,
    version text,
    commit_time timestamp with time zone NOT NULL,
    name text NOT NULL,
    synopsis text,
    license_types text[],
    license_paths text[],
    v1_path text NOT NULL,
    goos text NOT NULL,
    goarch text NOT NULL,
    redistributable boolean NOT NULL DEFAULT false,
    documentation text,
    tsv_parent_directories tsvector,
    created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT packages_pkey PRIMARY KEY (path, module_path, version),
    CONSTRAINT packages_module_path_version_fkey FOREIGN KEY (module_path, version) REFERENCES modules(module_path, version) ON DELETE CASCADE
);
COMMENT ON TABLE packages IS 'TABLE packages contains packages in a specific module version.';
COMMENT ON COLUMN packages.commit_time IS 'commit_time is the same as verions.commit_time. It is added here so that we can reduce the number of joins in our queries.';
COMMENT ON COLUMN packages.tsv_parent_directories IS 'tsv_parent_directories should always be NOT NULL, but it is populated by a trigger, so it will be initially NULL on insert.';  

我又要吐槽了:

  • 借用火丁笔记老王的评价:这是应届生设计的吧
  • 表名一般建议使用单数形式;
  • 字符串类型竟然全部是 text,类型选择很随意,没有任何讲究;
  • 主键竟然是三个 text 类型的联合;
  • 。。。

总体看,设计者应该没有经历过大项目,或没有参与过因为量大而遇到性能问题的项目。

设计数据表的建议

这里给出当初在 360 时,公司 DBA 对创建数据表的一些建议或要求:

  • 表名、列名长度不超过 16
  • 每个字段都必须有 NOT NULL DEFAULT ”, 如果是 int 则 default 0
  • 主键必须为 int/bigint 类型
  • 如果是 int 类型,且不会存负数,则标记为 UNSIGNED INT
  • 如果 int 类型是 10 以下的几个可枚举值,则使用 TINYINT 类型
  • varchar 长度小于 3000
  • text 字段个数不超过 3 个
  • 每个字段增加 COMMENT 注释,说明字段用途
  • 索引不能有重复
  • 索引个数不能大于 5 个(包括主键)
  • 索引字段必须为 not null,并且有 default 值
  • 请不要使用 MySQL 保留字

以上虽然是针对 MySQL 的,但基本上 Postgres 也是适用的。

代码组织

看看 pkgsite 项目的目录结构:

 $ tree -L 2
.
├── CONTRIBUTING.md
├── LICENSE
├── PATENTS
├── README.md
├── all.bash
├── cloudbuild.yaml
├── cmd
│   ├── frontend
│   ├── prober
│   ├── teeproxy
│   └── worker
├── content // 存放静态资源(css/img/js 等)
│   └── static
├── devtools
│   ├── compile_js.sh
│   ├── create_local_db.sh
│   ├── create_migration.sh
│   ├── drop_test_dbs.sh
│   ├── lib.sh
│   └── migrate_db.sh
├── doc // 存放设计文档
│   ├── architecture.png
│   ├── design.md
│   ├── frontend.md
│   ├── postgres.md
│   ├── precommit.md
│   └── worker.md
├── go.mod
├── go.sum
├── internal
│   ├── auth
│   ├── complete
│   ├── config
│   ├── database
│   ├── datasource.go
│   ├。。。。。 // 省略了很多
├── migrations // 迁移的 sql,前文讲过
└── third_party // 用到的第三方前端库
    ├── autoComplete.js
    └── dialog-polyfill

39 directories, 66 files  
  • cmd:该目录几乎是 Go 圈约定俗成的,是 Go 官方以及开源界推荐的方式,用于存放 main.main。它包含 4 个子目录,也就是项目可以生成 4 个可执行文件:
  • fronted:对应前文介绍的 Frontend 组件;
  • woker:对应前文介绍的 Worker 组件;
  • prober:探测器,定期探测 frontend,并导出 frontend 的度量指标,以便监控报警和性能追踪;
  • teeproxy:用于处理 godoc.org 上的链接跳转到 pkg.go.dev;
  • internal:除了 cmd 中部分的 Go 代码,其他代码全部在该目录下。目前开源项目习惯包含一个 pkg 包,将一些通用的代码放在该包下。我们知道 internal 包,对其他项目是不可见的。所以,该项目任何包,其他项目都没法直接使用。(上面省略了很多子包)

其他目录说明见上面对应的注释。

关于 main.main 放在哪的问题

上面说,main.main 放在 cmd 目录下几乎是约定俗成的,但针对这个问题还是需要进一步说明一下,毕竟这不是标准或规范。

那关于 main.main,即包含 main 包 和 main 函数的文件(一般是 main.go)放在哪里,目前一般有两种做法:

1)放在项目根目录下。这样放有一个好处,那就是可以方便的通过 go get 进行安装。比如 github.com/polaris1119/golangclub ,按这样的方式安装:

 $ go get github.com/polaris1119/golangclub  

成功后在 $GOBIN(未设置时取 $GOPATH[0]/bin )目录下会找到 golangclub 可执行文件。但如果你的项目不止一个可执行文件,也就是会存在多个 main.go,这种方式显然没法满足需求。

目前有一些开源项目是这么做的,比如 cobra 生成的框架也是采用的这种方式。

2)创建一个 cmd 目录,专门放置 main.main,有些项目可能会直接将 main.go 放在 cmd 下,但这又回到了上面的方式,而且还没上面的方式方便。一般建议项目存在多个可执行文件时,在 cmd 下创建对应的目录。因为 pkgsite 存在多个可执行文件,因此采用了这种方式。像知名的 Kubernetes 也是采用的这种方式。对于这种方式,通过 go get 可以这样安装:

 $ go get -v golang.org/x/pkgsite/cmd/...  

这样会将项目所有的可执行文件都生成,你也可以指定生成某一个:

 $ go get -v golang.org/x/pkgsite/cmd/frontend  

(未完待续)

文章来源:智云一二三科技

文章标题:解读 pkg.go.dev 的设计和实现:设计篇

文章地址:https://www.zhihuclub.com/99420.shtml

关于作者: 智云科技

热门文章

网站地图