Scala 7Z 压缩库的设计与实现

一个简单压缩软件的设计与实现系列(二)

Posted by John Mactavish on January 10, 2021

在上篇文章中我们完成了技术选型,我们放弃了成熟的选用了 sevenzipjbind 作为压缩的核心。

sevenzipjbind 提供的 API 还残留着 C++ 风味,我们有必要自己再包装一层,把它 变成易用的 Scala API。所以我们干脆另起一个新项目,先来实现一个 SevenZ4S(Seven Zip for Scala) Library。

一开始,决定包装策略。先阅读项目 User Guide,了解到 sevenzipjbind 依赖于 7Z 的原生动态链接库工作, 即依赖于 net.sf.sevenzipjbinding.sevenzipjbinding-all-platformssevenzipjbind 在此 基础上包装了调用 native 方法的逻辑。所以我们的包装也应当单单基于 sevenzipjbind 库本身, 让我们的 Scala Library 在内部生成对应逻辑,调用 sevenzipjbind 的 API 完成工作。 sevenzipjbinding-all-platforms 甚至 sevenzipjbind 应该对于我们的库的使用者基本不可见。

接着,分析被包装库。鉴于我已经通过 Gradle 引入依赖来运行 demo, 可以在 Gradle Local Repo 中找到 sevenzipjbindJava 源码。 哪怕有文档, 直接读源码的工作量还是很大的。所以要灵活运用 IDEAdiagram 功能生成类的 UML 图,以快速从整体上认清库的设计层次、大致结构。 在一个符号(类或接口)上右击,还可选择“find usages”功能找到它的使用处、父类、子类和 实现类等,以理清单个类或接口的作用。例如,通过这些方法,我们很快就能发觉, 被包装库中大量存在钻石继承的 接口(interface),而且只有继承树最下方的子类有对应的实现类。不难想到,这是为了方便 与原生的 C++ API 交互而采取的手段,也是我们的包装类一定要改造的部分。我们还能发现, 有一些名字中带有 Callback 的接口在库中没有具体实现类,他需要用户用匿名类实现并 补充相关方法以供 native 方法回调;但是 Scala 一般不用匿名类干这种事情,而是转而 使用高阶函数这种优雅的途径,这也是我们需要改造的地方。

IOutItemAllFormats

通过对被包装库的细致分析,我们对自己要做的工作就有了清晰的认识。是时候总体设计一下 我们的库 SevenZ4S 了。sevenzipjbind 支持对压缩包的三种操作:压缩、部分更新与解压缩; 相应地,我们设计三个类来处理:ArchiveCreatorArchiveUpdaterArchiveExtractorsevenzipjbind 的解压缩模块可以自动识别压缩包格式(format),且支持格式多, 因而为所有格式提供通用的接口;那么我们的库就把 ArchiveExtractor 设计成具体(concrete)类。 但是,压缩和部分更新功能仅支持 5 种格式(7z、zip、gzip、tar 与 bzip2),且不同格式各有 特点;所以 ArchiveCreatorArchiveUpdater 设计成抽象的 trait。各个格式的压缩和部分更新 处理逻辑类似,但是泛型的 type parameter 不同;所以 trait 也要带类型参数。

如下所示,Scala 的 trait 有两个地方可以放类型参数,声明处与 type memeber; 一般把概念上与 trait 自身性质有关的类型参数放在声明处,而把其他的当作普通的 trait 内部成员(memeber), 以 protected 修饰,用作子类多态。

trait AbstractArchiveCreator {
  protected type TEntry <: CompressionEntry
  protected type TItem <: IOutItemBase
  // ...
}

不同格式的压缩包支持一些不同的 feature,我们为 feature 用 trait 建模。

trait SetEncryptHeader {
  protected val archive: IOutFeatureSetEncryptHeader

  def setHeaderEncryption(b: Boolean): Unit = {
    archive.setHeaderEncryption(b)
  }
}

trait SetLevel {
  protected val archive: IOutFeatureSetLevel

  def setLevel(compressionLevel: Int): Unit = {
    archive.setLevel(compressionLevel)
  }
}

trait SetMultithreading {
  protected val archive: IOutFeatureSetMultithreading

  def setThreadCount(threadCount: Int): Unit = {
    archive.setThreadCount(threadCount)
  }
}

因为这些 trait 没有什么代码层面的共性,所以没有必要过度设计只有标记作用的父接口。 它们本质上都是代理,只是转发 sevenzipjbind 里面的 archive 类型的对应方法。 AbstractArchiveCreator 的具体的子类根据需要设置 archive,并继承这些 feature 即可 自动获得对应的方法。

final class ArchiveCreator7Z() extends AbstractArchiveCreator
  with SetEncryptHeader
  with SetLevel
  with SetMultithreading {
  override protected type TEntry = CompressionEntry7Z
  override protected type TItem = IOutItem7z

  override protected val archive: IOutCreateArchive7z = ???
}

sevenzipjbind 原生的使用方法大概像这样:

import ...

public class CompressNonGenericGZip {
    /**
     * The callback provides information about archive items.
     */
    private final class MyCreateCallback 
            implements IOutCreateCallback<IOutItemGZip> {

        public void setOperationResult(boolean operationResultOk)
                throws SevenZipException {
            // Track each operation result here
        }

        public void setTotal(long total) throws SevenZipException {
            // Track operation progress here
        }

        public void setCompleted(long complete) throws SevenZipException {
            // Track operation progress here
        }

        public IOutItemGZip getItemInformation(int index,
                OutItemFactory<IOutItemGZip> outItemFactory) {
            IOutItemGZip item = outItemFactory.createOutItem();

            item.setDataSize((long) content.length);

            return item;
        }

        public ISequentialInStream getStream(int i) throws SevenZipException {
            return new ByteArrayStream(content, true);
        }
    }

    byte[] content;

    public static void main(String[] args) {
        if (args.length == 1) {
            new CompressNonGenericGZip().compress(args[0]);
            return;
        }
        System.out.println("Usage: java CompressNonGenericGZip <archive>");
    }

    private void compress(String filename) {
        RandomAccessFile raf = null;
        IOutCreateArchiveGZip outArchive = null;
        content = CompressArchiveStructure.create()[0].getContent();
        try {
            raf = new RandomAccessFile(filename, "rw");

            // Open out-archive object
            outArchive = SevenZip.openOutArchiveGZip();

            // Configure archive
            outArchive.setLevel(5);

            // Create archive
            outArchive.createArchive(new RandomAccessFileOutStream(raf),
                    1, new MyCreateCallback());
        } catch (Exception e) {
            System.err.println("Error occurs: " + e);
        } finally {
            // close resources
        }
    }
}

Callback 匿名类包含了若干方法,其中 setOperationResultsetTotalsetCompleted 都是具体操作的钩子(hook function),而 getItemInformation 提供有关压缩包内部条目(称作 Item)的属性信息(如路径、上次访问时间等),getStream 提供对应 Item 的数据流(data)。 我们可以用多个高阶函数的组合取代匿名类。

trait AbstractArchiveCreator{
  // ...
  private var onProcess: (Long, Long) => Unit = (_, _) => {}
  private var onEachEnd: Boolean => Unit = _ => {}

  def onProcess(progressTracker: (Long, Long) => Unit): Unit = {
    this.onProcess = progressTracker
  }

  def onEachEnd(f: Boolean => Unit): Unit = {
    this.onEachEnd = f
  }

  // ...
}

真正进行具体操作时再由 SevenZ4S 进行装配:

trait AbstractArchiveCreator{
  // ...

  def compress(): Unit = {
    // ...

    val output: IOutStream = ???
    val numEntry: Int = ???

    archive.createArchive(output, numEntry, new IOutCreateCallback[TItem] {
      private var total: Long = -1

      override def setTotal(l: Long): Unit = this.total = l

      override def setCompleted(l: Long): Unit = 
        if (this.total != -1) onProcess(l, this.total)

      override def setOperationResult(b: Boolean): Unit = onEachEnd(b)

      override def getItemInformation(
                                        i: Int,
                                        outItemFactory: OutItemFactory[TItem]
                                      ): TItem = {
        // ...
      }

      override def getStream(i: Int): ISequentialInStream = {
        // ...
      }
    })

    // ...
  }
}

现在我们的类可以配置 feature 与钩子函数,显然它们都是可选的,并且它们独立、不相互依赖, 配置无顺序要求。显然我们应用建造者模式(builder pattern)组织这些方法。 这样,我们的 API 用起来就会像这样:

new ArchiveCreator7Z()
      // ...
      .onEachEnd {
        ok =>
          if (ok)
            println("one success")
          else
            println("one failure")
      }.onProcess {
      (completed, total) =>
        println(s"$completed of $total")
      }.setLevel(5)
      .setHeaderEncryption(true)
      .setThreadCount(3)
      .compress()

为了做到这一点,ArchiveCreator7Z 的每个方法最后都必须返回自己。但是注意,它的方法 都是在 trait AbstractArchiveCreator 里实现的。trait 里的 this 的实际类型是 trait 自己 而非子类。这会破坏子类的链式调用,因为子类拥有 trait 所没有的方法。

// AbstractArchiveCreator.scala
trait AbstractArchiveCreator{
  // ...

  def onProcess(progressTracker: (Long, Long) => Unit): AbstractArchiveCreator = {
    this.onProcess = progressTracker
    this // 类型是 AbstractArchiveCreator
  }

  def onEachEnd(f: Boolean => Unit): AbstractArchiveCreator = {
    this.onEachEnd = f
    this
  }
}

// ArchiveCreator7Z.scala
final class ArchiveCreator7Z() extends AbstractArchiveCreator
  with SetEncryptHeader
  with SetLevel
  with SetMultithreading {
  // ...
}

// Main.scala
new ArchiveCreator7Z()
      // ...
      .onEachEnd {
        ok =>
          if (ok)
            println("one success")
          else
            println("one failure")
      }.onProcess {
      (completed, total) =>
        println(s"$completed of $total")
      } // ERROR, onProcess returns AbstractArchiveCreator, which doesn't have these methods
      .setLevel(5) 
      .setHeaderEncryption(true)
      .setThreadCount(3)
      .compress()

为此,子类需要把自己的类型提交给 trait,因而修改 trait 为:

trait AbstractArchiveCreator[E <: AbstractArchiveCreator[E]] {
  this: E =>

  protected type TEntry <: CompressionEntry
  protected type TItem <: IOutItemBase
  // ...

  def onProcess(progressTracker: (Long, Long) => Unit): E = {
    this.onProcess = progressTracker
    this
  }

  def onEachEnd(f: Boolean => Unit): E = {
    this.onEachEnd = f
    this
  }

  // ...
}

声明处的类型参数附带约束 E <: AbstractArchiveCreator[E],是在提醒 开发者传入的是子类类型。同理修改 feature 的有关 trait:

trait SetEncryptHeader[T <: AbstractArchiveCreator[T] with SetEncryptHeader[T]] {
  this: T =>

  protected val archive: IOutFeatureSetEncryptHeader

  def setHeaderEncryption(b: Boolean): T = {
    archive.setHeaderEncryption(b)
    this
  }
}

trait SetLevel[T <: AbstractArchiveCreator[T] with SetLevel[T]] {
  this: T =>

  protected val archive: IOutFeatureSetLevel

  def setLevel(compressionLevel: Int): T = {
    archive.setLevel(compressionLevel)
    this
  }
}

trait SetMultithreading[T <: AbstractArchiveCreator[T] with SetMultithreading[T]] {
  this: T =>

  protected val archive: IOutFeatureSetMultithreading

  def setThreadCount(threadCount: Int): T = {
    archive.setThreadCount(threadCount)
    this
  }
}

子类继承时这样写:

final class ArchiveCreator7Z() extends AbstractArchiveCreator[ArchiveCreator7Z]
  with SetEncryptHeader[ArchiveCreator7Z]
  with SetLevel[ArchiveCreator7Z]
  with SetMultithreading[ArchiveCreator7Z] {
  override protected type TEntry = CompressionEntry7Z
  override protected type TItem = IOutItem7z

  override protected val archive: IOutCreateArchive7z = ???

然后到了最核心的部分:对于压缩包内部条目的抽象。站在 sevenzipjbind 的抽象层上看, 压缩包(archive)由通过有序列表(seq)组织的条目(item)构成,item 包括自身属性(property) 与数据(data)。sevenzipjbind 通过 getItemInformationgetStream 两个方法 分开处理 property 与 data,这让我感觉很割裂。所以我把内部条目重新建模为 entry, 用户使用我的 API 时需要提供同时具有 property 与 data 的 entry,SevenZ4S 会在 内部负责拆开 entry 给 getItemInformationgetStream。自然,我们还需要 实现 item 与 entry 间的转化器。即:

trait AbstractAdapter[T <: CompressionEntry, R <: IOutItemBase] {
  protected def adaptEntryToItem(entry: T, template: R): R

  protected def adaptItemToEntry(item: R): T
}

为了避免让转化器了解 sevenzipjbind 的 item 是如何创建的,entry 至 item 的转化器 接受一个额外的 template 参数,调用者负责创建一个默认的 item 传入,这样转化器仅需 知道 item 的 setters 即可工作,简化了设计。

为了避免用户在自己构造 entry 时无从下手,我们还可以提供一些常用的辅助函数,比如, 自本地文件系统创建 entry 的函数。

最后,entry 将这样工作:

// AbstractArchiveCreator.scala
trait AbstractArchiveCreator[E <: AbstractArchiveCreator[E]] {
  this: E =>

  protected type TEntry <: CompressionEntry
  protected type TItem <: IOutItemBase
  // ...

  protected def adaptItemToEntry(item: TItem): TEntry

  protected def adaptEntryToItem(entry: TEntry, template: TItem): TItem

  def compress(entries: IndexedSeq[TEntry]): Unit = {
    // ...
    val output: IOutStream = ???
    val numEntry: Int = ???

    archive.createArchive(output, numEntry, new IOutCreateCallback[TItem] {
      // ...

      override def getItemInformation(
                                       i: Int,
                                       outItemFactory: OutItemFactory[TItem]
                                     ): TItem = {
        val templateItem = outItemFactory.createOutItem()
        adaptEntryToItem(entries(i), templateItem)
      }

      override def getStream(i: Int): ISequentialInStream = {
        entries(i).source
      }
    })

    // ...
  }
}

// ArchiveCreator7Z.scala
final class ArchiveCreator7Z() extends AbstractArchiveCreator[ArchiveCreator7Z]
  with Adapter7Z
  with SetEncryptHeader[ArchiveCreator7Z]
  with SetLevel[ArchiveCreator7Z]
  with SetMultithreading[ArchiveCreator7Z] {
  // ...
}

// Adapter7Z.scala
trait Adapter7Z extends AbstractAdapter[CompressionEntry7Z, IOutItem7z] {
  protected def adaptEntryToItem(entry: CompressionEntry7Z, template: IOutItem7z): IOutItem7z = {
    template.setPropertyPath(entry.path)
    template.setPropertyLastModificationTime(entry.lastModificationTime)
    // ...
    template
  }

  protected def adaptItemToEntry(item: IOutItem7z): CompressionEntry7Z = {
    CompressionEntry7Z(
      source = null, // source is not required for library generated entries
      path = item.getPropertyPath,
      lastModificationTime = item.getPropertyLastModificationTime,
      // ...
    )
  }
}

// Main.scala
val path = new File(getClass.getResource("/root").getFile).toPath
val entries = SevenZ4S.get7ZEntriesFrom(path)
new ArchiveCreator7Z()
  // ...
  .setLevel(5)
  .setHeaderEncryption(true)
  .setThreadCount(3)
  .compress(entries)

这里主要是以 ArchiveCreator7Z 为例说明设计,其他的类同理。 最后,SevenZ4S 的代码可以在这里找到,在这个系列的下一篇文章中,我们将把 SevenZ4S 发布到 Maven Central 中去。


推荐阅读:

Scala Mixins: The Right Way

如果你喜欢我的文章,请我吃根冰棒吧 (o゜▽゜)o ☆

contribution

最后附上 GitHub:https://github.com/gonearewe