Go 工业级编程

原文(https://peter.bourgon.org/go-for-industrial-programming/)

我们不应该盲目地应用教条主义的建议,每次我们应该使用我们的判断。

互联网上的一些人发现,你在科技行业呆的时间越长,你的观点就会变得越好,这要看情况而定,我不确定,这些是不是很好。
根据我的经验,如果你发现随着你事业的发展,你会变得越来越固执己见,那么你很有可能陷入一种窠臼,成为一个专业的初学者。不太好了!

明确定义一个适用范围,就能给自己留出一点空间来形成一个明确的意见:

  • 在创业或公司环境中;
  • 在一个工程师来来去去的团队中;
  • 比任何一个工程师都长寿的代码;
  • 服务高度可变的业务需求。

构造代码和存储库

我们经常看到的一件事是对严格的项目结构的期望,通常在项目开始时就预先决定了。我认为这通常是问题多于帮助。要理解原因,请记住工业编程的第四个特性,或许也是最重要的一个特性:

工业编程 … 提供高度可变的业务需求

惟一不变的是变更,惟一的业务需求规则是它们 永远不会收敛,它们只会_发散、增长和变异。因此,我们应该确保我们的代码能够适应生活中的这一事实,而不是不必要地将自己限制在其中。我们的repos,我们的抽象,我们的代码本身应该易于维护,易于修改,易于适应,最终,易于删除

有几件事通常是好主意。如果您的项目有多个二进制文件,最好有一个cmd/子目录来保存它们。如果您的项目很大并且有重要的非Go代码,比如UI资产或复杂的构建工具,那么将Go代码隔离在pkg/子目录中可能是一个好主意。如果您要有多个包,那么最好围绕业务领域来定位它们,而不是围绕实现的意外情况。即:包用户;而非包模型

当然,业务领域和实现并不总是严格分开的。例如,大型web应用程序往往混合了传输和核心业务关注点。GoBuffalo之类的框架鼓励使用诸如操作、资产、模型和模板之类的包名。当您知道只处理HTTP时,将所有精力都放在耦合上是有好处的。

还有建议我们根据依赖边界对齐包。也就是说,为RedisStore、MySQLStore等提供单独的包,以避免外部消费者必须包含和编译对他们不需要的东西的支持。在我看来,对于具有封闭的导入器集的包来说,这是一种不合适的优化,但是对于第三方(如Kubernetes)广泛导入的包来说,这种优化非常有意义,因为在第三方,编译单元的大小可能成为真正的瓶颈。

所以有一个适用范围。我认为最好的一般性建议是,一旦你觉得有必要,具体地说,只选择加入一点结构。我的大多数项目仍然以package main中的几个文件开始,它们捆绑在repo的根目录下。它们一直保持这种状态,直到它们变成至少几千行代码。我的许多项目,甚至大多数项目,都没有走到这一步。Go的一个好处是它能让人感觉很轻;我喜欢尽可能长时间地保存它。

程序配置

Flags是最好的方式来配置您的项目,因为它们自我记录的方式来描述您的程序在运行时的配置。

这在工业上下文中尤其重要,在工业上下文中,无论谁在运行服务,都可能不是其原始作者。如果使用-h标志运行程序提供了控制其行为的旋钮和开关的完整列表,那么对于随叫随到的工程师来说,在发生事件时调整某些东西是非常容易的,或者对于新工程师来说,让它在他们的开发环境中运行也是非常容易的。这比在(可能已经过时的)文档中查找一组相关的环境变量,或者找出配置文件格式的语法和有效键名要容易得多。

这并不意味着永远不要使用env vars或配置文件。除了使用标志之外,还有很好的理由使用其中之一或两者。Env vars对于连接字符串和非秘密的auth令牌非常有用,尤其是在开发期间。配置文件对于声明详细的配置非常有用,而且是将秘密放入程序的最安全的方法。(系统的其他用户可以检查标志,env vars很容易设置和遗忘。)只要确保显式命令行标志(如果给定)具有最高优先级。

1
2
3
4
5
6
7
8
var fs myflag.FlagSet
var (
foo = fs.String("foo", "x", "foo val")
bar = fs.String("bar", "y", "bar val", myflag.JSON("bar"))
baz = fs.String("baz", "z", "baz val", myflag.JSON("baz"), myflag.Env("BAZ"))
cfg = fs.String("cfg", "", "JSON config file")
)
fs.Parse(os.Args, myflag.JSONVia("cfg"), myflag.EnvPrefix("MYAPP_"))

组件图

工业编程意味着一次性编写代码并永久维护代码。维护是阅读和重构的持续实践。因此,工业编程压倒性地倾向于读,_在易读与易写之间,我们应该强烈地偏向于前者

依赖注入是优化阅读理解的一个强大工具。这里我当然不是指facebook-go/injectuber-go/dig所使用的依赖容器方法,而是将依赖关系枚举为类型或构造函数的参数的简单得多的实践。

下面是最近流行的一个基于容器的依赖注入的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func BuildContainer() *dig.Container {  
container := dig.New()
container.Provide(NewConfig)
container.Provide(ConnectDatabase)
container.Provide(NewPersonRepository)
container.Provide(NewPersonService)
container.Provide(NewServer)
return container
}

func main() {
container := BuildContainer()
if err := container.Invoke(func(server *Server) {
server.Run()
}); err != nil {
panic(err)
}
}

主函数体结构紧凑,构建容器具有简洁、切中要点的外观。但是,提供方法需要反射来解释它的参数,而调用没有提供关于服务器实际需要做什么工作的线索。新员工必须在多个上下文之间跳转,以构建每个依赖项的心理模型、它们如何交互以及服务器如何使用它们。这代表了读理解和写理解之间的一个不好的权衡。

与本例要改进的代码的稍微修改过的版本相比:

1
2
3
4
5
6
7
8
9
10
11
func main() {  
cfg := GetConfig()
db, err := ConnectDatabase(cfg.URN)
if err != nil {
panic(err)
}
repo := NewPersonRepository(db)
service := NewPersonService(cfg.AccessToken, repo)
server := NewServer(cfg.ListenAddr, service)
server.Run()
}

主函数比较长,但是作为交换,我们得到了清晰的明确性。每个组件都按依赖顺序构造,并内联处理错误。每个构造函数都将其依赖项枚举为参数,从而允许新代码读取器快速、轻松地构建组件之间关系图的心智模型。在层层的间接性后面,没有什么是模糊的。

如果重构要求组件获得新的依赖项,则只需将其添加到构造函数中即可。下一个编译将触发错误,这些错误将精确地标识需要更新的参数列表,并且生成的PR中的差异将清楚地显示依赖关系的流。

我声称这严格优于前一种方法,在前一种方法中,从编写的代码中提取关系要困难得多,而且大部分故障检测延迟到运行时。我认为随着程序的大小(以及func main的大小)的增长,以及简单而显式的初始化复合函数的好处,它会变得越来越好。

当我们谈论阅读理解时,我喜欢思考我认为Go最重要的一个特性,那就是它本质上是非魔法的。除了极少数例外,对Go代码的直线阅读不会对定义、依赖关系或运行时行为产生歧义。这是伟大的。

但是有几种方法可以让魔法潜入其中。不幸的是,一种非常常见的方法是使用全局状态。包全局对象可以编码对外部调用者隐藏的状态和/或行为。调用这些全局变量的代码可能会产生令人惊讶的副作用,这将破坏读者理解并在心里为程序建模的能力

因此,考虑到读取优化和维护成本,我认为理想的Go程序几乎没有包全局状态,而是更喜欢通过构造函数显式地枚举依赖关系。由于func init的唯一工作就是初始化包的全局状态,所以我认为最好将其理解为几乎所有程序中的一个严重警告。多年来,我一直用这种方式编写程序,而在那段时间里,我只是越来越欣赏和提倡这种做法。

所以,我的现代Go理论是:

  1. 显式的依赖关系
  2. 没有包级别变量
  3. 没有函数初始化

Goroutine生命周期管理

根据我的经验,不正确或过于复杂的启动、停止和检查goroutines的设计是新手和中级Go程序员面临挫折的最大原因。

我认为问题在于goroutine最终是一个非常低级的构建块,对于大多数人希望使用并发来完成的那种高阶任务几乎没有可用性。我们会说“永远不要在不知道如何停止的情况下开始吃goroutine”,但是如果没有具体的方法,这个建议就有点空洞了。我认为许多教程和大量示例代码,即使是在其他好的参考资料中,比如Go编程语言书籍,也会给我们带来不利影响,因为它们演示了并发概念,使用泄漏的即发即弃 goroutines、全局状态和模式,即使是基本的代码检查也会失败。

我看到的大多数goroutine都是由我的同事启动的,它们并不是定义良好的并发算法的附带步骤。它们往往是结构化的,用模糊的终止语义管理长时间运行的东西,通常在程序开始时启动。我认为这些用例需要使用更强的约定。

想象一下,go关键字的正字法略有不同。如果我们不能启动goroutine,而不提供中断或停止它的功能,那该怎么办?实际上,强制执行约定,除非我们知道如何阻止goroutine,否则不应该启动它。

这是我在使用package run时遇到的问题,它是从我去年从事的一个更大的项目中提取出来的。这里是最重要的方法,添加:

1
func (g *Group) Add(execute func() error, interrupt func(error))

添加要运行的goroutine队列,但也跟踪一个函数,该函数将在需要杀死goroutine时中断goroutine。这为整个goroutine组提供了定义良好的终止语义。例如,当我有多个应该永远运行的服务器组件时,我最常使用它,然后添加一个goroutine来捕获ctrl-C并销毁所有内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
ctx, cancel := context.WithCancel(context.Background())
g.Add(func() error {
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
select {
case sig := <-c:
return fmt.Errorf("received signal %s", sig)
case <-ctx.Done():
return ctx.Err()
}
}, func(error) {
cancel()
})

如果您熟悉package errgroup,那么这在高层上与包errgroup类似,但更糟糕的是:errgroup隐式地假设所有执行函数都将响应提供给组的父上下文,并且没有使之显式的功能。

最近有一篇clickbaity博客文章声称“go语句被认为是有害的”,并提倡使用一种称为托儿所的结构,即生命周期绑定线程。package run和 最近有一篇clickbaity博客文章声称“go语句被认为是有害的”,并提倡使用一种称为托儿所的结构,即生命周期绑定线程。packageerrgroup都是这个托儿所概念的稍微不同的实现解释。

Futures

这是高阶结构的一种形式。但还有很多其他的!例如,你知道Go有Futures吗?它只是比其他语言更冗长一些。

1
2
3
future := make(chan int, 1)
go func() { future <- process() }()
result := <-future

future的另一种发音方式是async/ await:

1
2
3
c := make(chan int, 1)
go func() { c <- process() }() // async
v := <-c // await

Scatter-gather

我们也有散集,我一直在用它,当我知道我需要处理多少单位的工作时。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Scatter
c := make(chan result, 10)
for i := 0; i < cap(c); i++ {
go func() {
val, err := process()
c <- result{val, err}
}()
}

// Gather
var total int
for i := 0; i < cap(c); i++ {
res := <-c
if res.err != nil {
total += res.val
}
}

一个优秀的Go程序员会对这些高阶并发模式中的几个有很强的控制能力。一个优秀的Go程序员会主动地将这些模式传授给他们的同事。

可观察性

我们来谈谈可观察性。但在此之前,让我们先了解一下关于工业编程的另一种假设:我们编写的代码将运行在服务器上,为客户提供请求,并经历生命周期,而不会中断该服务。这不同于压缩包装并交付给客户的代码,也不同于作为批处理作业运行且不面向客户的代码。

在很大程度上,我同意Charity之前在节目中告诉我们的。特别是,我同意我们分布式工业系统的一个核心不变之处是,根本没有成本效益高的方法来进行全面集成或烟雾测试。集成或测试环境在很大程度上是一种浪费;更多的环境不会让事情变得更容易。对于我们的大多数系统来说,良好的可观察性比良好的测试更重要,因为良好的可观察性使明智的组织能够专注于快速部署和回滚,优化平均恢复时间(MTTR),而不是平均故障间隔时间(MTBF)。

对我们来说,问题是:Go中一个恰当的可观测系统是什么样子的?我想没有一个单一的答案,没有一个包我可以告诉你进口来一劳永逸地解决问题。可观察性空间是破碎的,许多供应商为了他们独特的世界观而竞争。有很多事情值得兴奋,但胜利者还没有尘埃落定。在我们等待这一切发生的同时,我们能做些什么呢?

如果我们生活在一个更完美的世界里,这就无关紧要了。如果我们有一个完美的可观测性数据收集系统,所有的解释都可以在查询时间内完成,而且成本为零,那么我们就可以向它发射原始的观测数据,并提供无限的细节,然后用它来完成。但是,这个领域之所以有趣或具有挑战性,当然是因为没有这样的数据系统存在。所以我们不得不做出工程决策,妥协,赋予特定类型的观察以语义意义,以实现特定的可观察性工作流。

venn

度量、日志记录和跟踪是使用可观察性数据的紧急模式,可观察性数据告知我们生成、发送和存储相应信号的方式。它们是一个优化函数的产物,介于运营商希望如何对自己的系统进行反省,以及技术是否能够大规模地满足这些需求之间。或许还有其他尚未被发现的消费模式,可能是由技术进步推动的,这将开启一个新的时代和可观察性分类。但这就是我们今天所处的位置,在可预见的未来也是如此。让我们看看如何在Go中最好地利用它们。

度量

度量是计数器、测量器和直方图,它们的观察结果在统计上结合起来,并报告给一个允许快速、实时导出总体系统行为的系统。度量电源指示板和警报。

metrics

到目前为止,大多数度量系统都提供了Go客户机库,并且标准的显示格式(如StatsD)已经很好地理解和实现了。如果您的组织已经拥有了关于给定系统的制度知识,请将其标准化;如果你只是刚刚开始或想要集中在一个系统上,Prometheus是同类中最好的。

不再足够好的是基于主机或检查的系统,如Nagios、Icinga或Ganglia。这些会使您陷入监视范例中,而这些范例在很久以前就不再有意义了,并且会积极地阻碍使您的系统可见。

日志记录

日志是对收集系统完全真实地报告的离散事件流,用于稍后的分析、报告和调试。好的日志是结构化的,允许灵活的事后操作。

logging

现在Go中有很多很棒的日志选项。好的日志库是面向日志程序接口的,而不是具体的日志程序对象。它们将日志记录器视为依赖项,避免了包的全局状态。并且在callsite上强制执行结构化日志记录。

日志是抽象的流,而不是具体的文件,所以要避免日志记录器在磁盘上写或旋转文件;这是另一个流程或编排平台的职责。而且日志记录可以快速地控制系统的运行时成本,所以在生成和发出日志时要谨慎和明智。捕获与请求路径相关的所有内容,但要深思熟虑,使用装饰器或中间件之类的模式。

跟踪

跟踪处理所有请求范围的性能数据,特别是当这些数据在分布式系统中跨流程和系统边界时。跟踪系统将元数据组织到树结构中,从而支持对特定异常或事件进行深度分类。

tracing

目前的跟踪实现主要围绕OpenTracing实现,OpenTracing是由Zipkin、Jaeger、Datadog等具体系统实现的客户端API标准。OpenCensus中也做了一些有趣的工作,它承诺提供一个更加集成的环境。

如果跟踪是有用的,那么它需要是全面的,并且在所有可观察性的支柱中,它拥有最严格的领域对象和谓词集。由于这些原因,正确实现跟踪的成本非常高,只有当分布式系统非常大(可能超过几十个微服务)时才有意义开始工作。

每个可观察性的支柱都有不同的优点和缺点。我认为我们可以沿着不同的轴比较它们:资本支出,开始测量和收集信号的初始成本;运营成本,运营支持基础设施的持续成本;反应,系统在发现和警报事件方面有多好;以及调查,系统能在多大程度上帮助对事件进行分类和调试。我的主观意见如下:

Metrics Logging Tracing
资本支出 Medium Low High
维护成本 Low High Medium
反应能力 High Medium Low
检测 Low Medium High

就资本支出而言,日志系统是最容易启动的,在代码中添加日志工具比其他方法更容易、更直观。度量标准涉及的更多,但是大多数度量标准收集系统仍然是相对独立的,并且非常容易部署。与此相反,跟踪需要大量时间才能在整个机群中安装,而且跟踪收集系统通常很大,需要一些专门的数据库知识。

就OpEx而言,根据我的经验,让一个日志系统保持在线状态需要付出不成比例的努力,通常与相应的生产系统一样困难,甚至更困难——由于大多数组织都倾向于不加区别地进行过多的日志记录,这使其更加困难。跟踪系统可以从前期成本中获益,并且通常可以顺利地进行大量抽样和定期数据库维护。相反,度量系统受益于统计聚合所产生的自然数据压缩,并且通常具有非常低的维护成本。当然,如果你选择使用供应商,这些成本会直接转化为美元。

就反应能力而言,度量系统被显式地设计为服务于dashboard和警报用例,以及excel。日志系统经常使用一些工具来执行可以驱动仪表板和警报的聚合或滚动,只需要做一点工作。而跟踪系统通常没有能力定义、检测或发出异常信号。

然而,当涉及到调查时,形势发生了逆转。公制系统在设计上失去了数据的保真度,而且通常在检测到问题的根本原因后,无法提供深入研究问题根源的好方法。日志系统做得更好,特别是如果您使用结构化日志记录和具有丰富查询语言的日志系统。跟踪本质上是为针对特定请求或请求类的深入研究而设计的,有时是识别复杂的不适应行为的唯一方法。

这里的教训是,没有一个单一的可观察性范式能够解决您所有的可观察性需求;它们都是完整的可观察早餐的一部分。从他们独特的特点,我认为我们可以得出一些一般性的建议:

  1. 首先,投资于基本的、全面的度量标准,为组件提供一组统一的仪表板和警报。在实践中,这通常足以发现并解决大量的问题。
  2. 接下来,投资于深度、高基数、结构化日志记录,以便对更复杂的问题进行事件分类和调试。
  3. 最后,当您拥有足够大的规模和定义良好的生产准备标准时,研究分布式跟踪。

测试

尽管大型分布式系统的可观察性可能优于集成测试,但是单元和有限的集成测试仍然是任何软件项目的基础和必要的。特别是在工业环境中,我认为它最大的价值是为新的维护者提供一种健康检查层,使他们的更改具有预期的范围和效果。

因此,测试应该优化为易用性。如果初级开发人员在克隆repo之后无法立即运行项目的测试,那么就会出现一个严重的问题。在Go中,我认为这意味着在您的项目中运行普通的Go测试,而没有额外的设置工作,应该总是成功的,不会发生意外。也就是说,大多数测试不应该要求任何类型的测试环境、运行数据库等来正常运行并返回成功。这些测试是集成测试,它们应该通过测试标志或环境变量选择加入:

1
2
3
4
5
6
7
8
9
func TestComplex(t *testing.T) {
urn := os.Getenv("TEST_DB_URN")
if urn == "" {
t.Skip("set TEST_DB_URN to run this test")
}

db, _ := connect(urn)
// ...
}

测试金字塔是一个很好的通用建议,建议您应该将大部分精力集中在单元测试上。根据我的经验,理想的比率甚至比金字塔建议的还要极端:如果您具有良好的生产可观察性,那么您的测试工作的80-90%应该集中于单元测试。在Go中,我们知道好的单元测试是表驱动的,并且利用组件接受接口并返回结构的事实来提供依赖关系的伪实现或模拟实现,并且只测试被测试的东西。

米切尔桥本(Mitchell Hashimoto)去年在GopherCon上使用Go进行的高级测试,可能是迄今为止我所见过的有关工业环境下的优秀Go程序设计的最佳信息来源。如果您的团队编写Go,那么它就是基本的背景材料。

关于这个问题没有太多可说的了。随着时间的推移,测试最佳实践的相对稳定性是一个受欢迎的缓刑。一如既往地:追求100%的测试覆盖率几乎肯定会适得其反,但50%似乎是一个很好的低水印;避免引入测试dsl或“帮助包”,除非您的团队从中获得明确的、具体的价值。

我需要多少接口?

当我们谈论测试时,我们谈论通过接口模拟依赖关系,但是我们通常并没有真正描述它在实践中是如何工作的。我认为很重要的一点是,围棋系统不是标称的,而是结构性的。将接口看作是对实现进行分类的一种方法是错误的;相反,将接口看作是一种识别期望公共行为集的代码的方法。换句话说,接口是消费者契约,而不是生产者(实现者)契约——因此,作为一个规则,我们应该在使用代码的callsite中定义接口,而不是在提供实现的包中定义接口。

您需要多少接口?嗯,有一个频谱。在您的callsite上尽可能的有意义,特别是为了帮助测试。在一种极端情况下,我们可以使用严格作用域的接口定义为每个函数的每个依赖关系建模。在有限的情况下,这可能是有意义的!例如,如果您的组件是粗粒度的、良好的,并且将来不太可能更改。

但是,特别是在工业上下文中,更有可能出现混合的抽象,这些抽象随时间而变化。在这种情况下,您可能希望在您的设计中尝试在“故障线”上定义接口。两个自然边界点位于func main和代码其余部分之间,以及包api之间。定义依赖接口有一个很好的起点,也有一个很好的起点来开始考虑定义单元测试。

上下文使用和误用

Go 1.7给我们带来了包上下文,从那时起,它就一直在影响我们的代码。这不是一件坏事!上下文是管理goroutine生命周期的一种很好理解且无处不在的方法,这是一个大而困难的问题。根据我的经验,这是它们最重要的功能。如果您有一个组件由于任何原因阻塞—通常是网络I/O,有时是磁盘I/O,可能是由于用户回调,等等—那么它可能应该将上下文作为其第一个参数。

这种模式无处不在,我从一开始就把它设计到我的服务器类型和接口中。下面是最近一个连接到谷歌云存储的项目的例子:Go 1.7给我们带来了包上下文,从那时起,它就一直在影响我们的代码。这不是一件坏事!上下文是管理goroutine生命周期的一种很好理解且无处不在的方法,这是一个大而困难的问题。根据我的经验,这是它们最重要的功能。如果您有一个组件由于任何原因阻塞—通常是网络I/O,有时是磁盘I/O,可能是由于用户回调,等等—那么它可能应该将上下文作为其第一个参数。

这种模式无处不在,我从一开始就把它设计到我的服务器类型和接口中。下面是最近一个连接到谷歌云存储的项目的例子:

1
2
3
4
5
6
// reportStore is a thin domain abstraction over GCS.
type reportStore interface {
listTimes(ctx context.Context, from time.Time, n int) ([]time.Time, error)
writeFile(ctx context.Context, ts time.Time, name string, r io.Reader) error
serveFile(ctx context.Context, ts time.Time, name string, w io.Writer) error
}

为生命周期语义编写上下文感知的组件很简单:只要确保代码响应ctx.Done即可。事实证明,使用上下文的值传播特性有点棘手。上下文的问题。值是键和值是无类型的,并且不能保证存在,这将使程序面临运行时成本和故障模式,否则这些问题是可以避免的。我的经验是开发人员过度使用上下文。值,这些值实际上应该是常规依赖项或函数参数。

所以,一个基本的经验是:只使用上下文。无法以任何其他方式通过程序传递的数据的值。实际上,这意味着只使用上下文。值,用于请求范围的信息,如请求id、用户身份验证令牌等,这些信息仅在请求时创建或在请求期间创建。如果该信息在程序启动时可用,比如数据库句柄或日志记录器,则不应通过上下文传递该信息。

同样,这个建议的基本原理归结为可维护性。如果一个组件以构造函数或函数参数的形式枚举它的需求(在编译时可检查),这要比从一个只在运行时可检查的无类型、不透明的值包中提取信息好得多。后者不仅更加脆弱,而且使理解和调整代码变得更加困难。

总结

我做这个最佳实践系列已经有六年了,虽然有一些技巧来来去去,特别是针对新出现的习惯用法和模式,但值得注意的是,作为一个有效的围棋程序员,所需的基础知识在这段时间内几乎没有改变。总的来说,我们并不追求设计趋势。我们有一个非常稳定的语言和生态系统,我相信我不仅仅是在为自己说话,当我说我真的很感激的时候。

我认为,在这样的会议上,我们能够彼此熟悉,这是很好的。但我认为我们在这里能做的最好的事情是建立对彼此的同理心。如果我们把工作做好,随着Go程序员数量的不断增长,社区将变得越来越多样化,具有不同的工作流、上下文和目标。我很高兴向大家介绍了我在Go上的一些体验报告,我也很高兴能听到并理解大家的感受。多谢!

Powered by Hexo and Hexo-theme-hiker

Copyright © 2013 - 2021 朝着牛逼的道路一路狂奔 All Rights Reserved.

访客数 : | 访问量 :