Skip to content

Latest commit

 

History

History
838 lines (605 loc) · 38.2 KB

File metadata and controls

838 lines (605 loc) · 38.2 KB

八、文件系统备份

有许多解决方案提供文件系统备份功能。其中包括从 Dropbox、Box、Carbonite 等应用程序到苹果的 Time Machine、Seagate 或网络连接存储产品等硬件解决方案的一切。大多数消费者工具提供一些关键的自动功能,以及一个应用程序或网站,供您管理策略和内容。通常,特别是对于开发人员来说,这些工具并不能完成我们需要它们做的事情。然而,多亏了 Go 的标准库(其中包括ioutilos等软件包),我们拥有了构建一个性能完全符合我们需要的备份解决方案所需的一切。

在我们的最后一个项目中,我们将为源代码项目构建一个简单的文件系统备份,用于归档指定的文件夹,并在每次进行更改时保存它们的快照。当我们调整并保存一个文件,或者添加新的文件和文件夹,或者甚至删除一个文件时,可能会发生变化。我们希望能够回到任何时间点检索旧文件。

具体而言,在本章中,您将学习:

  • 如何构造由包和命令行工具组成的项目
  • 跨工具执行持久化简单数据的实用方法
  • os包如何允许您与文件系统交互
  • 如何在无限时间循环中运行代码,同时尊重Ctrl+C
  • 如何使用filepath.Walk迭代文件和文件夹
  • 如何快速确定目录的内容是否已更改
  • 如何使用archive/zip包压缩文件
  • 如何构建关注命令行标志和普通参数组合的工具

方案设计

我们将从开始,列出我们的解决方案的一些高级验收标准和我们想要采取的方法:

  • 当我们对源代码项目进行更改时,解决方案应该定期创建文件的快照
  • 我们希望控制检查目录更改的时间间隔
  • 代码项目主要基于文本,因此压缩目录以生成归档文件将节省大量空间
  • 我们将快速构建这个项目,同时密切关注以后可能需要改进的地方
  • 如果我们决定在将来更改实现,那么我们所做的任何实现决策都应该很容易修改
  • 我们将构建两个命令行工具,一个是完成这项工作的后端守护程序,另一个是用户交互实用程序,它允许我们列出、添加和删除备份服务中的路径

项目结构

在 Go 解决方案中,在单个项目中,既有一个允许其他 Go 程序员使用您的功能的包,也有一个允许最终用户使用您的代码的命令行工具,这是非常常见的。

通过将包放在主项目文件夹中,并将命令行工具放在名为cmdcmds的子文件夹(如果有多个命令)中,正在形成一种组织项目的约定。因为所有包(不管目录树如何)在 Go 中都是相等的,所以您可以从子包导入主包,因为您知道永远不需要从主包导入命令。这似乎是一种不必要的抽象,但实际上是一种非常常见的模式,可以在标准的 Go 工具链中看到,例如gofmtgoimports

例如,对于我们的项目,我们将编写一个名为backup的包,以及两个命令行工具:守护进程和用户交互工具。我们将按照以下方式构建我们的项目:

/backup - package
/backup/cmds/backupuser interaction tool
/backup/cmds/backupdworker daemon

备份包

我们将首先编写包,在编写相关工具时,我们将成为其中的第一个客户。包将负责决定目录是否已更改,是否需要备份,以及实际执行备份过程。

明显的接口?

当开始一个新的围棋程序时,首先要考虑的是,是否有任何界面对你来说很突出。我们不想过于抽象或浪费太多时间在设计一些我们知道会随着我们开始编写代码而改变的东西,但这并不意味着我们不应该寻找值得借鉴的明显概念。由于我们的代码将归档文件,Archiver接口作为候选接口弹出。

在您的GOPATH中创建一个名为backup的新文件夹,并添加以下archiver.go代码:

package backup

type Archiver interface {
  Archive(src, dest string) error
}

Archiver接口将指定一个名为Archive的方法,该方法获取源路径和目标路径并返回错误。此接口的实现将负责归档源文件夹,并将其存储在目标路径中。

预先定义一个接口是一个很好的方法,可以将一些概念从我们的头脑中提取出来并转化为代码;这并不意味着只要我们记住简单接口的强大功能,这个接口就不能随着我们解决方案的发展而改变。另外,请记住,io包中的大多数 I/O 接口只公开一个方法。

从一开始,我们就提出了这样一个观点:虽然我们将实现 ZIP 文件作为归档格式,但我们可以很容易地在以后用另一种Archiver格式替换它。

实现 ZIP

现在已经有了Archiver类型的接口,我们将实现一个使用 ZIP 文件格式的接口。

archiver.go中增加以下struct定义:

type zipper struct{}

我们不会导出这种类型,这可能会让您得出结论,即包外的用户将无法使用它。事实上,我们将为他们提供一个要使用的类型实例,以使他们不必担心创建和管理自己的类型。

添加以下导出的实现:

// Zip is an Archiver that zips and unzips files.
var ZIP Archiver = (*zipper)(nil)

Go voodoo 的这个奇怪的片段实际上是一种非常有趣的方式,可以在不使用任何内存(字面上是 0 字节)的情况下向编译器公开其意图。我们正在定义一个名为ZIP的变量,类型为Archiver,因此从包外可以很清楚地看到,如果您想要压缩内容,我们可以在需要Archiver的任何地方使用该变量。然后我们用nil强制转换将其分配给类型*zipper。我们知道nil不占用内存,但由于它被强制转换为zipper指针,并且我们的zipper结构没有字段,因此它是解决问题的合适方法,它对外部用户隐藏了代码的复杂性(实际上是实际实现)。包外的任何人都不需要知道我们的zipper类型,这让我们可以随时在不接触外部的情况下更改内部;接口的真正力量。

这个技巧的另一个方便的好处是,编译器现在将检查 zipper 类型是否正确实现了Archiver接口,因此如果您尝试构建此代码,您将得到一个编译器错误:

./archiver.go:10: cannot use (*zipper)(nil) (type *zipper) as type Archiver in assignment:
 *zipper does not implement Archiver (missing Archive method)

我们看到我们的zipper类型没有实现接口中规定的Archive方法。

您还可以在测试代码中使用Archive方法来确保您的类型实现它们应该实现的接口。如果不需要使用该变量,则始终可以使用下划线将其丢弃,并且仍然可以获得编译器帮助:

var _ Interface = (*Implementation)(nil)

为了让编译器满意,我们将为zipper类型添加Archive方法的实现。

将以下代码添加到archiver.go

func (z *zipper) Archive(src, dest string) error {
  if err := os.MkdirAll(filepath.Dir(dest), 0777); err != nil {
    return err
  }
  out, err := os.Create(dest)
  if err != nil {
    return err
  }
  defer out.Close()
  w := zip.NewWriter(out)
  defer w.Close()
  return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
    if info.IsDir() {
      return nil // skip
    }
    if err != nil {
      return err
    }
    in, err := os.Open(path)
    if err != nil {
      return err
    }
    defer in.Close()
    f, err := w.Create(path)
    if err != nil {
      return err
    }
    io.Copy(f, in)
    return nil
  })
}

您还必须从 Go 标准库导入archive/zip包。在我们的Archive方法中,我们采取以下步骤准备写入 ZIP 文件:

  • 使用os.MkdirAll确保目标目录存在。0777代码表示用于创建任何缺失目录的文件权限。
  • 使用os.Create创建dest路径指定的新文件。
  • 如果创建的文件没有错误,请使用defer out.Close()延迟关闭文件。
  • 使用zip.NewWriter创建一个新的zip.Writer类型,该类型将写入我们刚刚创建的文件,并推迟写入程序的关闭。

一旦我们有了zip.Writer类型,我们就可以使用filepath.Walk函数来迭代源目录src

filepath.Walk函数有两个参数:根路径和回调函数func,用于在文件系统中迭代时遇到的每个项(文件和文件夹)。filepath.Walk函数是递归函数,因此它也将深入子文件夹。回调函数本身有三个参数:文件的完整路径、描述文件或文件夹本身的os.FileInfo对象以及错误(如果出现错误,它也会返回错误)。如果对回调函数的任何调用导致返回错误,则操作将中止,filepath.Walk将返回该错误。我们只是把这个问题传递给Archive的来电者,让他们担心,因为我们已经无能为力了。

对于树中的每个项目,我们的代码都采取以下步骤:

  • 如果info.IsDir方法告诉我们该项目是一个文件夹,我们只返回nil,实际上跳过了它。没有理由将文件夹添加到 ZIP 存档中,因为无论如何,文件的路径都会为我们编码这些信息。
  • 如果传入错误(通过第三个参数),则表示尝试访问有关文件的信息时出错。这是不常见的,因此我们只返回错误,它最终将传递给Archive的调用者。
  • 使用os.Open打开源文件进行读取,如果成功则推迟关闭。
  • 调用ZipWriter对象上的Create以指示我们要创建一个新的压缩文件,并为其提供文件的完整路径,其中包括嵌套在其中的目录。
  • 使用io.Copy从源文件读取所有字节,并通过ZipWriter对象将它们写入我们之前打开的 ZIP 文件。
  • 返回nil表示没有错误。

本章将不涉及单元测试或测试驱动开发TDD实践,但请随意编写一个测试,以确保我们的实现达到预期目的。

提示

由于我们正在编写一个软件包,请花一些时间对迄今为止出口的作品进行评论。您可以使用golint来帮助您查找您可能遗漏的任何出口件。

文件系统是否已更改?

我们的备份系统面临的最大问题之一是以跨平台、可预测且可靠的方式确定文件夹是否已更改。当我们思考这个问题时,会想到一些事情:我们是否应该只检查顶级文件夹中最后修改的日期?当我们关心文件更改时,是否应该使用系统通知来获得通知?这两种方法都存在问题,事实证明这不是一个需要解决的小问题。

相反,我们将生成一个 MD5 散列,该散列由我们在考虑是否有更改时所关心的所有信息组成。

查看os.FileInfo类型,我们可以发现关于一个文件的很多信息:

type FileInfo interface {
  Name() string       // base name of the file
  Size() int64        // length in bytes for regular files; 
                         system-dependent for others
  Mode() FileMode     // file mode bits
  ModTime() time.Time // modification time
  IsDir() bool        // abbreviation for Mode().IsDir()
  Sys() interface{}   // underlying data source (can return nil)
}

为了确保我们知道对文件夹中任何文件的各种更改,哈希将由文件名和路径(因此,如果重命名文件,哈希将不同)、大小(如果文件大小更改,则大小明显不同)、上次修改日期、项目是文件还是文件夹以及文件模式位组成。即使我们不会存档这些文件夹,我们仍然关心它们的名称和文件夹的树结构。

创建一个名为dirhash.go的新文件,并添加以下功能:

package backup
import (
  "crypto/md5"
  "fmt"
  "io"
  "os"
  "path/filepath"
)
func DirHash(path string) (string, error) {
  hash := md5.New()
  err := filepath.Walk(path, func(path string, info os.FileInfo, err error) error {
    if err != nil {
      return err
    }
    io.WriteString(hash, path)
    fmt.Fprintf(hash, "%v", info.IsDir())
    fmt.Fprintf(hash, "%v", info.ModTime())
    fmt.Fprintf(hash, "%v", info.Mode())
    fmt.Fprintf(hash, "%v", info.Name())
    fmt.Fprintf(hash, "%v", info.Size())
    return nil
  })
  if err != nil {
    return "", err
  }
  return fmt.Sprintf("%x", hash.Sum(nil)), nil
}

我们首先创建一个新的hash.Hash,它知道如何计算 MD5,然后使用filepath.Walk遍历指定路径目录中的所有文件和文件夹。对于每个项目,假设没有错误,我们使用io.WriteString将差异信息写入哈希生成器,这允许我们将字符串写入io.Writerfmt.Fprintf,这两种方法相同,但同时公开了格式化功能,允许我们使用%v为每个项目生成默认值格式格式动词。

一旦处理完每个文件,并且假设没有发生错误,我们就使用fmt.Sprintf生成结果字符串。hash.Hash上的Sum方法计算附加了指定值的最终哈希值。在我们的例子中,我们不想附加任何内容,因为我们已经添加了所有我们关心的信息,所以我们只需通过nil%x格式动词表示我们希望值以十六进制(以 16 为基数)表示,并用小写字母表示。这是表示 MD5 哈希的常用方法。

检查更改并启动备份

现在我们已经能够散列文件夹并执行备份,我们将把这两个文件放在一个名为Monitor的新类型中。Monitor类型将有一个路径映射及其相关的散列,一个对任何Archiver类型的引用(当然,我们现在将使用backup.ZIP类型),以及一个表示归档文件存放位置的目标字符串。

创建一个名为monitor.go的新文件,并添加以下定义:

type Monitor struct {
  Paths       map[string]string
  Archiver    Archiver
  Destination string
}

为了触发更改检查,我们将添加以下Now方法:

func (m *Monitor) Now() (int, error) {
  var counter int
  for path, lastHash := range m.Paths {
    newHash, err := DirHash(path)
    if err != nil {
      return 0, err
    }
    if newHash != lastHash {
      err := m.act(path)
      if err != nil {
        return counter, err
      }
      m.Paths[path] = newHash // update the hash
      counter++
    }
  }
  return counter, nil
}

Now方法在映射中的每个路径上迭代,并生成该文件夹的最新哈希值。如果哈希与映射中的哈希不匹配(上次检查时生成),则认为它已更改,需要再次备份。在使用这个新的散列更新映射中的散列之前,我们通过调用尚未编写的act方法来实现这一点。

为了让我们的用户在调用Now时能够高级指示发生了什么,我们还维护了一个计数器,每次备份文件夹时,计数器都会递增。稍后,我们将使用它让我们的最终用户了解系统正在做什么,而不必用信息轰炸他们。

m.act undefined (type *Monitor has no field or method act)

编译器再次帮助我们,提醒我们尚未添加act方法:

func (m *Monitor) act(path string) error {
  dirname := filepath.Base(path)
  filename := fmt.Sprintf("%d.zip", time.Now().UnixNano())
  return m.Archiver.Archive(path, filepath.Join(m.Destination, dirname, filename))
}

因为我们已经在 ZIPArchiver类型中完成了繁重的工作,所以我们在这里所要做的就是生成一个文件名,决定归档文件将放在哪里,并调用Archive方法。

提示

如果Archive方法返回错误,act方法和Now方法将分别返回错误。这种沿链传递错误的机制在 Go 中非常常见,它允许您处理可以做一些有用的恢复工作的情况,或者将问题推迟到其他人。

前面代码中的act方法使用time.Now().UnixNano()生成时间戳文件名,并硬编码.zip扩展名。

短时间内硬编码正常

硬编码像我们这样的文件扩展名在一开始是可以的,但是如果你考虑一下,我们在这里混合了一些担忧。如果我们将Archiver实现更改为使用 RAR 或我们制作的压缩格式,.zip扩展将不再合适。

提示

在继续阅读之前,考虑一下您可能采取哪些步骤来避免硬编码。文件扩展名的决定在哪里?为了避免正确地硬编码,您需要做哪些更改?

文件扩展名决策的正确位置可能在Archiver接口中,因为它知道它将进行的归档类型。所以我们可以添加一个Ext()字符串方法,并从act方法访问它。但是我们可以通过允许Archiver作者指定整个文件名格式,而不仅仅是扩展名,在不做太多额外工作的情况下增加一点额外的功能。

返回archiver.go更新Archiver接口定义:

type Archiver interface {
  DestFmt() string
  Archive(src, dest string) error
}

我们的zipper类型现在需要实现以下功能:

func (z *zipper) DestFmt() string {
  return "%d.zip"
}

现在我们可以要求我们的act方法从Archiver接口获取整个格式字符串,更新act方法:

func (m *Monitor) act(path string) error {
  dirname := filepath.Base(path)
  filename := fmt.Sprintf(m.Archiver.DestFmt(), time.Now().UnixNano())
  return m.Archiver.Archive(path, filepath.Join(m.Destination, dirname, filename))
}

用户命令行工具

我们将构建的两个工具中的第一个允许用户添加、列出和删除备份守护程序工具的路径(我们将在后面编写)。您可以公开 web 界面,甚至使用绑定包进行桌面用户界面集成,但我们将保持简单,并为自己构建一个命令行工具。

backup文件夹内创建一个名为cmds的新文件夹,并在该文件夹内创建另一个backup文件夹。

提示

最好将命令的文件夹和命令本身命名为相同的二进制文件。

在我们新的backup文件夹中,将以下代码添加到main.go

func main() {
  var fatalErr error
  defer func() {
    if fatalErr != nil {
      flag.PrintDefaults()
      log.Fatalln(fatalErr)
    }
  }()
  var (
    dbpath = flag.String("db", "./backupdata", "path to database directory")
  )
  flag.Parse()
  args := flag.Args()
  if len(args) < 1 {
    fatalErr = errors.New("invalid usage; must specify command")
    return
  }
}

我们首先定义我们的fatalErr变量,并延迟检查以确保值为nil的函数。如果不是,它将打印错误和标志默认值,并以非零状态代码退出。然后,我们定义一个名为db的标志,该标志需要指向filedb数据库目录的路径,然后解析标志并获取剩余参数,并确保至少有一个参数。

持久化小数据

为了跟踪路径和我们生成的哈希,我们需要某种数据存储机制,即使在我们停止和启动程序时也能理想地工作。我们有很多选择:从文本文件到完全水平可扩展的数据库解决方案。简单的 Go 精神告诉我们,为我们的小备份程序建立数据库依赖关系不是一个好主意;相反,我们应该问,解决这个问题的最简单方法是什么?

github.com/matryer/filedb软件包就是针对这类问题的实验性解决方案。它允许您与文件系统进行交互,就像它是一个非常简单的无模式数据库一样。它以mgo等软件包为设计先导,可用于数据查询需求非常简单的情况。在filedb中,数据库是一个文件夹,集合是一个文件,其中每行代表不同的记录。当然,随着filedb项目的发展,这一切都可能发生变化,但接口希望不会发生变化。

main功能的末尾添加以下代码:

db, err := filedb.Dial(*dbpath)
if err != nil {
  fatalErr = err
  return
}
defer db.Close()
col, err := db.C("paths")
if err != nil {
  fatalErr = err
  return
}

这里我们使用filedb.Dial函数连接filedb数据库。实际上,除了指定数据库的位置之外,这里没有发生什么事情,因为没有真正的数据库服务器可以连接(尽管这在将来可能会改变,这就是为什么接口中存在这样的规定)。如果成功,我们将推迟关闭数据库。关闭数据库实际上会做一些事情,因为可能会打开需要清理的文件。

按照mgo模式,接下来我们使用C方法指定一个集合,并在col变量中保留对它的引用。如果在任何时候发生错误,我们将其分配给fatalErr变量并返回。

为了存储数据,我们将定义一个名为path的类型,它将存储完整路径和最后一个散列值,使用 JSON 编码将其存储在我们的filedb数据库中。在main功能上方增加以下struct定义:

type path struct {
  Path string
  Hash string
}

解析参数

当调用flag.Args(与os.Args相反)时,我们会收到一个不包括标志的参数片段。这允许我们在同一个工具中混合标记参数和非标记参数。

我们希望我们的工具能够以以下方式使用:

  • 要添加路径:

    backup -db=/path/to/db add {path} [paths...]
  • 要删除路径:

    backup -db=/path/to/db remove {path} [paths...]
  • 要列出所有路径:

    backup -db=/path/to/db list

为了实现这一点,因为我们已经处理了标志,我们必须检查第一个(非标志)参数。

main功能中添加以下代码:

switch strings.ToLower(args[0]) {
case "list":
case "add":
case "remove":
}

在这里,我们只需打开第一个参数,将其设置为小写(如果用户键入backup LIST,我们仍然希望它工作)。

列出路径

为了在数据库中列出路径,我们将对路径的col变量使用ForEach方法。将以下代码添加到案例列表中:

var path path
col.ForEach(func(i int, data []byte) bool {
  err := json.Unmarshal(data, &path)
  if err != nil {
    fatalErr = err
    return false
  }
  fmt.Printf("= %s\n", path)
  return false
})

我们向ForEach传递一个回调函数,该函数将为该集合中的每个项调用。然后我们将它从 JSON 中Unmarshal转换成path类型,然后使用fmt.Printf打印出来。我们按照filedb接口返回false,该接口告诉我们返回true将停止迭代,并且我们希望确保将它们全部列出。

您自己类型的字符串表示法

如果您以这种方式在 Go 中打印结构,使用%s格式动词,您可能会得到一些用户难以阅读的混乱结果。但是,如果该类型实现了一个String()字符串方法,那么将改用它,我们可以使用它来控制打印的内容。在路径结构下方,添加以下方法:

func (p path) String() string {
  return fmt.Sprintf("%s [%s]", p.Path, p.Hash)
}

这告诉path类型它应该如何将自己表示为字符串。

添加路径

要添加一条路径,或多条路径,我们将迭代剩余的参数,并为每个参数调用InsertJSON方法。将以下代码添加到add案例中:

if len(args[1:]) == 0 {
  fatalErr = errors.New("must specify path to add")
  return
}
for _, p := range args[1:] {
  path := &path{Path: p, Hash: "Not yet archived"}
  if err := col.InsertJSON(path); err != nil {
    fatalErr = err
    return
  }
  fmt.Printf("+ %s\n", path)
}

如果用户没有指定任何附加参数,例如,如果他们只是调用了backup add而没有键入任何路径,我们将返回一个致命错误。否则,我们将执行此操作并打印出路径字符串(前缀为+符号),以指示它已成功添加。默认情况下,我们会将散列设置为Not yet archived字符串文字。这是一个无效的散列,但有两个目的,即让用户知道它尚未存档,以及向我们的代码表明这一点(假定文件夹的散列永远不会等于该字符串)。

移除路径

要删除一条或多条路径,我们使用RemoveEach方法收集路径。将以下代码添加到remove案例中:

var path path
col.RemoveEach(func(i int, data []byte) (bool, bool) {
  err := json.Unmarshal(data, &path)
  if err != nil {
    fatalErr = err
    return false, true
  }
  for _, p := range args[1:] {
    if path.Path == p {
      fmt.Printf("- %s\n", path)
      return true, false
    }
  }
  return false, false
})

我们提供给RemoveEach的回调函数要求我们返回两种 bool 类型:第一种表示是否应该删除该项,第二种表示是否应该停止迭代。

使用我们的新工具

我们已经完成了简单的backup命令行工具。让我们看看它的实际行动。在backup/cmds/backup内创建一个名为backupdata的文件夹;这将成为filedb数据库。

导航到main.go文件并运行以下命令,在终端中构建工具:

go build -o backup

如果一切正常,我们现在可以添加一条路径:

./backup -db=./backupdata add ./test ./test2

您应该看到预期的输出:

+ ./test [Not yet archived]
+ ./test2 [Not yet archived]

现在,让我们添加另一条路径:

./backup -db=./backupdata add ./test3

您现在应该可以看到完整的列表:

./backup -db=./backupdata list

我们的计划应该产生:

= ./test [Not yet archived]
= ./test2 [Not yet archived]
= ./test3 [Not yet archived]

让我们删除test3以确保删除功能正常工作:

./backup -db=./backupdata remove ./test3
./backup -db=./backupdata list

这将使我们回到:

+ ./test [Not yet archived]
+ ./test2 [Not yet archived]

我们现在能够以一种对我们的用例有意义的方式与filedb数据库交互。接下来,我们构建守护程序,它将实际使用我们的backup包来完成这项工作。

守护进程备份工具

backup工具我们称之为backupd,它将负责定期检查filedb数据库中列出的路径,对文件夹进行散列以查看是否有任何更改,并使用backup包对需要它的文件夹进行实际归档。

backup/cmds/backup文件夹旁边创建一个名为backupd的新文件夹,让我们直接开始处理致命错误和标志:

func main() {
  var fatalErr error
  defer func() {
    if fatalErr != nil {
      log.Fatalln(fatalErr)
    }
  }()
  var (
    interval = flag.Int("interval", 10, "interval between checks (seconds)")
    archive  = flag.String("archive", "archive", "path to archive location")
    dbpath   = flag.String("db", "./db", "path to filedb database")
  )
  flag.Parse()
}

到现在为止,您一定已经非常习惯看到这种代码了。在指定三个标志之前,我们推迟了致命错误的处理:intervalarchivedbinterval标志表示检查文件夹是否已更改的间隔秒数,archive标志表示 ZIP 文件将进入的存档位置的路径,db标志表示backup命令正在与之交互的同一filedb数据库的路径。通常对flag.Parse的调用会设置变量并验证我们是否准备好继续。

为了检查文件夹的散列,我们需要一个前面编写的Monitor实例。将以下代码附加到main函数:

m := &backup.Monitor{
  Destination: *archive,
  Archiver:    backup.ZIP,
  Paths:       make(map[string]string),
}

这里我们使用archive值作为Destination类型创建一个backup.Monitor方法。我们将使用backup.ZIP归档程序创建一个映射,以便在内部存储路径和散列。在守护进程开始时,我们希望从数据库加载路径,以便在停止和启动时不会对其进行不必要的归档。

main功能中添加以下代码:

db, err := filedb.Dial(*dbpath)
if err != nil {
  fatalErr = err
  return
}
defer db.Close()
col, err := db.C("paths")
if err != nil {
  fatalErr = err
  return
}

您以前也见过此代码;它拨入数据库并创建一个对象,允许我们与paths集合交互。如果有任何失败,我们设置fatalErr并返回。

重复结构

由于我们将使用与我们的用户命令行工具程序相同的路径结构,因此我们也需要为这个程序包含它的定义。在main功能上方插入以下结构:

type path struct {
  Path string
  Hash string
}

毫无疑问,现在,面向对象的程序员们对要求共享代码片段只存在于一个地方而不在两个程序中重复的页面大喊大叫。我敦促你们抵制这种过早抽象的冲动。这四行代码很难证明一个新的包是正确的,因此也很难证明对我们的代码的依赖性,因为它们可以很容易地存在于两个程序中,并且开销很小。考虑一下,我们可能想在我们的 Tyl T1 程序中添加一个 Tyt T0 字段,这样我们就可以添加每个文件夹最多只保存一次一次的规则。我们的backup项目并不关心这一点,它将非常乐意地看到哪些领域构成了一条道路。

缓存数据

我们现在可以查询所有现有路径并更新Paths映射,这是一种提高程序速度的有用技术,尤其是在数据存储速度较慢或断开连接的情况下。通过将数据加载到缓存中(在我们的例子中是Paths地图),我们可以以极快的速度访问数据,而无需每次需要信息时都查阅文件。

将以下代码添加到main函数的主体中:

var path path
col.ForEach(func(_ int, data []byte) bool {
  if err := json.Unmarshal(data, &path); err != nil {
    fatalErr = err
    return true
  }
  m.Paths[path.Path] = path.Hash
  return false // carry on
})
if fatalErr != nil {
  return
}
if len(m.Paths) < 1 {
  fatalErr = errors.New("no paths - use backup tool to add at least one")
  return
}

再次使用ForEach方法允许我们迭代数据库中的所有路径。我们Unmarshal将 JSON 字节转换成与我们在其他程序中使用的相同的路径结构,并在Paths映射中设置值。假设没有出错,我们会进行最后检查,以确保至少有一条路径,如果没有,则返回一个错误。

我们的程序的一个限制是,一旦启动,它将不会动态添加路径。需要重新启动守护进程。如果这让你感到困扰,你可以建立一种机制,定期更新Paths地图。

无限循环

接下来我们需要做的是,在进入无限时间循环之前,立即对散列进行检查,以查看是否有任何内容需要存档,然后在该循环中,我们会定期进行检查。

无限循环听起来是个坏主意;事实上,对一些人来说,这听起来像一个错误。然而,由于我们讨论的是这个程序中的无限循环,并且由于无限循环可以很容易地用一个简单的break命令来打破,所以它们并不像听起来那么戏剧化。

在 Go 中,编写无限循环非常简单:

for {}

大括号内的指令会一次又一次地执行,只要运行代码的机器能够执行它们就可以了。这听起来像是一个糟糕的计划,除非你对要求它做的事情很谨慎。在我们的例子中,我们立即在两个通道上启动一个select案例,该案例将安全地阻塞,直到其中一个通道有有趣的话要说。

添加以下代码:

check(m, col)
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
for {
  select {
  case <-time.After(time.Duration(*interval) * time.Second):
    check(m, col)
  case <-signalChan:
    // stop
    fmt.Println()
    log.Printf("Stopping...")
    goto stop
  }
}
stop:

当然,作为负责任的程序员,我们关心当用户终止我们的程序时会发生什么。因此,在调用尚未存在的check方法后,我们创建一个信号通道,并使用signal.Notify请求将终止信号提供给该通道,而不是自动处理。在无限for循环中,我们选择两种可能性:timer通道发送消息或终止信号通道发送消息。如果是timer频道消息,我们再次调用check,否则我们将继续终止程序。

time.After功能返回一个通道,该通道将在指定时间过后发送信号(实际上是当前时间)。有些混乱的time.Duration(*interval) * time.Second代码只是指示信号发送前等待的时间;第一个*字符是解引用运算符,因为flag.Int方法表示指向 int 的指针,而不是 int 本身。第二个*字符将间隔值乘以time.Second,该值等于指定的间隔(以秒为单位)。需要将*interval int转换为time.Duration,以便编译器知道我们正在处理数字。

在前面的代码片段中,我们通过使用goto语句跳出开关并阻塞循环,沿着内存通道走了一小段路。我们可以完全取消goto语句,只在接收到终止信号时返回,但是这里讨论的模式允许我们在for循环之后运行非延迟代码,如果我们愿意的话。

更新文件数据库记录

剩下的就是我们要实现check函数,该函数应该调用Monitor类型上的Now方法,如果有新的散列,则使用新的散列更新数据库。

main函数下面,添加以下代码:

func check(m *backup.Monitor, col *filedb.C) {
  log.Println("Checking...")
  counter, err := m.Now()
  if err != nil {
    log.Fatalln("failed to backup:", err)
  }
  if counter > 0 {
    log.Printf("  Archived %d directories\n", counter)
    // update hashes
    var path path
    col.SelectEach(func(_ int, data []byte) (bool, []byte, bool) {
      if err := json.Unmarshal(data, &path); err != nil {
        log.Println("failed to unmarshal data (skipping):", err)
        return true, data, false
      }
      path.Hash, _ = m.Paths[path.Path]
      newdata, err := json.Marshal(&path)
      if err != nil {
        log.Println("failed to marshal data (skipping):", err)
        return true, data, false
      }
      return true, newdata, false
    })
  } else {
    log.Println("  No changes")
  }
}

check功能首先告诉用户正在进行检查,然后立即调用Now。如果Monitor类型为我们做了任何工作,也就是询问它是否存档了任何文件,我们将它们输出给用户,然后用新值更新数据库。SelectEach方法允许我们通过返回替换字节来更改集合中的每个记录(如果我们愿意)。因此,我们Unmarshal对字节进行排序,以获取路径结构,更新哈希值并返回封送的字节。这确保了下次我们启动backupd进程时,它将使用正确的散列值来启动。

测试我们的解决方案

让我们看看两个程序是否配合得很好,以及是什么影响backup包中的代码。您可能需要为此打开两个终端窗口,因为我们将运行两个程序。

我们已经向数据库添加了一些路径,所以让我们使用backup来查看它们:

./backup -db="./backupdata" list

您应该看到两个测试文件夹;如果没有,请参阅添加路径部分。

= ./test [Not yet archived]
= ./test2 [Not yet archived]

在另一个窗口中,导航到backupd文件夹并创建两个名为testtest2的测试文件夹。

使用通常的方法构建backupd

go build -o backupd

假设一切正常,我们现在可以开始备份过程,确保将db路径指向backup程序使用的路径,并指定我们要使用名为archive的新文件夹来存储 ZIP 文件。出于测试目的,让我们指定一个5秒的间隔来节省时间:

./backupd -db="../backup/backupdata/" -archive="./archive" -interval=5

立即,backupd应检查文件夹,计算散列,注意它们不同(与Not yet archived不同),并启动两个文件夹的归档过程。它将打印输出,告诉我们:

Checking...
Archived 2 directories

打开backup/cmds/backupd中新创建的文件夹,注意它已经创建了两个子文件夹:testtest2。里面是空文件夹的压缩存档版本。请随意解压缩一个并查看;目前还不太令人兴奋。

同时,回到终端窗口,backupd再次检查文件夹是否有更改:

Checking...
 No changes
Checking...
 No changes

在您喜爱的文本编辑器中,在包含单词testtest2文件夹中创建一个新的文本文件,并将其另存为one.txt。几秒钟后,您将看到backupd注意到了新文件,并在archive/test2文件夹中创建了另一个快照。

当然,它有一个不同的文件名,因为时间不同,但如果您解压缩它,您会注意到它确实创建了文件夹的压缩存档版本。

通过采取以下操作来处理解决方案:

  • 更改one.txt文件的内容
  • test文件夹中也添加一个文件
  • 删除文件

总结

在本章中,我们成功地为您的代码项目构建了一个功能强大且灵活的备份系统。您可以看到扩展或修改这些程序的行为是多么简单。你可以继续解决的潜在问题的范围是无限的。

与我们在上一节中使用的本地归档目标文件夹不同,想象一下装载一个网络存储设备并使用它。突然之间,您就有了这些重要文件的非现场(或至少非机器)备份。您可以轻松地将 Dropbox 文件夹设置为归档目标,这意味着您不仅可以自己访问快照,还可以将副本存储在云中,甚至可以与其他用户共享。

扩展Archiver接口以支持Restore操作(只需使用encoding/zip包解压文件)就可以让您构建工具,可以在归档文件中窥视并访问单个文件的更改,就像 Time Machine 允许您做的那样。索引这些文件可以让您在代码的整个历史记录中进行全面搜索,就像 GitHub 一样。

因为文件名是时间戳,所以您可以将停用的旧存档备份到不太活跃的存储介质中,或者将更改汇总到每日转储中。

显然,备份软件是存在的,经过了良好的测试,并在全世界范围内得到了广泛的使用。专注于解决尚未解决的问题可能是明智之举。但是,当编写小程序来完成任务只需要很少的努力时,它通常是值得做的,因为它给了您控制权。当你编写代码时,你可以毫不妥协地得到你想要的东西,而这取决于每个人做出的决定。

特别是在本章中,我们探讨了 easy Go 的标准库如何使其与文件系统交互:打开文件进行读取、创建新文件和创建目录。os包与io包中的强大类型混合在一起,并进一步与encoding/zip等功能混合在一起,为组合极其简单的 Go 接口以提供非常强大的结果提供了一个清晰的示例。