Accelerate[1]一书的作者用了整整一章来讨论软件体系结构以及它如何影响开发效率。其中反复提到了将应用程序设计为“松散耦合”。
能够使团队顺利完成工作(从设计到部署),并且不需要在不同团队之间进行大量的交流是您的架构目标。 —— Accelerate
如果你还没有阅读过 Accelerate, 我非常建议您一读,这本书提供了提高开发团队工作效率的科学依据。本文描述的方法不仅基于我们的经验,而且在本书中也被反复提到。
耦合似乎主要存在于多个团队的微服务之间,但我们发现在团队内部的工作中同样如此。保持架构的标准能够让团队成员之间并行工作,对于新加入团队的成员也很有帮助。
您可能听说过“高内聚,低耦合”的概念,但是很少有人告诉您如何去实现它。好消息是,这是整洁架构(Clean Architecture)的主要好处。
整洁架构不仅是启动新项目的极佳方式,在对那些设计拙劣的应用程序进行重构时也同样很有帮助。在这篇文章中,我们将聚焦于后者,我展示了一个真实应用程序的重构,所以你应该会很清楚如何在你的项目中应用类似的变更。
整洁架构还有一些其他的好处:
-
标准的结构,你将很容易找到你想要找的地方 -
从长远来看发展得更快 -
在单元测试中更加容易mock依赖 -
易于从原型切换到真正的解决方案(例如,从内存存储更改为 SQL 数据库)。
整洁架构
我花了很长时间才想出这篇文章的标题,因为这个模式有很多种称呼。有 整洁架构(Clean Architecture)[2], 洋葱架构(Onion Architecture)[3], 六边形架构(Hexagonal Architecture)[4], 端口(Ports)和适配器(Adapters)。
在过去的几年中,我们试图用一些惯用的方式在Go中使用这些模式,尝试一些方法,失败,改良,然后再试一次。
我们得到了上述想法的混合体,有时并不严格遵循原始的模式,但我们发现它在实际使用时效果还不错。我将通过重构我们的示例应用程序Wild Workout[5] 来展示我们的方法。
我想说明一下,这个想法算不上新奇,核心思想是将实现细节抽象出来,即技术标准,尤其是软件标准。
它的另一个名字是“关注点分离”,这个概念已经很古老了,现在已经存在于许多层面了,有structures
, namespaces
, modules
, packages
, 甚至是(micro)services
,都是为了把相关的东西限制在一个范围内。有时候感觉就像是常识一样,比如:
-
优化 SQL 查询语句,但不希望更改显示的格式。 -
更改 HTTP 响应格式,但不希望更改数据库结构。
我们引入整洁架构的方法是两种思想的结合: 分离端口(Ports)和适配器(Adapters)并且限制代码结构之间的相互引用。
这不是一篇随便带点代码片段的文章,这篇文章是一个更大的系列的一部分,在这个系列中,我们将展示如何构建易于开发、维护和长期使用的 Go 应用程序。我们基于我们的团队和科学研究[6]来分享我们所做的许多已证实的实验和技术。
您可以通过与我们一起构建一个功能齐全的示例 Go web 应用程序-Wild Workouts 来学习这些模式。 我们做了一件不同的事情——我们在最初的 Wild Workout 实现中包含了一些不易被察觉的问题😉,这些问题在许多 Go 项目中很常见。从长远来看,这些小问题会变得越来越明显。
这是资深开发人员或技术leader的基本技能之一: 您总是需要考虑长期的影响。
我们将通过重构 Wild Workout 来解决这些问题。这样,您将很快理解我们分享的技术。
你知道在阅读了一篇关于某种技术的文章并尝试实现它时,却被指南中跳过的一些问题所阻碍的感觉吗?删除这些细节可以使文章更短,增加页面浏览量,但这不是我们的目标。我们的目标是生产内容,提供足够的知识来应用所提供的技术。如果您还没有阅读本系列之前的文章[7],我们强烈建议您这样做。
我们相信,在一些领域,没有捷径可走。如果希望以快速有效的方式构建复杂的应用程序,则需要花费一些时间来学习这一点。如果它很简单,我们就不会有大量可怕的遗留代码。
Wild Workout 的完整源代码可以在 GitHub[8] 上找到。别忘了给我们的项目点个star!⭐
在开始之前
在将整洁架构引入 Wild Workout 之前,我对项目进行了一些重构。这些变更来自于我们在之前的文章中分享的模式。
第一种是对数据库实体和 HTTP 响应使用单独的模型。在我关于 DRY 原则的文章中,我已经介绍了user
服务的变化。我现在在trainer
和tranings
服务中也采用了同样的模式,你可以在 GitHub 上查看完整的提交。
第二个更改遵循 Robert 在前一篇文章[9]中介绍的存储库模式。我的重构[10]将trainings
中与数据库相关的代码移到了一个单独的结构中。
分离Port和Adapter
译注: 这里的”端口(Port)”和”适配器(Adapter)”可以理解为我们项目中常提到的”层”, 由于端口在中文语境中更像是指计算机网络通信所用到的端口,因此为了避免混淆,下文的名词翻译会统一使用英文原文
port
和adapter
也有许多其他的叫法,如interface和infrastrcture。其思想是显式地将这两部分与应用程序代码的其余部分分隔开。
我们将这些代码放在不同的包中。我们称之为“层(layer)”。我们通常使用的层是adapter
、port
、application
和domain
。
如果你了解六边形架构, 请看这里
您可能对
port
和adapter
感到困惑:
我们的port是六边形架构的主要适配器。 我们的adapter是六边形架构的次要适配器。 我们发现原始命名里的主要/次要命名很难掌握,所以请随意使用适合您的命名,思想是不变的。您可以使用gateways、entry points、interfaces、infrastrcture等等,只要在你的团队中保持一致即可。
那原来的port(层)呢?由于 Go 的隐式接口,我们认为保持专用的接口层没有任何价值。我们将接口保持在使用它们的地方附近(见下文)。
-
adapter是应用程序与外部世界通信的方式。 您必须调整您的内部结构以适应外部 API 的期望。例如 SQL 查询、 HTTP 或 gRPC 客户端、文件读取和写入、 Pub/Sub 消息发布。 -
port是应用程序的输入,是外部世界访问它的唯一途径。它可以是 HTTP 或 gRPC 服务端、 CLI 命令或 Pub/Sub 消息订阅者。 -
application层是一个“粘合”其他层的薄层。它也被称为“用例(use cases)”。如果您读了这段代码,但是不知道它使用的是什么数据库,或者它调用的是什么 URL,那么这是一个好兆头。它的代码有时候看起来很短,没关系,你可以把它想象成一个编排器(orchestrator)。 -
如果你也遵循领域驱动设计[11],你可以引入一个只包含业务逻辑的domain层。
如果分层的想法还不清楚,看看你的智能手机。仔细想想,它也使用了类似的概念。
您可以使用物理按钮、触摸屏或语音助手来控制您的智能手机。无论你按下“音量调高”按钮,滑动音量栏,或者说“Hey Siri,音量调高”,效果都是一样的。“更改音量”的逻辑有好几个入口(port)。
当你播放一些音乐时,你可以听到它从扬声器里传出来。如果你插上耳机,音频就会自动变成耳机。你的音乐应用根本不在乎。它不直接与硬件对话,而是使用操作系统提供的 适配器(adapter) 之一。
想象一下,创建一个这样的App:它必须了解连接到智能手机的具体耳机型号。在应用程序逻辑中直接包含SQL查询是类似的:它暴露了实现的细节
让我们通过在trainings
服务中引入”层”来开始重构。现在的该项目结构是这样的:
trainings/
├── firestore.go
├── go.mod
├── go.sum
├── http.go
├── main.go
├── openapi_api.gen.go
└── openapi_types.gen.go
这个环节的重构很简单:
-
创建 ports
,adapters
和app
目录。 -
将每个文件移动到合适的目录。
trainings/
├── adapters
│ └── firestore.go
├── app
├── go.mod
├── go.sum
├── main.go
└── ports
├── http.go
├── openapi_api.gen.go
└── openapi_types.gen.go
这次我们不会对user
服务做任何更改,它很小,而我们只在有意义的地方应用整洁架构。
如果项目的规模不断扩大,您可能会发现添加另一级别的子目录会很有帮助,例如
adapters/hour/mysql_repository.go
或ports/http/hour_handler.go
.
您可能注意到app
包中没有文件。现在,我们必须从HTTP handler中提取应用程序逻辑。
Application 层
我们来看看我们的应用程序逻辑在哪里:先看看trainings
服务中的CancelTraining
方法。
func (h HttpServer) CancelTraining(w http.ResponseWriter, r *http.Request) {
trainingUUID := r.Context().Value("trainingUUID").(string)
user, err := auth.UserFromCtx(r.Context())
if err != nil {
httperr.Unauthorised("no-user-found", err, w, r)
return
}
err = h.db.CancelTraining(r.Context(), user, trainingUUID)
if err != nil {
httperr.InternalError("cannot-update-training", err, w, r)
return
}
}
// 完整代码在: github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainings/http.go
这个方法是应用程序的入口,这里没有多少逻辑,所以让我们深入看一下db.CancelTraining
方法。
在Firestore中,有很多代码不属于数据库处理。
更糟糕的是,此方法中的业务逻辑使用数据库模型(TrainingModel
)进行决策:
// training := &TrainingModel{}
if training.canBeCancelled() {
// ...
} else {
// ...
}
// 完整的源码在:github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainings/firestore.go
将业务规则(比如是否可以取消培训)与数据库模型混合会降低开发速度,因为代码变得难以理解和推理。测试这种逻辑也很困难。
为了解决这个问题,我们在app
层中添加了一个中间的Training
类型:
type Training struct {
UUID string
UserUUID string
User string
Time time.Time
Notes string
ProposedTime *time.Time
MoveProposedBy *string
}
func (t Training) CanBeCancelled() bool {
return t.Time.Sub(time.Now()) > time.Hour*24
}
func (t Training) MoveRequiresAccept() bool {
return !t.CanBeCancelled()
}
// 完整源码在: github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainings/app/training.go
什么时候可以取消培训,现在一读就应该清楚了。我们无法说明培训是如何存储在数据库中的,这是个好兆头。
现在,我们可以更新数据库层方法以返回此通用应用程序类型,而不是特定的数据库结构(TrainingModel
)。将两者进行映射的代价是微不足道的,因为两个结构具有相同的字段(但是从现在开始,它们可以彼此独立发展)
t := TrainingModel{}
if err := doc.DataTo(&t); err != nil {
return nil, err
}
trainings = append(trainings, app.Training(t))
// 完整源码在: github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainings/adapters/trainings_firestore_repository.go
Application Service
然后,我们在app
包中创建一个TrainingsService
结构,该结构将作为trainings应用程序逻辑的切入点。
type TrainingService struct {
}
func (c TrainingService) CancelTraining(ctx context.Context, user auth.User, trainingUUID string) error {
}
那我们现在怎么调用数据库?让我们尝试复制目前在 HTTP handler程序中使用的内容。
type TrainingService struct {
db adapters.DB
}
func (c TrainingService) CancelTraining(ctx context.Context, user auth.User, trainingUUID string) error {
return c.db.CancelTraining(ctx, user, trainingUUID)
}
不过,这段代码不能编译。
import cycle not allowed
package github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainings
imports github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainings/adapters
imports github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainings/app
imports github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainings/adapters
我们需要确定层和层之间如何相互引用。
依赖反转原则
将port,adapter和业务逻辑之间进行分离本身就很有用,而整洁架构通过依赖反转进一步改进了它。
该原则规定,外部层(实现细节)可以引用内部层(抽象) ,但不能反过来。内部层应该依赖于接口。
-
Domain对其他层一无所知,它包含纯业务逻辑。 -
Application可以导入Domain,但对外层一无所知。它不知道它是由 HTTP 请求、 Pub/Sub 处理程序还是 CLI 命令调用。 -
Port可以导入内部层。Port是应用程序的入口点,因此它们通常执行应用程序服务或命令。但是,它们不能直接访问Adapter。 -
Adapter可以导入内部层。通常,它们将操作 Application 和 Domain 中的类型,例如,从数据库中检索它们。
再强调一次,这不是什么新想法。依赖反转原则是 SOLID[12] 中的“D”。它不只适用于OOP, Go 接口与之完美匹配[13]。
该原则解决了包之间如何相互引用的问题。这样做的好处是显而易见的,尤其是在 Go 中,因为它禁止循环依赖。也许这就是为什么一些开发人员声称最好避免“嵌套”并将所有代码保存在一个包中的原因。但是包的存在是有原因的,那就是分离关注点。
回到我们的例子,我们应该如何引用数据库层?
因为 Go interface 不需要显式实现,所以我们可以在需要它们的代码旁边定义它们。
因此,业务定义: “我需要一种方法来取消指定UUID的training。我不管你怎么做,但是我相信如果你实现了这个接口,你会做得很好”。
type trainingRepository interface {
CancelTraining(ctx context.Context, user auth.User, trainingUUID string) error
}
type TrainingService struct {
trainingRepository trainingRepository
}
func (c TrainingService) CancelTraining(ctx context.Context, user auth.User, trainingUUID string) error {
return c.trainingRepository.CancelTraining(ctx, user, trainingUUID)
}
// 完整源码在:github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainings/app/training_service.go
数据库方法调用trainer
和user
服务的 gRPC 客户端。但这里不是个合适的位置,所以我们将使用两个新的接口来引入两个服务。
type userService interface {
UpdateTrainingBalance(ctx context.Context, userID string, amountChange int) error
}
type trainerService interface {
ScheduleTraining(ctx context.Context, trainingTime time.Time) error
CancelTraining(ctx context.Context, trainingTime time.Time) error
}
// 完整源码在:github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainings/app/training_service.go
请注意,此上下文中的“user”和“trainer”不是微服务,而是应用程序(业务)概念。在这个项目中,它们恰好处于同名微服务的范围内。
我们将这些接口的实现移动到adapters
,如UsersGrpc[14] 和 TrainerGrpc[15]。一个额外的好处是:时间戳转换现在也在那里实现,这对于应用程序服务来说是不可见的。
提取应用逻辑
现在代码已经可以编译了,但是我们的服务还做不了太多东西。现在是时候提取逻辑并将其放在适当位置了。
最后,我们可以使用 Repository文章[16]中的 update 函数模式从存储库中提取应用程序逻辑。
func (c TrainingService) CancelTraining(ctx context.Context, user auth.User, trainingUUID string) error {
return c.repo.CancelTraining(ctx, trainingUUID, func(training Training) error {
if user.Role != "trainer" && training.UserUUID != user.UUID {
return errors.Errorf("user '%s' is trying to cancel training of user '%s'", user.UUID, training.UserUUID)
}
var trainingBalanceDelta int
if training.CanBeCancelled() {
// just give training back
trainingBalanceDelta = 1
} else {
if user.Role == "trainer" {
// 1 for cancelled training +1 fine for cancelling by trainer less than 24h before training
trainingBalanceDelta = 2
} else {
// fine for cancelling less than 24h before training
trainingBalanceDelta = 0
}
}
if trainingBalanceDelta != 0 {
err := c.userService.UpdateTrainingBalance(ctx, training.UserUUID, trainingBalanceDelta)
if err != nil {
return errors.Wrap(err, "unable to change trainings balance")
}
}
err := c.trainerService.CancelTraining(ctx, training.Time)
if err != nil {
return errors.Wrap(err, "unable to cancel training")
}
return nil
})
}
// 完整源码在:github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainings/app/training_service.go
大量的逻辑表明,我们可能希望在将来的某个时候引入一个domain层。现在,让我们保持现状。
我只描述了一个 CancelTraining
方法的过程。请参考完整的diff[17]以了解我是如何重构所有其他方法的。
依赖注入
如何告诉服务使用哪个adaptor
? 首先,我们为服务定义一个简单的构造函数。
func NewTrainingsService(
repo trainingRepository,
trainerService trainerService,
userService userService,
) TrainingService {
if repo == nil {
panic("missing trainingRepository")
}
if trainerService == nil {
panic("missing trainerService")
}
if userService == nil {
panic("missing userService")
}
return TrainingService{
repo: repo,
trainerService: trainerService,
userService: userService,
}
}
// 完整的源代码在: github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainings/app/training_service.go
然后,在main.go
里,我们注入adaptor
。
trainingsRepository := adapters.NewTrainingsFirestoreRepository(client)
trainerGrpc := adapters.NewTrainerGrpc(trainerClient)
usersGrpc := adapters.NewUsersGrpc(usersClient)
trainingsService := app.NewTrainingsService(trainingsRepository, trainerGrpc, usersGrpc)
// 完整的源码在: github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainings/main.go
使用main
函数是注入依赖项的最简单的方法。在以后的文章中,随着项目变得更加复杂,我们将研究wire[18]。
添加测试
最初,该项目混合了所有层,不可能模拟依赖关系。测试它的唯一方法是使用集成测试,并运行数据库和所有的服务。
虽然用这样的测试覆盖一些场景是可以的,但是它们往往比单元测试更慢,而且不那么有趣。在更改之后,我能够用一个单元测试套件覆盖 CancelTraining[19]。
我使用了表驱动测试的标准 Go 方法,使所有情况都易于阅读和理解。
{
Name: "return_training_balance_when_trainer_cancels",
UserRole: "trainer",
Training: app.Training{
UserUUID: "trainer-id",
Time: time.Now().Add(48 * time.Hour),
},
ShouldUpdateBalance: true,
ExpectedBalanceChange: 1,
},
{
Name: "extra_training_balance_when_trainer_cancels_before_24h",
UserRole: "trainer",
Training: app.Training{
UserUUID: "trainer-id",
Time: time.Now().Add(12 * time.Hour),
},
ShouldUpdateBalance: true,
ExpectedBalanceChange: 2,
},
// 完整代码在: github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainings/app/training_service_test.go
我没有引入任何用于mocking的库。如果愿意,您可以使用它们,但是您的接口通常应该足够小,可以简单地编写专用的mock。
type trainerServiceMock struct {
trainingsCancelled []time.Time
}
func (t *trainerServiceMock) CancelTraining(ctx context.Context, trainingTime time.Time) error {
t.trainingsCancelled = append(t.trainingsCancelled, trainingTime)
return nil
}
// 完整源码在: github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainings/app/training_service_test.go
你有没有注意到 RepositoryMock
中有许多方法没有实现?这是因为我们对所有方法使用单一的training服务,所以我们需要实现完整的接口,即使只测试其中的一个。
我们将在CQRS[20]的下一篇文章中改进它。
What about the boilerplate?
你可能想知道我们是否引入了太多的样板文件。这个项目的规模确实是通过代码行增长的,但是这本身并没有造成任何伤害。 这是对松散耦合的一种投资[21],随着项目的增长,这种投资将获得回报。
一开始,把所有东西放在一个package里似乎比较容易,但是当你考虑在一个团队中工作时,有界限是有帮助的。如果您的所有项目都具有类似的结构,那么接纳新的团队成员就很简单了。考虑一下将所有层混合起来会有多难(Mattermost 的应用程序包[22]就是这种方法的一个例子)
处理应用程序error
我添加的另一个特性是port无关[23]的错误。它们允许应用程序层返回通用错误,HTTP 和 gRPC 处理程序都可以处理这些错误。
if from.After(to) {
return nil, errors.NewIncorrectInputError("date-from-after-date-to", "Date from after date to")
}
// 完整代码在: github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainer/app/hour_service.go
上面的错误转换为port层中的400 Bad Request
HTTP 响应。它包括一个可以在前端翻译并显示给用户的slug,这是另一种避免将实现细节暴露给业务逻辑的模式。
还有什么?
我鼓励您阅读完整的提交[24],看看我是如何重构 Wild Workout 的其他部分的。
您可能想知道如何正确使用“层”?在code review中还有什么需要注意的吗?
幸运的是,您可以通过静态分析检查,在本地使用 Robert 的 go-Clean[25] linter 检查您的项目,或者将其包含在 CI pipeline中。
分层之后,我们准备引入更高级的模式。
下一次,Robert[26] 将展示如何通过应用 CQRS 来改进项目。
如果您想阅读更多关于 Clean Architecture 的内容,请参见《为什么使用 Microservices 或 Monolith 仅仅是一个细节》。
原文: https://threedots.tech/post/introducing-clean-architecture/
作者: Miłosz Smółka
翻译: 赵不贪
参考资料
Accelerate,中文版译作《加速:企业数字化转型的24项核心能力》: https://book.douban.com/subject/30192146/
[2]整洁架构: https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
[3]洋葱架构: https://jeffreypalermo.com/2008/07/the-onion-architecture-part-1/
[4]六边形架构: https://web.archive.org/web/20180822100852/http://alistair.cockburn.us/Hexagonal+architecture
[5]Wild Workout示例程序: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example
[6]科学研究: https://threedots.tech/post/ddd-lite-in-go-introduction/?utm_source=about-wild-workouts#thats-great-but-do-you-have-any-evidence-it-works
[7]现代软件架构系列文章: https://threedots.tech/series/modern-business-software-in-go/?utm_source=about-wild-workouts
[8]Wild Workout源码: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example?utm_source=about-wild-workouts
[9]存储库模式: https://threedots.tech/post/repository-pattern-in-go/
[10]重构commit: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/commit/f89da08cc3c1c7ed8e7767415b04e87d3a5ef9cf
[11]领域驱动设计: https://threedots.tech/post/ddd-lite-in-go-introduction/
[12]SOLID: https://en.wikipedia.org/wiki/SOLID
[13]solid-go-design: https://dave.cheney.net/2016/08/20/solid-go-design
[14]UsersGrpc: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/e98630507809492b16496f4370dd26b1d26220d3/internal/trainings/adapters/users_grpc.go#L9
[15]TrainerGrpc: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/e98630507809492b16496f4370dd26b1d26220d3/internal/trainings/adapters/trainer_grpc.go#L13
[16]Repository文章: https://threedots.tech/post/repository-pattern-in-go/
[17]完整的diff: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/commit/e98630507809492b16496f4370dd26b1d26220d3
[18]Go依赖注入库wire: https://github.com/google/wire
[19]CalcelTraining单元测试: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/e98630507809492b16496f4370dd26b1d26220d3/internal/trainings/app/training_service_test.go
[20]BasicCQRS: https://threedots.tech/post/basic-cqrs-in-go
[21]松散耦合的投资: investment-in-loose-coupling
[22]MattermostApp: https://github.com/mattermost/mattermost-server/tree/master/app
[23]端口无关的错误: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/blob/e98630507809492b16496f4370dd26b1d26220d3/internal/common/errors/errors.go
[24]完整的commit: https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/commit/e98630507809492b16496f4370dd26b1d26220d3
[25]go-cleanarch: https://github.com/roblaszczak/go-cleanarch
[26]Robert: https://twitter.com/roblaszczak
原文始发于微信公众号(梦真日记):通过重构Go项目介绍整洁架构
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/167782.html