Go语言官方依赖注入工具Wire的使用教程

目录
  • 1. 前言
  • 2. 依赖注入(DI)是什么
  • 3. Wire Come
    • 3.1 简介
    • 3.2 快速使用
    • 3.3 基础概念
  • 4. Wire使用实践
    • 4.1 基础使用
    • 4.2 高级特性
    • 4.3 高阶使用
  • 5. 注意事项
    • 5.1 相同类型问题
    • 5.2 单例问题
  • 6. 结语

1. 前言

接触 Golang 有一段时间了,发现 Golang 同样需要类似 Java 中 Spring 一样的依赖注入框架。如果项目规模比较小,是否有依赖注入框架问题不大,但当项目变大之后,有一个合适的依赖注入框架是十分必要的。通过调研,了解到 Golang 中常用的依赖注入工具主要有 Inject 、Dig 等。但是今天主要介绍的是 Go 团队开发的 Wire,一个编译期实现依赖注入的工具。

2. 依赖注入(DI)是什么

说起依赖注入就要引出另一个名词控制反转( IoC )。IoC 是一种设计思想,其核心作用是降低代码的耦合度。依赖注入是一种实现控制反转且用于解决依赖性问题的设计模式。

举个例子,假设我们代码分层关系是 dal 层连接数据库,负责数据库的读写操作。那么我们的 dal 层的上一层 service 负责调用 dal 层处理数据,在我们目前的代码中,它可能是这样的:

// dal/user.go

func (u *UserDal) Create(ctx context.Context, data *UserCreateParams) error {
    db := mysql.GetDB().Model(&entity.User{})
    user := entity.User{
      Username: data.Username,
      Password: data.Password,
   }

    return db.Create(&user).Error
}

// service/user.go
func (u *UserService) Register(ctx context.Context, data *schema.RegisterReq) (*schema.RegisterRes, error) {
   params := dal.UserCreateParams{
      Username: data.Username,
      Password: data.Password,
   }

   err := dal.GetUserDal().Create(ctx, params)
   if err != nil {
      return nil, err
   }

   registerRes := schema.RegisterRes{
      Msg: "register success",
   }

   return &registerRes, nil
}

在这段代码里,层级依赖关系为 service -> dal -> db,上游层级通过 Getxxx实例化依赖。但在实际生产中,我们的依赖链比较少是垂直依赖关系,更多的是横向依赖。即我们一个方法中,可能要多次调用Getxxx的方法,这样使得我们代码极不简洁。

不仅如此,我们的依赖都是写死的,即依赖者的代码中写死了被依赖者的生成关系。当被依赖者的生成方式改变,我们也需要改变依赖者的函数,这极大的增加了修改代码量以及出错风险。

接下来我们用依赖注入的方式对代码进行改造:

// dal/user.go
type UserDal struct{
    DB *gorm.DB
}

func NewUserDal(db *gorm.DB) *UserDal{
    return &UserDal{
        DB: db
    }
}

func (u *UserDal) Create(ctx context.Context, data *UserCreateParams) error {
    db := u.DB.Model(&entity.User{})
    user := entity.User{
      Username: data.Username,
      Password: data.Password,
   }

    return db.Create(&user).Error
}

// service/user.go
type UserService struct{
    UserDal *dal.UserDal
}

func NewUserService(userDal dal.UserDal) *UserService{
    return &UserService{
        UserDal: userDal
    }
}

func (u *UserService) Register(ctx context.Context, data *schema.RegisterReq) (*schema.RegisterRes, error) {
   params := dal.UserCreateParams{
      Username: data.Username,
      Password: data.Password,
   }

   err := u.UserDal.Create(ctx, params)
   if err != nil {
      return nil, err
   }

   registerRes := schema.RegisterRes{
      Msg: "register success",
   }

   return &registerRes, nil
}

// main.go 
db := mysql.GetDB()
userDal := dal.NewUserDal(db)
userService := dal.NewUserService(userDal)

如上编码情况中,我们通过将 db 实例对象注入到 dal 中,再将 dal 实例对象注入到 service 中,实现了层级间的依赖注入。解耦了部分依赖关系。

在系统简单、代码量少的情况下上面的实现方式确实没什么问题。但是项目庞大到一定程度,结构之间的关系变得非常复杂时,手动创建每个依赖,然后层层组装起来的方式就会变得异常繁琐,并且容易出错。这个时候勇士 wire 出现了!

3. Wire Come

3.1 简介

Wire 是一个轻巧的 Golang 依赖注入工具。它由 Go Cloud 团队开发,通过自动生成代码的方式在编译期完成依赖注入。它不需要反射机制,后面会看到, Wire 生成的代码与手写无异。

3.2 快速使用

wire 的安装:

go get github.com/google/wire/cmd/wire

上面的命令会在 $GOPATH/bin 中生成一个可执行程序 wire,这就是代码生成器。可以把$GOPATH/bin 加入系统环境变量 $PATH 中,所以可直接在命令行中执行 wire 命令。

下面我们在一个例子中看看如何使用 wire

现在我们有这样的三个类型:

type Message string
type Channel struct {
    Message Message
}
type BroadCast struct {
    Channel Channel
}

三者的 init 方法:

func NewMessage() Message {
    return Message("Hello Wire!")
}
func NewChannel(m Message) Channel {
    return Channel{Message: m}
}
func NewBroadCast(c Channel) BroadCast {
    return BroadCast{Channel: c}
}

假设 Channel 有一个 GetMsg 方法,BroadCast 有一个 Start 方法:

func (c Channel) GetMsg() Message {
    return c.Message
}

func (b BroadCast) Start() {
    msg := b.Channel.GetMsg()
    fmt.Println(msg)
}

如果手动写代码的话,我们的写法应该是:

func main() {
    message := NewMessage()
    channel := NewChannel(message)
    broadCast := NewBroadCast(channel)

    broadCast.Start()
}

如果使用 wire,我们需要做的就变成如下的工作了:

1.提取一个 init 方法 InitializeBroadCast:

func main() {
    b := demo.InitializeBroadCast()

    b.Start()
}

2.编写一个 wire.go 文件,用于 wire 工具来解析依赖,生成代码:

//+build wireinject

package demo

func InitializeBroadCast() BroadCast {
    wire.Build(NewBroadCast, NewChannel, NewMessage)
    return BroadCast{}
}

注意:需要在文件头部增加构建约束://+build wireinject

3.使用 wire 工具,生成代码,在 wire.go 所在目录下执行命令:wire gen wire.go。会生成如下代码,即在编译代码时真正使用的Init函数:

// Code generated by Wire. DO NOT EDIT.

//go:generate wire
//+build !wireinject
func InitializeBroadCast() BroadCast {
    message := NewMessage()
    channel := NewChannel(message)
    broadCast := NewBroadCast(channel)
    return broadCast
}

我们告诉 wire,我们所用到的各种组件的 init 方法(NewBroadCastNewChannelNewMessage),那么 wire 工具会根据这些方法的函数签名(参数类型/返回值类型/函数名)自动推导依赖关系。

wire.go 和 wire_gen.go 文件头部位置都有一个 +build,不过一个后面是 wireinject,另一个是 !wireinject+build 其实是 Go 语言的一个特性。类似 C/C++ 的条件编译,在执行 go build 时可传入一些选项,根据这个选项决定某些文件是否编译。wire 工具只会处理有wireinject 的文件,所以我们的 wire.go 文件要加上这个。生成的 wire_gen.go 是给我们来使用的,wire 不需要处理,故有 !wireinject

3.3 基础概念

Wire 有两个基础概念,Provider(构造器)和 Injector(注入器)

  • Provider 实际上就是生成组件的普通方法,这些方法接收所需依赖作为参数,创建组件并将其返回。我们上面例子的 NewBroadCast 就是 Provider
  • Injector 可以理解为 Providers 的连接器,它用来按依赖顺序调用 Providers 并最终返回构建目标。我们上面例子的 InitializeBroadCast 就是 Injector

4. Wire使用实践

下面简单介绍一下 wire 在飞书问卷表单服务中的应用。

飞书问卷表单服务的 project 模块中将 handler 层、service 层和 dal 层的初始化通过参数注入的方式实现依赖反转。通过 BuildInjector 注入器来初始化所有的外部依赖。

4.1 基础使用

dal 伪代码如下:

func NewProjectDal(db *gorm.DB) *ProjectDal{
    return &ProjectDal{
        DB:db
    }
}

type ProjectDal struct {
   DB *gorm.DB
}

func (dal *ProjectDal) Create(ctx context.Context, item *entity.Project) error {
   result := dal.DB.Create(item)
   return errors.WithStack(result.Error)
}
// QuestionDal、QuestionModelDal...

service 伪代码如下:

func NewProjectService(projectDal *dal.ProjectDal, questionDal *dal.QuestionDal, questionModelDal *dal.QuestionModelDal) *ProjectService {
   return &projectService{
      ProjectDal:       projectDal,
      QuestionDal:      questionDal,
      QuestionModelDal: questionModelDal,
   }
}

type ProjectService struct {
   ProjectDal       *dal.ProjectDal
   QuestionDal      *dal.QuestionDal
   QuestionModelDal *dal.QuestionModelDal
}

func (s *ProjectService) Create(ctx context.Context, projectBo *bo.ProjectCreateBo) (int64, error) {}

handler 伪代码如下:

func NewProjectHandler(srv *service.ProjectService) *ProjectHandler{
    return &ProjectHandler{
        ProjectService: srv
    }
}

type ProjectHandler struct {
   ProjectService *service.ProjectService
}

func (s *ProjectHandler) CreateProject(ctx context.Context, req *project.CreateProjectRequest) (resp *
project.CreateProjectResponse, err error) {}

injector.go 伪代码如下:

func NewInjector()(handler *handler.ProjectHandler) *Injector{
    return &Injector{
        ProjectHandler: handler
    }
}

type Injector struct {
   ProjectHandler *handler.ProjectHandler
   // components,others...
}

在 wire.go 中如下定义:

// +build wireinject

package app

func BuildInjector() (*Injector, error) {
   wire.Build(
      NewInjector,

      // handler
      handler.NewProjectHandler,

      // services
      service.NewProjectService,
      // 更多service...

      //dal
      dal.NewProjectDal,
      dal.NewQuestionDal,
      dal.NewQuestionModelDal,
      // 更多dal...

      // db
      common.InitGormDB,
      // other components...
   )

   return new(Injector), nil
}

执行 wire gen ./internal/app/wire.go 生成 wire_gen.go

// Code generated by Wire. DO NOT EDIT.

//go:generate wire
//+build !wireinject

func BuildInjector() (*Injector, error) {
   db, err := common.InitGormDB()
   if err != nil {
      return nil, err
   }
   
   projectDal := dal.NewProjectDal(db)
   questionDal := dal.NewQuestionDal(db)
   questionModelDal := dal.NewQuestionModelDal(db)
   projectService := service.NewProjectService(projectDal, questionDal, questionModelDal)
   projectHandler := handler.NewProjectHandler(projectService)
   injector := NewInjector(projectHandler)
   return injector, nil
}

在 main.go 中加入初始化 injector 的方法 app.BuildInjector

injector, err := BuildInjector()
if err != nil {
   return nil, err
}

//project服务启动
svr := projectservice.NewServer(injector.ProjectHandler, logOpt)
svr.Run()

注意,如果你运行时,出现了 BuildInjector 重定义,那么检查一下你的 //+build wireinject 与 package app 这两行之间是否有空行,这个空行必须要有!见https://github.com/google/wire/issues/117

4.2 高级特性

4.2.1 NewSet

NewSet 一般应用在初始化对象比较多的情况下,减少 Injector 里面的信息。当我们项目庞大到一定程度时,可以想象会出现非常多的 Providers。NewSet 帮我们把这些 Providers 按照业务关系进行分组,组成 ProviderSet(构造器集合),后续只需要使用这个集合即可。

// project.go
var ProjectSet = wire.NewSet(NewProjectHandler, NewProjectService, NewProjectDal)

// wire.go
func BuildInjector() (*Injector, error) {
   wire.Build(InitGormDB, ProjectSet, NewInjector)

   return new(Injector), nil
}

4.2.2 Struct

上述例子的 Provider 都是函数,除函数外,结构体也可以充当 Provider 的角色。Wire 给我们提供了结构构造器(Struct Provider)。结构构造器创建某个类型的结构,然后用参数或调用其它构造器填充它的字段。

// project_service.go
// 函数provider
func NewProjectService(projectDal *dal.ProjectDal, questionDal *dal.QuestionDal, questionModelDal *dal.QuestionModelDal) *ProjectService {
   return &projectService{
      ProjectDal:       projectDal,
      QuestionDal:      questionDal,
      QuestionModelDal: questionModelDal,
   }
}

// 等价于
wire.Struct(new(ProjectService), "*") // "*"代表全部字段注入

// 也等价于
wire.Struct(new(ProjectService), "ProjectDal", "QuestionDal", "QuestionModelDal")

// 如果个别属性不想被注入,那么可以修改 struct 定义:
type App struct {
    Foo *Foo
    Bar *Bar
    NoInject int `wire:"-"`
}

4.2.3 Bind

Bind 函数的作用是为了让接口类型的依赖参与 Wire 的构建。Wire 的构建依靠参数类型,接口类型是不支持的。Bind 函数通过将接口类型和实现类型绑定,来达到依赖注入的目的。

// project_dal.go
type IProjectDal interface {
   Create(ctx context.Context, item *entity.Project) (err error)
   // ...
}

type ProjectDal struct {
   DB *gorm.DB
}

var bind = wire.Bind(new(IProjectDal), new(*ProjectDal))

4.2.4 CleanUp

构造器可以提供一个清理函数(cleanup),如果后续的构造器返回失败,前面构造器返回的清理函数都会调用。初始化 Injector 之后可以获取到这个清理函数,清理函数典型的应用场景是文件资源和网络连接资源。清理函数通常作为第二返回值,参数类型为 func()。当 Provider 中的任何一个拥有清理函数,Injector 的函数返回值中也必须包含该函数。并且 Wire 对 Provider 的返回值个数及顺序有以下限制:

  • 第一个返回值是需要生成的对象
  • 如果有 2 个返回值,第二个返回值必须是 func() 或 error
  • 如果有 3 个返回值,第二个返回值必须是 func(),而第三个返回值必须是 error
// db.go
func InitGormDB()(*gorm.DB, func(), error) {
    // 初始化db链接
    // ...
    cleanFunc := func(){
        db.Close()
    }

    return db, cleanFunc, nil
}

// wire.go
func BuildInjector() (*Injector, func(), error) {
   wire.Build(
      common.InitGormDB,
      // ...
      NewInjector
   )

   return new(Injector), nil, nil
}

// 生成的wire_gen.go
func BuildInjector() (*Injector, func(), error) {
   db, cleanup, err := common.InitGormDB()
   // ...
   return injector, func(){
       // 所有provider的清理函数都会在这里
       cleanup()
   }, nil
}

// main.go
injector, cleanFunc, err := app.BuildInjector()
defer cleanFunc()

更多用法具体可以参考 wire官方指南:https://github.com/google/wire/blob/main/docs/guide.md

4.3 高阶使用

接着我们就用上述的这些 wire 高级特性对 project 服务进行代码改造:

project_dal.go

type IProjectDal interface {
   Create(ctx context.Context, item *entity.Project) (err error)
   // ...
}

type ProjectDal struct {
   DB *gorm.DB
}

// wire.Struct方法是wire提供的构造器,"*"代表为所有字段注入值,在这里可以用"DB"代替
// wire.Bind方法把接口和实现绑定起来
var ProjectSet = wire.NewSet(
   wire.Struct(new(ProjectDal), "*"),
   wire.Bind(new(IProjectDal), new(*ProjectDal)))

func (dal *ProjectDal) Create(ctx context.Context, item *entity.Project) error {}
dal.go
// DalSet dal注入
var DalSet = wire.NewSet(
   ProjectSet,
   // QuestionDalSet、QuestionModelDalSet...
)

project_service.go

type IProjectService interface {
   Create(ctx context.Context, projectBo *bo.CreateProjectBo) (int64, error)
   // ...
}

type ProjectService struct {
   ProjectDal       dal.IProjectDal
   QuestionDal      dal.IQuestionDal
   QuestionModelDal dal.IQuestionModelDal

}
func (s *ProjectService) Create(ctx context.Context, projectBo *bo.ProjectCreateBo) (int64, error) {}

var ProjectSet = wire.NewSet(
   wire.Struct(new(ProjectService), "*"),
   wire.Bind(new(IProjectService), new(*ProjectService)))

service.go

// ServiceSet service注入
var ServiceSet = wire.NewSet(
   ProjectSet,
   // other service set...
)

handler 伪代码如下:

var ProjectHandlerSet = wire.NewSet(wire.Struct(new(ProjectHandler), "*"))

type ProjectHandler struct {
   ProjectService service.IProjectService
}

func (s *ProjectHandler) CreateProject(ctx context.Context, req *project.CreateProjectRequest) (resp *
project.CreateProjectResponse, err error) {}

injector.go 伪代码如下:

var InjectorSet = wire.NewSet(wire.Struct(new(Injector), "*"))

type Injector struct {
   ProjectHandler *handler.ProjectHandler
   // others...
}

wire.go

 // +build wireinject

package app

func BuildInjector() (*Injector, func(), error) {
   wire.Build(
      // db
      common.InitGormDB,
      // dal
      dal.DalSet,
      // services
      service.ServiceSet,
      // handler
      handler.ProjectHandlerSet,
      // injector
      InjectorSet,
      // other components...
   )

   return new(Injector), nil, nil
}

5. 注意事项

5.1 相同类型问题

wire 不允许不同的注入对象拥有相同的类型。google 官方认为这种情况,是设计上的缺陷。这种情况下,可以通过类型别名来将对象的类型进行区分。

例如服务会同时操作两个 Redis 实例,RedisA & RedisB

func NewRedisA() *goredis.Client {...}
func NewRedisB() *goredis.Client {...}

对于这种情况,wire 无法推导依赖的关系。可以这样进行实现:

type RedisCliA *goredis.Client
type RedisCliB *goredis.Client

func NewRedisA() RedicCliA {...}
func NewRedisB() RedicCliB {...}

5.2 单例问题

依赖注入的本质是用单例来绑定接口和实现接口对象间的映射关系。而通常实践中不可避免的有些对象是有状态的,同一类型的对象总是要在不同的用例场景发生变化,单例就会引起数据的错误,不能保存彼此的状态。针对这种场景我们通常设计多层的 DI 容器来实现单例隔离,亦或是脱离 DI 容器自行管理对象的生命周期。

6. 结语

Wire 是一个强大的依赖注入工具。与 Inject 、Dig 等不同的是,Wire只生成代码而不是使用反射在运行时注入,不用担心会有性能损耗。项目工程化过程中,Wire 可以很好协助我们完成复杂对象的构建组装。

更多关于 Wire 的介绍请传送至:https://github.com/google/wire

到此这篇关于Go语言官方依赖注入工具Wire的使用教程的文章就介绍到这了,更多相关Go语言 依赖注入Wire内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • 浅析golang的依赖注入

    目录 前言 基于反射的DI 基于代码生成的DI 前言 如果是做web开发,对依赖注入肯定不陌生,java程序员早就习惯了spring提供的依赖注入,做业务开发时非常方便,只关注业务逻辑即可,对象之间的依赖关系都交给框架. golang是强类型语言,编译后是机器码,所以一般使用 反射 或 代码生成 解决依赖注入的问题 基于反射的DI 基于反射解决DI问题的框架, 使用比较多的是Uber的 dig 库 官方的例子: type Config struct { Prefix string } //初始化

  • Go依赖注入DI工具wire使用详解(golang常用库包)

    目录 什么是依赖注入 第一次编写mysql操作类: 第二次编写mysql操作类: 第三次编写mysql操作类: 何时使用依赖注入 wire 概念说明 provider 和 injector provider injector wire 使用 快速开始 小结 绑定接口 Provider Set 参考 google 出品的依赖注入库 wire:https://github.com/google/wire 什么是依赖注入 依赖注入 ,英文全名是 dependency injection,简写为 DI.

  • golang不到30行代码实现依赖注入的方法

    本文介绍了golang不到30行代码实现依赖注入的方法,分享给大家,具体如下: 项目地址 go-di-demo 本项目依赖 使用标准库实现,无额外依赖 依赖注入的优势 用java的人对于spring框架一定不会陌生,spring核心就是一个IoC(控制反转/依赖注入)容器,带来一个很大的优势是解耦.一般只依赖容器,而不依赖具体的类,当你的类有修改时,最多需要改动一下容器相关代码,业务代码并不受影响. golang的依赖注入原理 总的来说和java的差不多,步骤如下:(golang不支持动态创建对

  • Go语言官方依赖注入工具Wire的使用教程

    目录 1. 前言 2. 依赖注入(DI)是什么 3. Wire Come 3.1 简介 3.2 快速使用 3.3 基础概念 4. Wire使用实践 4.1 基础使用 4.2 高级特性 4.3 高阶使用 5. 注意事项 5.1 相同类型问题 5.2 单例问题 6. 结语 1. 前言 接触 Golang 有一段时间了,发现 Golang 同样需要类似 Java 中 Spring 一样的依赖注入框架.如果项目规模比较小,是否有依赖注入框架问题不大,但当项目变大之后,有一个合适的依赖注入框架是十分必要的

  • Golang 官方依赖注入工具wire示例详解

    目录 依赖注入是什么 开源选型 wire providers injectors 类型区分 总结 依赖注入是什么 Dependency Injection is the idea that your components (usually structs in go) should receive their dependencies when being created. 在 Golang 中,构造一个结构体常见的有两种方式: 在结构体初始化过程中,构建它的依赖: 将依赖作为构造器入参,传入进

  • C#控制台程序中使用官方依赖注入的实现

    asp.net core 中已经自带了一个官方的依赖注入框架,现在想把它应用到控制台程序中,控制台程序是最简洁的代码结构,摒除了其他一堆嵌入的框架代码,只包含最简洁的入口函数,是学习基础类库框架的最佳选择,为什么最佳,原因很简单,没有其他项的干扰,Demo效果清晰明了,方便写测试代码,调试也顺畅. 1. 业务接口类设计编写 先要写一个测试用的接口和类,我写了一个很简单的计算求和的接口类和方法,方便待会注入演示效果. 我设计的演示接口很简单,IBaseService 基础接口负责生成一个随机的数字

  • Javascript技术栈中的四种依赖注入小结

    作为面向对象编程中实现控制反转(Inversion of Control,下文称IoC)最常见的技术手段之一,依赖注入(Dependency Injection,下文称DI)可谓在OOP编程中大行其道经久不衰.比如在J2EE中,就有大名鼎鼎的执牛耳者Spring.Javascript社区中自然也不乏一些积极的尝试,广为人知的AngularJS很大程度上就是基于DI实现的.遗憾的是,作为一款缺少反射机制.不支持Annotation语法的动态语言,Javascript长期以来都没有属于自己的Spri

  • Angular 4依赖注入学习教程之InjectToken的使用(八)

    学习目录 Angular 4 依赖注入教程之一 依赖注入简介 Angular 4 依赖注入教程之二 组件服务注入 Angular 4 依赖注入教程之三 ClassProvider的使用 Angular 4 依赖注入教程之四 FactoryProvider的使用 Angular 4 依赖注入教程之五 FactoryProvider配置依赖对象 Angular 4 依赖注入教程之六 Injectable 装饰器 Angular 4 依赖注入教程之七 ValueProvider的使用 Angular

  • Javascript技术栈中的四种依赖注入详解

    作为面向对象编程中实现控制反转(Inversion of Control,下文称IoC)最常见的技术手段之一,依赖注入(Dependency Injection,下文称DI)可谓在OOP编程中大行其道经久不衰.比如在J2EE中,就有大名鼎鼎的执牛耳者Spring.Javascript社区中自然也不乏一些积极的尝试,广为人知的AngularJS很大程度上就是基于DI实现的.遗憾的是,作为一款缺少反射机制.不支持Annotation语法的动态语言,Javascript长期以来都没有属于自己的Spri

  • 详解ASP.NET Core 中的框架级依赖注入

    1.ASP.NET Core 中的依赖注入 此示例展示了框架级依赖注入如何在 ASP.NET Core 中工作. 其简单但功能强大,足以完成大部分的依赖注入工作.框架级依赖注入支持以下 scope: Singleton - 总是返回相同的实例 Transient - 每次都返回新的实例 Scoped - 在当前(request)范围内返回相同的实例 假设我们有两个要通过依赖注入来进行工作的工件: PageContext - 自定义请求上下文 Settings - 全局应用程序设置 这两个都是非常

  • AngularJS学习笔记之依赖注入详解

    最近在看AngularJS权威指南,由于各种各样的原因(主要是因为我没有money,好讨厌的有木有......),于是我选择了网上下载电子版的(因为它不要钱,哈哈...),字体也蛮清晰的,总体效果还不错.但是,当我看到左上角的总页码的时候,479页....479....479....俺的小心脏被击穿了二分之一有木有啊,上半身都石化了有木有啊,那种特别想学但是看到页码又不想学的纠结的心情比和女朋友吵架了还复杂有木有啊,我平常看的电子书百位数都不大于3的好伐! 哎,原谅我吧,我应该多看几本新华字典习

随机推荐