使用 Scala 生态系统构建静态文件服务:手把手的教程

更新于 2024-10-06 01:13

前言

本文(指本系列文章,下同)将使用 Scala 3 及其生态系统,构建一个尽可能功能完整的静态文件服务器,并打包成实用的命令行工具。我们将得到一个可以运 行在 Linux/MacOS 上的实用命令行工具,可用于取代 python -m http.server,使用并体验以下“秘技”:

  • 构建工具 mill 及其插件 versionFile, mill-universal-packager
  • 代码格式化工具 scalafmt
  • 函数式抽象库 cats 及其 IO 库 cats-effect
  • HTTP service 库 http4s
  • HTML/CSS 构建/模板库 scalatags
  • 单元测试库 scalatest
  • 支持 cats-effect 的命令行解析库 decline
  • GraalVM Native Image
  • Tagless Final 风格的副作用处理
  • 调用 Java NIO2 API 读取文件系统

本文旨在展示 Scala 3 生态系统的先进,简洁,精巧和高度可用,通过实用的应用程序开发揭示当前 Scala 3 开发的一般流程,但不会讨论以下内容:

  • Scala 语法
  • 函数式编程,不可变性
  • 副作用管理
  • Monad, Functor, Applicative
  • Scala 标准库
  • 涉及到的 Scala 库的具体用法

2 在这个前提下,当你无法理解本文某些 Scala 语法和库的调用方式时,应当可以略过而不影响学习和吸收全文的主要内容。

因此,本文阅读指南:

  • 如果你是一名已经掌握了 Scala 3 基本语法和上述库基本用法的初学者,希望本文能为你进入实际的 Scala 项目开发提供有益的帮助和参考。
  • 如果你是一名还没有开始学习 Scala 的“路人”,那么本文将带你大致领略一种比较特别的 Scala 开发工作流,帮你下定决心来学习它或者拒绝它。

在普遍的固有的印象中, Scala 是一门具有可伸缩性,为处理大规模数据和服务而生的后端语言。但本人既然自号“细数据工程师”,一个自然而然的做法就是通 过使用 Scala 3 构建命令行程序这种“细数据”项目来展示 Scala 作为一门通用语言的强大和优美之处。这是本文不同于其他 Scala 教程文章的一个显著特 点,请在阅读的时候多加留意,多多点赞。

我们在本地文件共享、临时快速测试之类的场景下,有时候会有随手起一个 HTTP 静态文件服务器的需求。而Python 的 http.server 模块给我们提供了非常方便的支持,但其缺点也很明显,比如:

  • 安全性 :它不支持 HTTPSHTTP/2 ,这意味着所有传输的数据都不是加密的,容易受到中间人攻击。

  • 功能缺乏 :缺乏进阶的 HTTP 服务器功能,如身份验证、缓存、日志记录、虚拟主机等,播放音频视频的时候,不支持任意快进,等等。

  • 错误处理 :错误处理非常基础,不适合需要定制错误页面或执行复杂错误处理的场景。

  • 配置限制 :几乎没有可配置的选项,例如不能设置服务的头信息、不能定义MIME类型等。

本文不会逐个实现或修复上述缺陷,但会尽可能提供其可行性。

准备工作

为了开发我们的项目,需要安装 JDK 17+mill. 其中, mill 作为替代 sbt 的构建工具,可以参考其 官方文档 进行安装。

请注意:

  • 虽然我们要写一个 Scala 项目,但是我们并不需要安装 Scala .
  • 在结束我们的开发以后,运行我们生产出来的实用程序,并不需要 JDKmill

项目脚手架

我们首先在 ~/projects/ 下新建一个新的 Scala 项目 static-snap .

1@nixos ~ $ cd projects/
1@nixos ~/projects $ mkdir -pv static-snap
mkdir: created directory 'static-snap'
1@nixos ~/projects $ cd static-snap
1@nixos ~/projects/static-snap $ touch build.sc .scalafmt.conf
1@nixos ~/projects/static-snap $ 

其中, .scalafmt.conf 是代码格式化配置文件,暂时不去细究其中的内容。

.scalafmt.conf:

version = "3.8.0"
maxColumn = 144
align.preset = most
continuationIndent.defnSite = 2
runner.dialect = scala3
rewrite.rules = [Imports]
rewrite.imports.sort = scalastyle
rewrite.imports.groups = [
  ["java[x]*\\..*"],
  ["scala\\..*"],
  ["^(?!simple.).*"]
]
newlines.topLevelStatementBlankLines = [ { blanks { before = 1 } } ]

build.sc 是最关键的项目构建文件,它指定了项目使用的 Scala 版本(为当前最新的 3.4.0 ),lib 依赖及其版本,自动格式化配置,打包配置的关键信息,Scala 编译器的关键选项,应用主程序的类等等。

build.sc :

import $ivy.`io.github.hoangmaihuy::mill-universal-packager::0.1.2`
import io.github.hoangmaihuy.mill.packager.archetypes.JavaAppPackagingModule
import mill._
import mill.scalalib.scalafmt.ScalafmtModule
import scalalib._

object Versions {
  val http4sV         = "0.23.26"
  val declineV        = "2.4.1"
  val scalatagsV      = "0.12.0"
  val scalaloggingV   = "3.9.5"
  val logbackClassicV = "1.4.14"
}

object app extends ScalaModule with ScalafmtModule with JavaAppPackagingModule {

  import Versions._

  def scalaVersion = "3.4.0"

  // Define the main class
  def mainClass = Some("simple.Main")

  // Define the top-level directory name for the archived package
  def topLevelDirectory = Some(packageName())

  // Define the version of the package
  def packageVersion = "0.0.1-SNAPSHOT"

  def scalacOptions = Seq(
    "-deprecation",
    "-encoding",
    "UTF-8",
    "-feature",
    "-unchecked",
    "-Wunused:all"
  )

  def ivyDeps = Agg(
    ivy"org.http4s::http4s-ember-server:$http4sV",
    ivy"org.http4s::http4s-dsl:$http4sV",
    ivy"com.lihaoyi::scalatags:$scalatagsV",
    ivy"com.monovore::decline:$declineV",
    ivy"com.monovore::decline-effect:$declineV",
    ivy"com.typesafe.scala-logging::scala-logging:$scalaloggingV",
    ivy"ch.qos.logback:logback-classic:$logbackClassicV"
  )
}

以上使用到的库解释如下:

  • http4s 作为 HTTP Server,本节暂时不会用到
  • scalatags 作为模板引擎,本节暂时不会用到
  • deline 提供命令行参数支持
  • scala-logginglogback-classic 用于日志打印

现在可以用 IDEA 或者 VS Code 打开我们的项目,写一个基本的 Hello World 程序了。

app/src/simple/Main.scala:

package simple

import cats.effect.{ExitCode, IO}
import cats.implicits.*
import com.monovore.decline.Opts
import com.monovore.decline.effect.CommandIOApp
import com.typesafe.scalalogging.StrictLogging

object Main
    extends CommandIOApp(name = "static-snap", header = "A Simple Static Server Powered By Scala", version = "0.0.1-SNAPSHOT")
    with StrictLogging {

  val rootOpts: Opts[String] = Opts.argument[String]("root directory")
  val hostOpts: Opts[String] = Opts.option[String]("host", short = "h", help = "host").withDefault("0.0.0.0")
  val portOpts: Opts[Int]    = Opts.option[Int]("port", short = "p", help = "port").withDefault(8888)

  override def main: Opts[IO[ExitCode]] = {
    (hostOpts, portOpts, rootOpts).mapN { (host, port, root) =>
      for {
        _ <- IO.delay(logger.info(s"hello world!"))
        _ <- IO.delay(logger.info("got parameters: host = {}, port = {}, pathPrefix = {}", host, port, root))
      } yield ExitCode.Success
    }
  }
}

这个简单的 Hello World 程序接受三个参数 root, --host (-h)--port (-p), 然后打印 "hello world!" 和接收到的参数。其中, hostport 参数都有默认值。

我们可以直接运行这个程序:

1@nixos ~/projects/static-snap $ mill app.run /
[36/49] app.compile
[info] compiling 1 Scala source to /home/weiwen/projects/static-snap/out/app/compile.dest/classes ...
[info] done compiling
[49/49] app.run
20:36:42.590 [io-compute-1] INFO simple.Main$ -- hello world!
20:36:42.591 [io-compute-1] INFO simple.Main$ -- got parameters: host = 0.0.0.0, port = 8888, root = /

可以加上所有的参数,用全称或者简写:

1@nixos ~/projects/static-snap $ mill app.run /tmp --host 127.0.0.1 -p 1234
[49/49] app.run
20:37:39.414 [io-compute-29] INFO simple.Main$ -- hello world!
20:37:39.416 [io-compute-29] INFO simple.Main$ -- got parameters: host = 127.0.0.1, port = 1234, root = /tmp

还可以加上 --help 参数显示命令行帮助文档:

1@nixos ~/projects/static-snap $ mill app.run --help
[49/49] app.run
Usage: static-snap [--host <string>] [--port <integer>] <root directory>

A Simple Static Server Powered By Scala

Options and flags:
    --help
        Display this help text.
    --version, -v
        Print the version number and exit.
    --host <string>, -h <string>
        host
    --port <integer>, -p <integer>
        port

所有的这些命令行功能,都是由 Scala 库 decline 提供的,这里只是用到了最基础的功能,可以去它的网站 https://monovore.com/decline 了解更多复杂的用法。

由于我们已经在 build.sc 里配置了 mill 的插件 mill-universal-packager 为应用程序打包,现在可以执行 mill app.universalStage 打包出一个可以执行的应用程序:

1@nixos ~/projects/static-snap $ mill app.universalStage
[62/62] app.universalStage

可以在 ./out/app/universalStage.dest/ 目录下找到我们打包出来的产物,并且执行它:

1@nixos ~/projects/static-snap $ ./out/app/universalStage.dest/bin/app /
20:50:57.437 [io-compute-6] INFO simple.Main$ -- hello world!
20:50:57.439 [io-compute-6] INFO simple.Main$ -- got parameters: host = 0.0.0.0, port = 8888, root = /

还可以使用其他的命令来生成压缩包:

1@nixos ~/projects/static-snap $ mill app.universalStagePackageTarBzip2
[68/68] app.universalStagePackageTarBzip2
Generated package: /home/weiwen/projects/static-snap/out/app/universalStagePackageTarBzip2.dest/app-0.0.1-SNAPSHOT.tar.bz2

1@nixos ~/projects/static-snap $ mill app.universalStagePackageTarZstd
[69/69] app.universalStagePackageTarZstd
Generated package: /home/weiwen/projects/static-snap/out/app/universalStagePackageTarZstd.dest/app-0.0.1-SNAPSHOT.tar.zstd

附注: 上面用到的三个 mill-universal-packager 功能是我本人写的,参见项目地址 https://github.com/hoangmaihuy/mill-universal-packager .

下面是其它一些常用的 mill 命令(以 app 子项目为例):

  • mill app.compile
  • mill clean, 顾名思义,会删除整个 ./out 目录。
  • mill app.reformat, 读取 .scalafmt.conf 中的配置并重新格式化 app 子目录的所有 .scala 文件. mill __.reformat 则重新格式化整个项目。
  • mill app.runMain simple.Main, 指定 main class 运行。
  • mill -w app.run, 运行并且保持热更新,在调试服务器端程序的时候特别有用。
  • mill -i app.console, 进入一个可供调试的 REPL 控制台。
  • mill resolve app._, mill resolve __, 显示所有可以执行的 mill 子命令。
01-代码行数统计

统计下代码行数,会发现仅仅用了 74 行 Scala 代码(其中还包含空行和注释)。

小结:以上完整代码可以在这里找到:https://github.com/weiwen99/static-snap/tree/01-scaffold

第一个 HTTP Service

本节的主要任务是基于 http4s 实现一个最简单的 HTTP Service 并测试。

参数的解析

不过在开始之前,我们需要对之前的工作做一些改进。在上一节中,我们通过命令行接收 hostport 参数的时候,并没有对这个两个参数作必要的校验。我们可以很方便地使用 decline 这个现成的库来达到这一点。

app/src/simple/CmdOpts.scala:

package simple

import java.nio.file.{Path, Paths}

import scala.util.*

import cats.data.Validated
import com.comcast.ip4s.{Host, Port}
import com.monovore.decline.Argument

object CmdOpts {

  given Argument[Port] = new Argument[Port] {

    def read(in: String) = {
      in.toIntOption.flatMap(Port.fromInt) match
        case Some(port) => Validated.valid(port)
        case None       => Validated.invalidNel(s"Invalid port: $in")
    }
    def defaultMetavar   = "8888"
  }

  given Argument[Host] = new Argument[Host] {

    def read(in: String) = Host.fromString(in) match
      case Some(host) => Validated.valid(host)
      case None       => Validated.invalidNel(s"Invalid host: $in")

    def defaultMetavar: String = "127.0.0.1"
  }

  given Argument[Path] = new Argument[Path] {

    // 转化为 realpath 以检验路径是否存在
    def read(in: String) = Try(Paths.get(in).toRealPath()) match
      case Success(path)      => Validated.valid(path)
      case Failure(exception) => Validated.invalidNel(s"Invalid path: $in, exception: $exception")

    def defaultMetavar: String = "."
  }
}

可以看到,我们使用了 cats 提供的 Validated 来帮助校验参数。 Validated 是个非常有用的数据结构,可以去 https://typelevel.org/cats/datatypes/validated.html 了解更详细的信息。

对于输入的 root path 目录,我们转化为 real path 以检验路径是否存在。这样就避免了对一个不存在或者不合法的目录提供服务。

现在我们把 CmdOpts 应用到 Main 里:

app/src/simple/Main.scala:

++ import CmdOpts.given

-- val rootOpts: Opts[String] = Opts.argument[String]("root directory")
-- val hostOpts: Opts[String]   = Opts.option[String]("host", short = "h", help = "host").withDefault("0.0.0.0")
-- val portOpts: Opts[Int]   = Opts.option[Int]("port", short = "p", help = "port").withDefault(8888)
++ val rootOpts: Opts[Path] = Opts.argument[Path]("root directory")
++ val hostOpts: Opts[Host]   = Opts.option[Host]("host", short = "h", help = "host").withDefault(Host.fromString("0.0.0.0").get)
++ val portOpts: Opts[Port]   = Opts.option[Port]("port", short = "p", help = "port").withDefault(Port.fromInt(8888).get)

测试一下:

1@nixos ~/projects/static-snap (main*?) $ mill app.run . -h 1234
[36/49] app.compile
[info] compiling 1 Scala source to /home/weiwen/projects/static-snap/out/app/compile.dest/classes ...
[info] done compiling
[49/49] app.run
14:58:19.487 [io-compute-14] INFO simple.Main$ -- hello world!
14:58:19.488 [io-compute-14] INFO simple.Main$ -- got parameters: host = 1234, port = 8888, root = /home/weiwen/projects/static-snap

对于错误的参数:

1@nixos ~/projects/static-snap (main*?) $ mill app.run /tmp -p 123456
[49/49] app.run
Invalid port: 123456

Usage: static-snap [--host <127.0.0.1>] [--port <8888>] <root directory>

A Simple Static Server Powered By Scala

Options and flags:
    --help
        Display this help text.
    --version, -v
        Print the version number and exit.
    --host <127.0.0.1>, -h <127.0.0.1>
        host
    --port <8888>, -p <8888>
        port
1 targets failed
app.run subprocess failed
1@nixos ~/projects/static-snap (main*?) $ mill app.run /xyz-muhahahha
[49/49] app.run
Invalid path: /xyz-muhahahha, exception: java.nio.file.NoSuchFileException: /xyz-muhahahha

Usage: static-snap [--host <127.0.0.1>] [--port <8888>] <root directory>

A Simple Static Server Powered By Scala

Options and flags:
    --help
        Display this help text.
    --version, -v
        Print the version number and exit.
    --host <127.0.0.1>, -h <127.0.0.1>
        host
    --port <8888>, -p <8888>
        port
1 targets failed
app.run subprocess failed

/_status API 的实现

现在可以来实现一个简单的 API 了。

app/src/simple/SnapRouter.scala:

package simple

import cats.effect.kernel.Async
import fs2.io.file.Files
import org.http4s.HttpRoutes
import org.http4s.dsl.Http4sDsl

class SnapRouter[F[_]: Async: Files]() extends Http4sDsl[F] {

  private val statusR = HttpRoutes.of[F] { case request @ GET -> Root / "_status" =>
    Ok("simple static server is running.\n")
  }

  val routes: HttpRoutes[F] = statusR

}

这个 API 很简单,就是 GET /_status, 返回一个简单的字符串。

然后是一个简单的 HTTP Service。

app/src/simple/SnapServer.scala:

package simple

import cats.effect._
import cats.effect.kernel.Async
import com.comcast.ip4s.{Host, Port}
import fs2.io.file.Files
import fs2.io.net.Network
import org.http4s.HttpRoutes
import org.http4s.ember.server.EmberServerBuilder
import org.http4s.server.{Router, Server}

object SnapServer {

  def build[F[_]: Async: Files: Network](routes: HttpRoutes[F], host: Host, port: Port): Resource[F, Server] =
    EmberServerBuilder
      .default[F]
      .withHttpApp(Router("/" -> routes).orNotFound)
      .withHost(host)
      .withPort(port)
      .withHttp2
      .build
}

最后,在 Main 里启动这个 HTTP Service, 并把之前的 hello world 日志打印删去。

app/src/simple/Main.scala:

--      for {
--        _ <- IO.delay(logger.info(s"hello world!"))
--        _ <- IO.delay(logger.info("got parameters: host = {}, port = {}, root = {}", host, port, root))
--      } yield ExitCode.Success
++      val routes    = SnapRouter[IO]().routes
++      val runServer: IO[Unit] = SnapServer.build(routes, host, port).use(_ => IO.never)
++      for {
++        _ <- IO.delay(logger.info("server parameters: root = {}, host = {}, port = {}", root ,host, port))
++        _ <- runServer
++      } yield ExitCode.Success

现在我们终于可以启动服务了:

1@nixos ~/projects/static-snap (main*?) $ mill app.run .
[36/49] app.compile
[info] compiling 1 Scala source to /home/weiwen/projects/static-snap/out/app/compile.dest/classes ...
[info] done compiling
[49/49] app.run
15:36:37.449 [io-compute-3] INFO simple.Main$ -- server parameters: root = /home/weiwen/projects/static-snap, host = 0.0.0.0, port = 8888
15:36:37.565 [io-compute-2] INFO org.http4s.ember.server.EmberServerBuilderCompanionPlatform -- Ember-Server service bound to address: [::]:8888

保持这个服务的运行状态,我们可以用浏览器打开 http://127.0.0.1:8888/_status 或者用 curl 之类的命令行工具来测试我们的服务。

1@nixos ~ $ curl http://127.0.0.1:8888/_status
simple static server is running.

还可以用 wrk 之类的工具来测试这个服务的基础性能:

1@nixos ~ $ wrk --latency -t 8 -c 200 -d 60 http://127.0.0.1:8888/_status
Running 1m test @ http://127.0.0.1:8888/_status
  8 threads and 200 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     2.16ms    1.24ms 210.49ms   94.90%
    Req/Sec    11.64k   464.19    15.10k    79.02%
  Latency Distribution
     50%    2.05ms
     75%    2.15ms
     90%    2.27ms
     99%    4.25ms
  5557970 requests in 1.00m, 0.90GB read
Requests/sec:  92588.59
Transfer/sec:     15.36MB

另外,我们的服务是天然支持 IPv6 的:

1@nixos ~ $ curl 'http://[::1]:8888/_status'
simple static server is running.

甚至支持 HTTP/2:

1@nixos ~ $ curl -vvvv --http2 'http://[::1]:8888/_status'
*   Trying [::1]:8888...
* Connected to ::1 (::1) port 8888
> GET /_status HTTP/1.1
> Host: [::1]:8888
> User-Agent: curl/8.6.0
> Accept: */*
> Connection: Upgrade, HTTP2-Settings
> Upgrade: h2c
> HTTP2-Settings: AAMAAABkAAQAoAAAAAIAAAAA
>
< HTTP/1.1 101 Switching Protocols
< Date: Thu, 28 Mar 2024 07:52:43 GMT
< connection: Upgrade
< upgrade: h2c
* Received 101, Switching to HTTP/2
< HTTP/2 200
< content-type: text/plain; charset=UTF-8
< content-length: 33
<
simple static server is running.
* Connection #0 to host ::1 left intact

单元测试

首先引入 scalatest 依赖并新增子项目 app/test

build.sc:

  object Versions {
  ++  val scalatestV      = "3.2.18"
  }

  object app extends ScalaModule with ScalafmtModule with JavaAppPackagingModule {
++   object test extends ScalaTests with TestModule.ScalaTest {
++
++    def ivyDeps = Agg(
++      ivy"org.scalatest::scalatest:$scalatestV"
++    )
++  }
  }

新建目录 app/test/src/, 并把单元测试代码放在这个目录下。

app/test/src/simple/SnapRouterSpec.scala:

package simple

import cats.effect.*
import cats.effect.unsafe.IORuntime
import org.http4s.*
import org.http4s.dsl.io.*
import org.http4s.implicits.uri
import org.scalatest.matchers.should
import org.scalatest.wordspec.AnyWordSpec

class SnapRouterSpec extends AnyWordSpec with should.Matchers {

  implicit val runtime: IORuntime = cats.effect.unsafe.IORuntime.global

  "API `/_status`" should {
    "work" in {
      val router = SnapRouter[IO]()
      val actual = router.routes.orNotFound.run(Request[IO](GET, uri"/_status")).unsafeRunSync()
      actual.status shouldBe Ok
      actual.as[String].unsafeRunSync() shouldBe "simple static server is running.\n"
    }
  }
}  

现在我们可以运行单元测试了:

1@nixos ~/projects/static-snap (main*?) $ mill app.test
[85/85] app.test.test
SnapRouterSpec:
API `/_status`
- should work
Run completed in 1 second, 58 milliseconds.
Total number of tests run: 1
Suites: completed 1, aborted 0
Tests: succeeded 1, failed 0, canceled 0, ignored 0, pending 0
All tests passed.

至此,我们整个项目结构和代码行数统计如下:

1@nixos ~/projects/static-snap (tags/02-first-http-service) $ tree .
.
├── app
│   ├── src
│   │   └── simple
│   │       ├── CmdOpts.scala
│   │       ├── Main.scala
│   │       ├── SnapRouter.scala
│   │       └── SnapServer.scala
│   └── test
│       └── src
│           └── simple
│               └── SnapRouterSpec.scala
└── build.sc

7 directories, 6 files
1@nixos ~/projects/static-snap (tags/02-first-http-service) $ tokei .
===============================================================================
 Language            Files        Lines         Code     Comments       Blanks
===============================================================================
 Scala                   6          189          145            4           40
===============================================================================
 Total                   6          189          145            4           40
===============================================================================
1@nixos ~/projects/static-snap (tags/02-first-http-service) $
02-项目结构和代码行数统计

由此可见,使用 mill 的 Scala 项目,跟使用 sbt 的类 maven 结构是有所区别的,一般会更简洁明了一些。

小结:本节的阶段性源代码可以在 https://github.com/weiwen99/static-snap/tree/02-first-http-service 找到。

单文件服务器

我们大致分两步来实现核心功能:

  1. 单文件服务,返回指定目录下的单个文件。
  2. 目录浏览功能,返回一个 HTML 页面,并提供上一级和子目录浏览功能。

本节先实现第一步。在 SnapRouter 中增加一个 fileR, 提供单文件服务。 Main 和 Unit Test 中的引用需要做的变更非常明显而且简单,略过不提。

app/src/simple/SnapRouter.scala:

package simple

import java.nio.charset.StandardCharsets
import java.nio.file.{Path => NioPath, Paths}

import cats.effect.kernel.Async
import cats.implicits.*
import com.typesafe.scalalogging.StrictLogging
import fs2.io.file.{Files, Path => Fs2Path}
import org.http4s.{HttpRoutes, StaticFile}
import org.http4s.dsl.Http4sDsl
import org.http4s.headers.`Accept-Ranges`

/** @param root
  *   提供服务的文件系统根目录
  * @param metaPrefix
  *   元信息 API 的前缀. 比如, 如果 metaPrefix 为 "__", 则元信息 API 为 `/__/status`
  */
class SnapRouter[F[_]: Async: Files](root: NioPath, metaPrefix: String) extends Http4sDsl[F] with StrictLogging {

  require("metaPrefix".nonEmpty, "metaPrefix should not be empty")

  private val META_PREFIX: String = metaPrefix

  private val statusR = HttpRoutes.of[F] { case request @ GET -> Root / META_PREFIX / "status" =>
    Ok("simple static server is running.\n")
  }

  private val fileR = HttpRoutes.of[F] { case request @ GET -> path =>
    // 由于 path 被 URL 编码,所以需要 URL 解码
    val decoded          = java.net.URLDecoder.decode(path.toString, StandardCharsets.UTF_8)
    // 将解码后的路径拼接到根路径下
    val nioPath: NioPath = Paths.get(root.toAbsolutePath.toString, decoded)
    val fs2Path: Fs2Path = Fs2Path.fromNioPath(nioPath)
    StaticFile
      .fromPath(fs2Path, request.some)
      // 支持 Range 请求, 否则播放音视频无法任意快进
      .map(_.putHeaders(`Accept-Ranges`.bytes))
      // 如果文件不存在,返回 404 Not Found
      .getOrElseF({
        logger.error("resource {} not found", nioPath)
        NotFound()
      })
  }

  val routes: HttpRoutes[F] = statusR <+> fileR

}

工作机制:在我们指定的根目录 rootPath 下,如果有路径 x/y/z.json, 那么 HTTP 请求为 GET /x/y/z.json 时,返回文件 z.json 的内容。

因为路径 /x/y/z.json 可能会被 URL 编码,比如 /x/y/[z].json 会被编码成 /x/y/%5bz%5e.json, 我们需要对 URL 路径作解编码处理。

为了支持多媒体文件播放时的快进快退,我们需要在 HTTP Response 加上 Accept-Ranges: bytes 这个 HTTP 头。

如果文件不存在,返回 404 Not Found.

注意,由于我们通过 catsSemigroupK type class 运算符 <+>statusRfileR 复合在一起,如果根目录 root 下恰好有与 META_PREFIX 这个参数同名的文件或者目录,则该文件不可见。例如,如果恰好有个名为 __/status 的文件在根目录下,那么这个文件不能被我们的服务捕获,因为 /__/status 这个 API 会被 statusR 优先匹配。这也是我们特别令 META_PREFIX 可配置的原因。如果不需要 /META_PREFIX/status 这个 API, 也可以直 接删去它。

单元测试

app/test/src/simple/SnapRouterSpec.scala:

package simple

import java.nio.file.Paths

import scala.util.Random

import cats.effect.*
import cats.effect.unsafe.IORuntime
import cats.implicits.*
import com.typesafe.scalalogging.StrictLogging
import org.http4s.*
import org.http4s.dsl.io.*
import org.http4s.implicits.*
import org.scalatest.BeforeAndAfterAll
import org.scalatest.matchers.should
import org.scalatest.wordspec.AnyWordSpec
import org.typelevel.ci.CIString

class SnapRouterSpec extends AnyWordSpec with should.Matchers with BeforeAndAfterAll with StrictLogging {

  implicit val runtime: IORuntime = cats.effect.unsafe.IORuntime.global

  private val dir   = "__static_snap_" + Random.alphanumeric.take(10).mkString
  private val dir1  = s"/tmp/$dir"
  private val dir2  = s"$dir1/subdir"
  private val text1 = "text 1"
  private val file1 = s"$dir1/1.txt"
  private val text2 = """{"key":"value"}"""
  private val file2 = s"$dir2/2.json"

  override def beforeAll(): Unit = {
    os.write(os.Path(file1), text1, createFolders = true)
    os.write(os.Path(file2), text2, createFolders = true)
    super.beforeAll()
  }

  override def afterAll(): Unit = {
    os.remove.all(os.Path(dir1))
    super.afterAll()
  }

  "Meta API" should {
    "work" in {
      val router = SnapRouter[IO](Paths.get("/tmp"), "_")
      val actual = router.routes.orNotFound.run(Request[IO](GET, uri"/_/status")).unsafeRunSync()
      actual.status shouldBe Ok
      actual.as[String].unsafeRunSync() shouldBe "simple static server is running.\n"
    }
  }

  "Single File API" should {
    val router = SnapRouter[IO](Paths.get(dir1), "_")
    "serve top level files" in {
      val actual = router.routes.orNotFound.run(Request[IO](GET, uri"/1.txt")).unsafeRunSync()
      actual.status shouldBe Ok
      actual.as[String].unsafeRunSync() shouldBe text1
    }
    "serve nested dir files" in {
      val actual = router.routes.orNotFound.run(Request[IO](GET, uri"/subdir/2.json")).unsafeRunSync()
      actual.status shouldBe Ok
      actual.headers.get(CIString("content-type")).map(_.head.value) shouldBe "application/json".some
      actual.as[String].unsafeRunSync() shouldBe text2
    }
  }

}

测试的总体思路是先生成一个随机的目录,然后在目录里新建两个不同层级的文本文件,并分别测试,最后再删掉整个测试目录。

运行测试

1@nixos ~/projects/static-snap (main*) $ mill app.run .
[49/49] app.run
21:42:32.152 [io-compute-28] INFO simple.Main$ -- server parameters: root = /home/weiwen/projects/static-snap, host = 0.0.0.0, port = 8888
21:42:32.235 [io-compute-28] INFO org.http4s.ember.server.EmberServerBuilderCompanionPlatform -- Ember-Server service bound to address: [::]:8888
1@nixos ~ $ curlie 'http://[::1]:8888/.bloop/bloop.settings.json'
HTTP/1.1 200 OK
Date: Thu, 28 Mar 2024 13:43:36 GMT
Connection: keep-alive
Last-Modified: Thu, 28 Mar 2024 13:13:52 GMT
Content-Length: 431
Content-Type: application/json
ETag: "18e8532099b-1af"
Accept-Ranges: bytes

{
    "javaSemanticDBVersion": "0.9.9",
    "semanticDBVersion": "4.9.1",
    "supportedScalaVersions": [
        "2.13.13",
        "2.12.19",
        "2.12.18",
        "2.12.17",
        "2.12.16",
        "2.13.10",
        "2.13.11",
        "2.13.12",
        "2.11.12",
        "2.12.12",
        "2.12.13",
        "2.12.14",
        "2.12.15",
        "2.13.6",
        "2.13.7",
        "2.13.8",
        "2.13.9"
    ]
}  

至此,我们整个项目结构和代码行数统计如下:

03-项目结构和代码行数统计

小结: 本节所有代码都可以在 https://github.com/weiwen99/static-snap/tree/03-add-single-file-service 里找到。

目录浏览的基础支持

功能的规划

我们设计以下的目录浏览功能:

  1. 列出指定目录下的所有文件和子目录
  2. 在功能 1 的页面上,可以打开任意文件或者进入下一级子目录
  3. 在功能 1 的页面上,可以进入上一级目录,直到根目录
  4. 支持根据文件名或者其他文件属性排序
  5. 支持在页面上进行文件搜索,这是一个 Python 的 http.server 或者 Nginx 默认没有的功能

本节我们先用 ScalaTags 来实现基础的目录浏览功能 1, 2, 3.

根据官网的自述, ScalaTags 是一款小型、快速的 ScalaXML/HTML/CSS 构建库。其详细文档可以去官网 https://com-lihaoyi.github.io/scalatags/ 阅读。本 项目使用 ScalaTags 的原因是因为它小巧,易用,功能强大而又依赖极少。我们马上开始请出 ScalaTags, 开始它的表演。

不过,开始之前,我们需要先理一下实现思路,免得在代码中迷失方向。总体思路应当清晰明了:

  1. API 从 URL 里接收到一个相对路径 path
  2. 判断 path 是否为一个文件(含符号链接)。如果是文件,那么输出文件内容;如果是目录,则列出其下所有文件和子目录
  3. 在目录列表中,所有 HTTP 超链接都需要作 URL 编码
  4. 对于目录,显示上一级的链接 ../, 除非本身已经是根目录

引入 ScalaTags

我们从一个最简单的页面开始。首先我们需要有一个 EntityEncoder 来把 ScalaTags 的输出转化为 http4sHTML 输出。http4s-scalatags 是一个 http4s 官方提供的实现。考虑到这个实现比较简单,而且本着依赖多一个不如少一个的原则,我们干脆自己写一个好了。

app/src/simple/SnapRouter.scala:

- import org.http4s.{HttpRoutes, StaticFile}
+ import org.http4s._
  import org.http4s.dsl.Http4sDsl
- import org.http4s.headers.`Accept-Ranges`
+ import org.http4s.headers.{`Accept-Ranges`, `Content-Type`}
+ import scalatags.Text.*
+ import scalatags.Text.all.*

class SnapRouter[F[_]: Async: Files](root: NioPath, metaPrefix: String) extends Http4sDsl[F] with StrictLogging {

  private given EntityEncoder[F, TypedTag[String]] =
    EntityEncoder
      .stringEncoder[F]
      .contramap[TypedTag[String]](content => "<!DOCTYPE html>" + content.render)
      .withContentType(`Content-Type`(MediaType.text.html, Charset.`UTF-8`))

}

现在可以写一个最简单的页面了。我们让这个页面拥有完整的 HTML 元素,只有两行标题,路由自 API 根目录 /.

app/src/simple/SnapRouter.scala:

class SnapRouter[F[_]: Async: Files](root: NioPath, metaPrefix: String) extends Http4sDsl[F] with StrictLogging {

  def rootR = HttpRoutes.of[F] { case request @ GET -> Root =>
    val page: TypedTag[String] = html(
      lang := "zh-CN",
      head(
        meta(charset := "utf-8"),
        meta(name    := "viewport", content := "width=device-width, initial-scale=1.0, user-scalable=no, maximum-scale=1, minimum-scale=1"),
        tags2.title("Static Snap Server")
      ),
      h1("Simple Scala Static Snap Server", style := "text-align: center; margin-top: 10%; font-size: 4em; font-family: serif;"),
      h1("一个简单的静态文件服务器", style := "text-align: center; font-size: 4em; font-family: serif;")
    )
    Ok(page)
  }
- val routes: HttpRoutes[F] = statusR <+> fileR
+ val routes: HttpRoutes[F] = rootR <+> statusR <+> fileR
}

请注意,路由的复合顺序 rootR <+> statusR <+> fileR 不能改变,否则可能得不到期望的结果。

现在我们执行 mill app.run . 把服务运行起来以后,就可以用浏览器打开 :8888 地址得到一个期望中的页面了:

04-最简单的页面

根目录文件列表

接下来我们尝试列出根目录下的所有内容。只需要删去两行标题,用列出来的内容生成超链接,去替代即可。

app/src/simple/SnapRouter.scala:


+ import scala.jdk.CollectionConverters.*

class SnapRouter[F[_]: Async: Files](root: NioPath, metaPrefix: String) extends Http4sDsl[F] with StrictLogging {

  def rootR = HttpRoutes.of[F] { case request @ GET -> Root =>
    val children               = java.nio.file.Files.list(Paths.get(root.toAbsolutePath.toString)).toList().asScala.toList
    val links                  = children
      .map { d =>
        val r       = root.relativize(d)
        val encoded = java.net.URLEncoder.encode(r.toString, StandardCharsets.UTF_8)
        p(a(href := s"/$encoded", r.toString))
      }
    val page: TypedTag[String] = html(
      lang := "zh-CN",
      head(
        meta(charset := "utf-8"),
        meta(name    := "viewport", content := "width=device-width, initial-scale=1.0, user-scalable=no, maximum-scale=1, minimum-scale=1"),
        tags2.title("Static Snap Server")
      ),
      // h1("Simple Scala Static Snap Server", style := "text-align: center; margin-top: 10%; font-size: 4em; font-family: serif;"),
      // h1("一个简单的静态文件服务器", style := "text-align: center; font-size: 4em; font-family: serif;")
      links
    )
    Ok(page)
  }
}

请注意,超链接需要用 URL 编码。

特别制造一个含有方括号需要转义的文件,测试一下:

1@nixos ~/projects/static-snap (main*?) $ echo 'xyz' > "[1].txt"
1@nixos ~/projects/static-snap (main*?) $ mill app.run .
[49/49] app.run
15:59:09.931 [io-compute-16] INFO simple.Main$ -- server parameters: root = /home/weiwen/projects/static-snap, host = 0.0.0.0, port = 8888
15:59:10.053 [io-compute-14] INFO org.http4s.ember.server.EmberServerBuilderCompanionPlatform -- Ember-Server service bound to address: [::]:8888

测试没有问题:

04-最简单的列表
04-URL转义测试

测试完毕,请记得删除测试文件 [1].txt, 否则可能会被误提交到 git repo 上去。

递归地列出目录或输出文件

为了递归地列出目录或输出文件,我们把 SnapRouter 简单重构。

package simple

import java.nio.charset.StandardCharsets
import java.nio.file.{Path => NioPath, Paths}

import scala.jdk.CollectionConverters.*

import cats.effect.kernel.Async
import cats.implicits.*
import com.typesafe.scalalogging.StrictLogging
import fs2.io.file.{Files, Path => Fs2Path}
import org.http4s._
import org.http4s.dsl.Http4sDsl
import org.http4s.headers.{`Accept-Ranges`, `Content-Type`}
import scalatags.Text.*
import scalatags.Text.all.*

/** @param root
  *   提供服务的文件系统根目录
  * @param metaPrefix
  *   元信息 API 的前缀. 比如, 如果 metaPrefix 为 "__", 则元信息 API 为 `/__/status`
  */
class SnapRouter[F[_]: Async: Files](root: NioPath, metaPrefix: String) extends Http4sDsl[F] with StrictLogging {

  require("metaPrefix".nonEmpty, "metaPrefix should not be empty")

  private val META_PREFIX: String = metaPrefix

  private given EntityEncoder[F, TypedTag[String]] =
    EntityEncoder
      .stringEncoder[F]
      .contramap[TypedTag[String]](content => "<!DOCTYPE html>" + content.render)
      .withContentType(`Content-Type`(MediaType.text.html, Charset.`UTF-8`))

  // 元信息 API
  private val statusR = HttpRoutes.of[F] { case request @ GET -> Root / META_PREFIX / "status" =>
    Ok("simple static server is running.\n")
  }

  // 列出目录内容或者返回文件
  private val mainR = HttpRoutes.of[F] { case request @ GET -> path =>
    // 由于 path 被 URL 编码,所以需要 URL 解码
    val decoded          = java.net.URLDecoder.decode(path.toString, StandardCharsets.UTF_8)
    // 将解码后的路径拼接到根路径下
    val nioPath: NioPath = Paths.get(root.toAbsolutePath.toString, decoded)
    nioPath match {
      // 如果文件不存在,返回 404 Not Found
      case n if !java.nio.file.Files.exists(n)       => NotFound()
      // 如果文件不在根目录下,返回 403 Forbidden. (虽然 URL path 理论上能阻止溢出根目录范围)
      case n if !n.toAbsolutePath().startsWith(root) => Forbidden()
      // 如果是目录,列出目录内容
      case n if (java.nio.file.Files.isDirectory(n)) => Ok(listDir(nioPath))
      // 如果是文件,返回文件内容
      case n                                         => serveFile(n, request.some)
    }
  }

  private def serveFile(path: NioPath, req: Option[Request[F]]) =
    StaticFile
      .fromPath(Fs2Path.fromNioPath(path), req)
  // 支持 Range 请求, 否则播放音视频无法任意快进
      .map(_.putHeaders(`Accept-Ranges`.bytes))
  // 如果文件不存在,返回 404 Not Found. 这里仅仅是为了形式上的正确,因为前面已经处理了文件不存在的情况
      .getOrElseF(NotFound())

  private def listDir(path: NioPath) = {
    val children: List[NioPath]       = java.nio.file.Files.list(path).toList().asScala.toList
    val links: List[TypedTag[String]] = children
      .map { d =>
        // 相对于根目录的路径
        val relativePath: NioPath = root.relativize(d)
        // 相对于父级目录的路径,为了显示的简介性
        val displayPath: NioPath  = path.relativize(d)
        // URL 编码后的路径,用于超链接
        val encoded: String       = "/" + urlEncodePath(relativePath)
        p(a(href := encoded, displayPath.toString))
      }
    html(
      lang := "zh-CN",
      head(
        meta(charset := "utf-8"),
        meta(name    := "viewport", content := "width=device-width, initial-scale=1.0, user-scalable=no, maximum-scale=1, minimum-scale=1"),
        tags2.title("Static Snap Server")
      ),
      links
    )
  }

  // 将路径中的每个部分进行 URL 编码,避免 x/y/z -> x%2Fy%2Fz
  private def urlEncodePath(path: NioPath): String =
    path.toString.split("/").map(java.net.URLEncoder.encode(_, StandardCharsets.UTF_8)).mkString("/")

  val routes: HttpRoutes[F] = statusR <+> mainR

}

请注意:

  1. 对于一个目录,我们会尝试列出目录内容. 如果目录本身不存在,因为 Files.isDirectory(nioPath) 会返回 false, 所以不会尝试列出不存在的目录的内容,而会返回 404 Not Found .
  2. 在 URL 编码路径的时候,我们需要将路径中的每个部分进行 URL 编码,而不是直接对整个路径做编码,从而避免 x/y/z -> x%2Fy%2Fz 这样的情况发生。
  3. 除了显示的文件名,所有的相对路径都应该以根目录为基准。

测试一下:

↑ 04-递归文件列表测试
↑ 04-JSON文件输出

顺便加上父级目录链接,只需要简单加一行就够了:

app/src/simple/SnapRouter.scala:

private def listDir(path: NioPath) = {
  html(
+    if (path != root) List(p(a(href := "../", "../"))) else List.empty[Modifier],
    links
  )
}

当前我们只是列出了文件名,简陋得有点惨不忍睹。可以很容易加上文件大小,最后修改日期和文件类型。为此我们只需要重写 listDir 方法,增加一个表格。

private def listDir(path: NioPath) = {
  val children: List[NioPath]       = java.nio.file.Files.list(path).toList().asScala.toList
  val trs = children
    .map { d =>
      // 相对于根目录的路径
      val relativePath: NioPath = root.relativize(d)
      // 相对于父级目录的路径,为了显示的简介性
      val displayPath: NioPath  = path.relativize(d)
      // URL 编码后的路径,用于超链接
      val encoded: String       = "/" + urlEncodePath(relativePath)
      val size                  = d.toFile().length() match {
        case s if s < 1024L                 => s"$s B"
        case s if s < 1024L * 1024L         => s"${s / 1024L} KB"
        case s if s < 1024L * 1024L * 1024L => s"${s / 1024L / 1024L} MB"
        case s                              => s"${s / 1024L / 1024L / 1024L} GB"
      }
      val lastModified          = java.nio.file.Files.getLastModifiedTime(d).toInstant().atZone(java.time.ZoneId.systemDefault())
      val formatter             = java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
      val lastModifiedDisplayed = formatter.format(lastModified)
      val typ                   = if (java.nio.file.Files.isDirectory(d)) "Directory" else "File"
      tr(td(a(href := encoded, displayPath.toString)), td(size), td(lastModifiedDisplayed), td(typ))
    }
  html(
    lang := "zh-CN",
    head(
      meta(charset := "utf-8"),
      meta(name    := "viewport", content := "width=device-width, initial-scale=1.0, user-scalable=no, maximum-scale=1, minimum-scale=1"),
      tags2.title("Static Snap Server")
    ),
    table(
      tr(th("Name"), th("Size"), th("Last Modified"), th("Type")),
      if (path != root) tr(td(a(href := "../", "../"))) else tr(),
      tbody(trs)
    )
  )
}

测试一下:

↑ 04-表格-1
↑ 04-表格-2

emmmmm… 看起来还是非常简陋,因为我们还没有使用样式,但是基本的信息已经有了,我们的应用终于第一次达到了一定的可用性。

我们将在下一节来美化它的外观。

至此,我们的项目结构和代码行数统计如下:

↑ 04-项目结构和代码行数

也就是说,我们折腾了这么久,居然只写了 328 行 Scala 代码,除去空行和注释,其实只有 248 行。

其中,可执行文件 rewatch 是个方便开发测试的 Shell 脚本,可以快速启动和关闭测试服务。

rewatch:

#!/usr/bin/env bash

for p in $(ps -ef | grep simple.Main | grep static-snap | grep -v grep | awk '{print $2}')
do
  kill -9 $p
done

mill -w app.run .

本节所有代码都可以在 github 仓库的 04-list-dir tag 上找到:https://github.com/weiwen99/static-snap/tree/04-list-dir .

引入 UI 库优化界面

本节我们尝试调整页面 UI 以提高可用性。

首先引入流行的 CSS 库 Materialize:

app/src/simple/SnapRouter.scala:

html(
      lang := "zh-CN",
      head(
+        link(rel     := "stylesheet", href  := "https://cdn.bootcdn.net/ajax/libs/materialize/1.0.0/css/materialize.min.css"),
+        link(
+          rel        := "stylesheet",
+          href       := "https://fonts.googleapis.com/icon?family=Material+Icons"
+        ),
+        script(src   := "https://cdn.bootcdn.net/ajax/libs/materialize/1.0.0/js/materialize.min.js"),
      )
)
↑ 05-首次应用Materialize-Web
↑ 05-首次应用Materialize-iPhone

芜湖,直接起飞!它甚至能很好地适配移动端设备!这一下子让我有点措手不及。本来上一节我是用 table 随便写的,想着本节好好下工夫重新写界面,写得好看些。现在看来,完全没必要了。

直接下课!

不过下课之前,我们先找点事情做做。首先是给表格加上 Materialize 的 striped 两个类。

      table(
+        cls := "striped",
        tr(th("Name"), th("Size"), th("Last Modified"), th("Type")),
        if (path != root) tr(td(a(href := "../", "../"))) else tr(),
        tbody(trs)
      )

效果:

↑ 05 striped table

其次,我们最好不要从 CDN 上下载静态文件。因为我们这个服务的主要使用场景,更多的是在内网浏览和下载文件而已,没有必要引入外网的依赖。

那么我们需要从本地 HTTP Service 提供静态资源文件服务。首先引入以下静态资源文件(你也可以从我们的 github repo 上直接复制):

1@nixos ~/projects/static-snap (main*?) $ tree app/resources
app/resources
├── css
└── static
    ├── css
    │   └── materialize.min.css
    ├── font
    │   ├── flUhRq6tzZclQEJ-Vdg-IuiaDsNc.woff2
    │   └── google-fonts-icon.css
    └── js
        └── materialize.min.js

6 directories, 4 files

更新 SnapServer, 让 HTTP Service 提供以双下划线 __ 静态资源文件服务。之所以采用 /__/statics/css/ 这样的 URL 路径而不是常见的 /statics/css/, 主要是尽量避免和要提供服务的根目录下的目录产生冲突。这个问题我们之前在第二节讨论过。

app/src/simple/SnapServer.scala:

package simple

import cats.effect._
import cats.effect.kernel.Async
import com.comcast.ip4s.{Host, Port}
import fs2.io.file.Files
import fs2.io.net.Network
import org.http4s.HttpRoutes
import org.http4s.ember.server.EmberServerBuilder
import org.http4s.server.{Router, Server}
import org.http4s.server.staticcontent._

object SnapServer {

  def build[F[_]: Async: Files: Network](routes: HttpRoutes[F], host: Host, port: Port): Resource[F, Server] = {
    val resourceStaticRoutes = ResourceServiceBuilder("/static").withPathPrefix("/static").toRoutes
    val router               = Router("/" -> routes, "__" -> resourceStaticRoutes).orNotFound
    EmberServerBuilder
      .default[F]
      .withHttpApp(router)
      .withHost(host)
      .withPort(port)
      .withHttp2
      .build

  }
}

最后一步,把静态资源文件的引用从 CDN 改为本服务。

app/src/simple/SnapRouter.scala:

html(
  lang := "zh-CN",
  head(
    meta(charset := "utf-8"),
    meta(name    := "viewport", content := "width=device-width, initial-scale=1.0, user-scalable=no, maximum-scale=1, minimum-scale=1"),
    link(rel     := "stylesheet", href  := "/__/static/css/materialize.min.css"),
    link(rel     := "stylesheet", href  := "/__/static/font/google-fonts-icon.css"),
    script(src   := "/__/static/js/materialize.min.js"),
    tags2.title("Static Snap Server")
  ),

重启服务测试成功。

在移动设备上,页面左右两边留一点空间看起来会更舒适: app/src/simple/SnapRouter.scala:

-      table(
-        cls := "striped",
-        tr(th("Name"), th("Size"), th("Last Modified"), th("Type")),
-        if (path != root) tr(td(a(href := "../", "../"))) else tr(),
-        tbody(trs)
+      body(
+        style := "padding: 0 1em 0 1em;",
+        table(
+          cls := "striped",
+          tr(th("Name"), th("Size"), th("Last Modified"), th("Type")),
+          if (path != root) tr(td(a(href := "../", "../"))) else tr(),
+          tbody(trs)
+        )  

以上代码也修复了此前 HTML 页面没有 body 标签的问题。

可以给页面加上当前目录,作为标题。 app/src/simple/SnapRouter.scala:

       }
+    val displayedDir            = s"/${root.relativize(path)}"
     html(
       lang := "zh-CN",
       head(
         script(src   := "/__/static/js/materialize.min.js"),
-        tags2.title("Static Snap Server")
+        tags2.title(displayedDir)
       ),
       body(
         style := "padding: 0 1em 0 1em;",
+        h6(s"Index of $displayedDir"),
         table(
           cls := "striped",

效果:

↑ 05 titled page Web
↑ 05 titled page Phone

本节所有代码和变更可以在 github repo 的 05-add-materializecss tag 上找到:https://github.com/weiwen99/static-snap/tree/05-add-materializecss .

配置 gihub workflow

话不多说,直接引入文件 .github/workflows/scala.yml 即可。

.github/workflows/scala.yml:

name: Scala Mill CI

on:
  push:
    branches: [ "*" ]
  pull_request:
    branches: [ "main" ]

permissions:
  contents: read

jobs:

  build:

    runs-on: ubuntu-latest

    steps:

    - uses: actions/checkout@v3

    - name: Set up JDK 22
      uses: actions/setup-java@v3
      with:
        java-version: '22'
        distribution: 'temurin'

    - uses: jodersky/setup-mill@master
      with:
        mill-version: 0.11.7

    - name: Compile
      run: mill __.compile

    - name: Test
      run: mill __.test

这个 CI 用力 JDK 22 和 mill 0.11.7, 会在任何 git push 和 git pull request 时自动触发。执行的动作是 mill __.compilemill __.test .

可以去 github 页面 https://github.com/weiwen99/static-snap/actions 查看 CI 任务详情。

↑ 06 github action 1
↑ 06 github action 2

实现文件排序

为了实现页面上文件的排序功能,我们需要增加额外的业务逻辑。我们之前把文件列表功能写在 SnapRouter 里,这是不利于工程上维护的。为了使得项目 代码结构更为合理,大部分的业务逻辑应该从 SnapRouter 中抽离出来。

于是我们考虑把文件的信息用 case class FileMeta 来表示,而排序,搜索之类的功能在 FileService 中实现。

先来实现一个全新的 FileMeta.

app/src/simple/FileMeta.scala:

package simple

import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.nio.file.{Files, Path}
import java.nio.file.attribute.{BasicFileAttributes, FileTime}
import java.time.{ZonedDateTime, ZoneId}
import java.time.format.DateTimeFormatter

/** 文件元信息
  *
  * @param root
  *   根目录
  * @param path
  *   文件路径
  */
final case class FileMeta(root: Path, path: Path) {

  import FileMeta.*

  // 目录名或者文件名
  val name: String = path.getParent().relativize(path).toString

  // 文件属性集
  val attrs: BasicFileAttributes = Files.readAttributes(path, classOf[BasicFileAttributes])

  // 是否是目录
  val isDirectory: Boolean = attrs.isDirectory()

  // 文件最后访问时间
  val lastAccessTime: ZonedDateTime = attrs.lastAccessTime().toZonedDateTime

  // 文件最后修改时间
  val lastModifiedTime: ZonedDateTime = attrs.lastModifiedTime().toZonedDateTime

  // 文件创建时间
  val creationTime: ZonedDateTime = attrs.creationTime().toZonedDateTime

  // 相对于根目录的路径
  val relativeToRoot: Path        = root.relativize(path)

  // 文件大小,单位 Byte
  val size = attrs.size()

  // 人类可读的文件大小
  val humanSize: String = size match {
    case s if s < 1024L                 => s"$s B"
    case s if s < 1024L * 1024L         => s"${s / 1024L} KB"
    case s if s < 1024L * 1024L * 1024L => s"${s / 1024L / 1024L} MB"
    case s                              => s"${s / 1024L / 1024L / 1024L} GB"
  }

  // 文件类型
  val `type`: String = if (isDirectory) "Directory" else "File"

  // URL 编码后的路径,用于超链接. 将路径中的每个部分进行 URL 编码,避免 x/y/z -> x%2Fy%2Fz
  val href: String =
    "/" + relativeToRoot.toString.split("/").map(URLEncoder.encode(_, StandardCharsets.UTF_8)).mkString("/")
}

object FileMeta {

  private val zoneId: ZoneId = ZoneId.systemDefault()

  private[FileMeta] val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")

  // 将 FileTime 转换为 ZonedDateTime
  extension (fileTime: FileTime) {
    def toZonedDateTime: ZonedDateTime = fileTime.toInstant().atZone(zoneId)
  }

  // 生成适合显示的时间日期
  extension (zonedDateTime: ZonedDateTime) {
    def formatted: String = formatter.format(zonedDateTime)
  }
}

接下来是 FileService. 这个文件我们暂时没有增加新的功能,只有一个简单的方法。

app/src/simple/FileService.scala:

package simple

import java.nio.file.{Files, Path}

import scala.jdk.CollectionConverters.*

class FileService(root: Path) {

  def listDir(path: Path): List[FileMeta] = {
    Files
      .list(path)
      .toList()
      .asScala
      .toList
      .map { d => FileMeta(root = root, path = d) }
  }
}

现在可以在 SnapRouter 中重写 listDir() 方法,并删去其他不必要的部分了。 app/src/simple/SnapRouter.scala:

+import simple.FileMeta.*

private def listDir(path: NioPath): TypedTag[String] = {
    val simpleDirName = s"/${root.relativize(path)}"
    val trs           = FileService(root).listDir(path).map { f =>
      tr(
        td(a(href := f.href, f.name)),
        td(f.`type`),
        td(f.humanSize),
        td(f.lastModifiedTime.formatted),
        td(f.lastAccessTime.formatted),
        td(f.creationTime.formatted)
      )
    }
    html(
      lang := "zh-CN",
      head(
        meta(charset := "utf-8"),
        meta(name    := "viewport", content := "width=device-width, initial-scale=1.0, user-scalable=no, maximum-scale=1, minimum-scale=1"),
        link(rel     := "stylesheet", href  := "/__/static/css/materialize.min.css"),
        link(rel     := "stylesheet", href  := "/__/static/font/google-fonts-icon.css"),
        script(src   := "/__/static/js/materialize.min.js"),
        tags2.title(simpleDirName)
      ),
      body(
        style := "padding: 0 1em 0 1em;",
        h6(s"Index of $simpleDirName"),
        table(
          cls := "striped",
          tr(th("Name"), th("Type"), th("Size"), th("Last Modified"), th("Last Access"), th("Creation Time")),
          if (path != root) tr(td(a(href := "../", "../"))) else tr(),
          tbody(trs)
        )
      )
    )
  }

-  // 将路径中的每个部分进行 URL 编码,避免 x/y/z -> x%2Fy%2Fz
-  private def urlEncodePath(path: NioPath): String =
-    path.toString.split("/").map(java.net.URLEncoder.encode(_, StandardCharsets.UTF_8)).mkString("/")

请注意,我们重构的同时,也增加了 "Last Access" 和 "Creation Time" 两列。

重新运行我们的服务,得到新增两列的新界面:

↑ 07 新增两列的界面

现在可以考虑排序的实现了。最简单的排序应该支持两个参数:排序的字段 column 和排序的升降 order. 一个自然的想法是用 URL 的 query string 来传递这两个参数。我们可以设计形如 /app/src/simple?sort=Name:Desc 的 API, 来表示按文件名降序排列。

可以先做一个基本的实现规划:

  1. 我们首先定义 SortColumnSortOrder,
  2. 想办法让 http4s 的 API 能接收并解析这两个参数,
  3. FileService 里实现排序的逻辑,
  4. 在 API 实现里调用排序的逻辑,
  5. 改进页面,让表头支持可以快捷的排序操作。

先考虑排序字段的问题。理论上讲,我们可以直接用 String 来定义这个字段,但这样不够严谨,需要手动处理错误的 API 参数输入。如果我们把 SortColumn 定义成枚举,那么这个参数在 API 层就能得到校验,如果用不支持排序的字段请求 API, 将自动得到一个 400 Bad Request 的 HTTP Response.

app/src/simple/Sort.scala:

package simple

import scala.util.Try

import cats.implicits.*

enum SortOrder:
  case Asc  extends SortOrder
  case Desc extends SortOrder

enum SortColumn:
  case Name         extends SortColumn
  case Size         extends SortColumn
  case Type         extends SortColumn
  case LastModified extends SortColumn
  case LastAccess   extends SortColumn
  case Creation     extends SortColumn

final case class SortBy(column: SortColumn, order: SortOrder)

object SortBy {

  def parseString(s: String): Either[String, SortBy] = {
    s.split(":").toList match {
      case column :: order :: Nil =>
        val r = for {
          column <- Try(SortColumn.valueOf(column)).toEither.leftMap(_.getMessage())
          order  <- Try(SortOrder.valueOf(order)).toEither.leftMap(_.getMessage())
        } yield SortBy(column, order)
        r
      case _                      => Left("Invalid SortBy format")
    }
  }
}

我们写一个对 parseString() 的单元测试:

app/test/src/simple/QuerySpec.scala:

package simple

import org.scalatest.matchers.should
import org.scalatest.wordspec.AnyWordSpec

class QuerySpec extends AnyWordSpec with should.Matchers {

  "SortBy" should {
    "parse valid string" in {
      SortBy.parseString("Name:Asc") shouldBe Right(SortBy(SortColumn.Name, SortOrder.Asc))
      SortBy.parseString("Name:Desc") shouldBe Right(SortBy(SortColumn.Name, SortOrder.Desc))
      SortBy.parseString("LastModified:Desc") shouldBe Right(SortBy(SortColumn.LastModified, SortOrder.Desc))
    }
    "parse invalid string" in {
      SortBy.parseString("Name:Invalid") shouldBe Left("enum simple.SortOrder has no case with name: Invalid")
      SortBy.parseString("Name:Invalid:Ext") shouldBe Left("Invalid SortBy format")
    }
  }
} 

可以执行 mill app.test 来运行我们的测试。

接下来我们让 mainR 这个 API 能接收这个参数。

app/src/simple/SnapRouter.scala:

+import org.http4s.dsl.impl.OptionalQueryParamDecoderMatcher

given QueryParamDecoder[SortBy] =
  QueryParamDecoder[String].emap(s => SortBy.parseString(s).leftMap(t => ParseFailure(t, t)))

object OptionalSortByQueryParamMatcher extends OptionalQueryParamDecoderMatcher[SortBy]("sort")  

class SnapRouter[F[_]: Async: Files](root: NioPath, metaPrefix: String) extends Http4sDsl[F] with StrictLogging {
  // 列出目录内容或者返回文件
  - private val mainR = HttpRoutes.of[F] { case request @ GET -> path =>
  + private val mainR = HttpRoutes.of[F] { case request @ GET -> path :? OptionalSortByQueryParamMatcher(sortByOpt) =>
  +    logger.debug("got sort parameter: {}", sortByOpt)

这里我们让 query string 参数 sort 是可选的,接受以后只打印日志。运行测试一下:

↑ 07 服务器端日志
↑ 07 客户端请求

另外容易发现我们不写 sort 这参数的时候, sortByOpt 的值会是 None . 这完全符合我们的预期。

接下来我们在 FileService 重载 listDir() 方法,实现排序的逻辑。

app/src/simple/FileService.scala:

package simple

import java.nio.file.{Files, Path}

import scala.jdk.CollectionConverters.*

class FileService(root: Path) {

  def listDir(path: Path): List[FileMeta] = {
    Files
      .list(path)
      .toList()
      .asScala
      .toList
      .map { d => FileMeta(root = root, path = d) }
  }

  // TODO: 避免 sortBy.order = Desc 时的双重遍历
  def listDir(path: Path, sortBy: SortBy): List[FileMeta] = {
    val files = listDir(path)
    val rs1   = sortBy.column match
      case SortColumn.Name         => files.sortBy(_.name)
      case SortColumn.Size         => files.sortBy(_.size)
      case SortColumn.Type         =>  files.sortBy(f => (f.`type`, f.name))
      case SortColumn.LastModified => files.sortBy(_.lastModifiedTime)
      case SortColumn.LastAccess   => files.sortBy(_.lastAccessTime)
      case SortColumn.Creation     => files.sortBy(_.creationTime)
    val rs2   = sortBy.order match
      case SortOrder.Asc  => rs1
      case SortOrder.Desc => rs1.reverse
    rs2
  }
}

请注意,我们对 sortBy.columnsortBy.order 使用了模式匹配。如果我们漏写了其中任意一个分支,Scala 编译器会警告我们 "match may not be exhaustive" . 例如,如果我们把 case SortColumn.Creation => files.sortBy(_.creationTime) 这行删掉或者注释掉,会发现代码编辑器发出警告: "match may not be exhaustive. It would fail on pattern case: Creation". 如果我们忽略它,直接编译代码,Scala 编译器也会发出同样的警告。另外,我们 在写这两段模式匹配代码的时候,配置妥当的代码编辑器会自动帮我们补全所有可能的匹配分支。以上这些,都是 Scala 完善的类型系统给我们带来的便利。(当 然不是说其他语言就做不到。)

app/src/simple/SnapRouter.scala:

 class SnapRouter[F[_]: Async: Files](root: NioPath, metaPrefix: String) extends Http4sDsl[F] with StrictLogging {

+  private val DEFAULT_SORT_BY: SortBy = SortBy(SortColumn.Name, SortOrder.Asc)
+
   // 列出目录内容或者返回文件
   private val mainR = HttpRoutes.of[F] { case request @ GET -> path :? OptionalSortByQueryParamMatcher(sortByOpt) =>
-    logger.debug("got sort parameter: {}", sortByOpt)
+    val sortBy           = sortByOpt.getOrElse(DEFAULT_SORT_BY)

       // 如果是目录,列出目录内容
-      case n if (java.nio.file.Files.isDirectory(n)) => Ok(listDir(nioPath))
+      case n if (java.nio.file.Files.isDirectory(n)) => Ok(listDir(nioPath, sortBy))
       // 如果是文件,返回文件内容

-  private def listDir(path: NioPath): TypedTag[String] = {
+  private def listDir(path: NioPath, sortBy: SortBy): TypedTag[String] = {
     val simpleDirName = s"/${root.relativize(path)}"
-    val trs           = FileService(root).listDir(path).map { f =>
+    val trs           = FileService(root).listDir(path, sortBy).map { f =>

为了应用我们的新 API, 重写页面的表头,使之带超链接。

app/src/simple/SnapRouter.scala:

class SnapRouter[F[_]: Async: Files](root: NioPath, metaPrefix: String) extends
         h6(s"Index of $simpleDirName"),
         table(
           cls := "striped",
-          tr(th("Name"), th("Type"), th("Size"), th("Last Modified"), th("Last Access"), th("Creation Time")),
+          tableHeaderOf(sortBy),

def tableHeaderOf(sortBy: SortBy): TypedTag[String] = {
    val arrow                    = sortBy.order match
      case SortOrder.Asc  => "▲"
      case SortOrder.Desc => "▼"
    def thOf(column: SortColumn) =
      th(
        a(
          href := s"?sort=$column:${if sortBy.column == column && sortBy.order == SortOrder.Asc then SortOrder.Desc else SortOrder.Asc}",
          column.toString + (if sortBy.column == column then arrow else "")
        )
      )
    tr(
      List(SortColumn.Name, SortColumn.Type, SortColumn.Size, SortColumn.LastModified, SortColumn.LastAccess, SortColumn.Creation).map(thOf)
    )
  }

现在可以得到带任意字段排序的新页面了:

↑ 07 按文件大小倒序排列的的页面

小结

本节我们实现了文件的排序输出和页面交互功能,为了实现这些功能,我们学习了:

  • Scala 3Enumerations (枚举类型)
  • Scala 3extension, 用于替代 Scala 2implicit class
  • http4s 如何接受复杂的 Query String 参数
  • Scala 3 中如何重载方法
  • Scalatags 中如何根据比较复杂的业务逻辑生成需要的页面元素
  • 把相对复杂的业务逻辑从 API 层分离出来
  • 调用 Java NIO2 的 API 获取本地文件系统的信息

本节所有代码都可以在 https://github.com/weiwen99/static-snap/tree/07-file-sorting 找到。

构建 GraalVM Native Image

回顾我们已经有的打包编译动作,我们用 mill app.universalStage 来生成可执行 Shell 脚本和必要的一组 jar 包,而 universalStagePackageTarBzip2 之类的命令则把打包出来的脚本和 jar 包进一步打包成归档文件和不同格式的压缩包。

↑ universal packaging

这样打出来的包,运行的时候,其实就是执行 java --class 这个命令。它的缺点有几个:

  • 运行时依赖系统上的 JRE
  • 作为命令行程序,启动速度比较慢。不过我们这个应用一般不会频繁重启,这一点应该不大
  • 作为一个可执行应用程序,其安装文件包含一堆 jar 包,终究比较啰嗦

本节我们尝试把整个应用打包成 GraalVM Native Image.

Java/Scala 项目的 GraalVM Native Image 化,需要依赖 GraalVM 提供的 native-image 应用程序,一般有两种途径:直接利用系统上已经安装的 GraalVM 和 根据指定的版本即时下载 GraalVM 再使用。由于本人是 NixOS Linux 用户,由于 NixOS 的非 FHS 特性,第二种方式变得不那么方便。为了简(tou)单(lan)起 见,我们直接使用系统上的 GraalVM. 另外,mill 的第三方插件 native-image 提供了第二种方式的支持,感兴趣的话可以去 https://mill-build.com/mill/Thirdparty_Plugins.html#_native_image 找到。不论如何,对于初学者而言,我们即将展示的方式都会更加简洁和富有启发性。

首先我们需要在系统上安装 GraalVM 21+, 其文档见 https://www.graalvm.org/22.0/reference-manual/native-image .

从官方文档可以找到, native-image 的运行本质上是调用这么一个命令:

$ native-image --class-path xx:xx:xx [options] -o output MainClass

于是我们可以自己写一个名为 GraalvmNativeImageModule 的 mill module. build.sc:

// add GraalVM native-image support, use `mill {subproject}.graalvmNativeImage` to generate native image
trait GraalvmNativeImageModule extends JavaModule {

  // GraalVM native-image executable in the system
  def graalvmNativeExecutable: T[String] = "native-image"

  // GraalVM native-image main class
  def graalvmNativeMainClass: T[String]

  // GraalVM native-image options
  def graalvmNativeImageOptions: T[Seq[String]] = T(Seq.empty[String])

  // GraalVM native-image executable script name
  def graalvmExecutableScriptName: T[String]

  // GraalVM native-image class paths
  def graalvmNativeClassPaths: T[Seq[os.Path]] = T { runClasspath().map(_.path) }

  // GraalVM native-image task
  def graalvmNativeImage: T[Unit] = T {
    val out   = T.dest / graalvmExecutableScriptName()
    val cmds1 = graalvmNativeExecutable() :: "--class-path" :: graalvmNativeClassPaths().mkString(":") :: Nil
    val cmds2 = "-o" :: out.toString :: graalvmNativeMainClass() :: Nil
    val cmds  = cmds1 ++ graalvmNativeImageOptions() ++ cmds2
    graalvmNativeImageOptions() ++
      List(
        "-o",
        out.toString,
        graalvmNativeMainClass()
      )
    T.log.info(s"""executing: ${cmds.mkString(" ")}""")
    val r     = os.proc(cmds).call()
    if (r.exitCode == 0) {
      T.log.info(s"generated GraalVM native image: $out")
      val size     = os.size(out)
      val sizeInKB = size / 1024.0
      val sizeInMB = sizeInKB / 1024.0
      T.log.info(s"""generated GraalVM native image size: $size Bytes ≈ ${"%.3f".format(sizeInKB)} KB ≈ ${"%.3f".format(sizeInMB)} MB""")
    } else {
      T.log.error("got non ZERO cmd result when call native-image")
    }
  }
}

然后将它应用在 app 子项目上。

build.sc:

-object app extends ScalaModule with ScalafmtModule with JavaAppPackagingModule {
+object app extends ScalaModule with ScalafmtModule with JavaAppPackagingModule with GraalvmNativeImageModule {
+
+  def graalvmNativeMainClass: T[String] = T("simple.Main")
+
+  def graalvmNativeImageOptions: T[Seq[String]] = T(
+    Seq(
+      "--verbose",
+      "--static",
+      "--initialize-at-build-time",
+      "--no-fallback",
+      "-H:+UnlockExperimentalVMOptions",
+      "-H:IncludeResources=.*/(.*.css)|(.*.js)$",
+      "-H:-UnlockExperimentalVMOptions"
+    )
+  )
+
+  // Simple Scala Static Snap Server: s5
+  def graalvmExecutableScriptName: T[String] = T("s5")

其中,关键的 graalvmNativeImageOptions 参数解释如下:

  • --static 用于生成静态链接的可执行文件
  • --initialize-at-build-time--no-fallback 是必须的,具体原因我们这里不去细究
  • -H:IncludeResources=.*/(.*.css)|(.*.js)$ 是为了把必要的静态资源文件包含进去,否则我们将会得到一个没有任何样式的文件

本文旨在探索如何充分整合利(bai)用(piao) Scala 和 JVM 生态,GraalVM Native Image 的参数选项不是本文的讨论范畴,如果有需要,请到官网翻阅文档。

生成出来的可执行文件被命名为 s5, 由 Simple Scala Static Snap Server 硬凑而来。因为 static-snap 作为命令行程序,嫌长了点,而 s3 已经 被 AWS 用去了, s4 又显得不够厉害,于是我们强行凑一个 s5 好了。其实如果你喜欢,直接用 sssss 也是可以的。

万事俱备,现在直接执行 mill app.graalvmNativeImage 编译打包命令。

↑ Graalvm Native Image 编译打包

特别注意到:

1@nixos ~/projects/static-snap (weiwen/08-graalvm-native-packge*) $ file ./out/app/graalvmNativeImage.dest/s5
./out/app/graalvmNativeImage.dest/s5: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, for GNU/Linux 3.10.0, with debug_info, not stripped

这是一个静态链接的可执行文件,命令行输出显示了它的适用范围。这样我们安装到系统上的时候,只需要直接复制这个文件就行了。

小结

本节我们事实上实现了一个可用的 GraalVM Native Image mill Plugin, 调用系统的 native-image 来构建静态链接可执行文件。

本节所有阶段性代码都可以在 https://github.com/weiwen99/static-snap/tree/08-graalvm-native-image 找到。

构建 Github 发布工作流

上一节我们成功地把代码编译成可以执行的实用程序,本节就可以在此基础上构建一个从开发到 Github 发布的工作流。

你可能已经注意到我们的项目版本号是固定写死的 0.0.1-SNAPSHOT . 让我们先把项目版本化。

一般而言,大多数构建工具都会配合 git 来做项目的版本管理,我们用的 mill 也不例外。 versionFile 是一个非常简单实用的 mill plugin, 提供了我们需要的基础功能。

先引入这个插件。

build.sc:

import $ivy.`com.lihaoyi::mill-contrib-versionfile:`
import mill.contrib.versionfile.VersionFileModule  

object versionFile extends VersionFileModule {
  def millSourcePath = millOuterCtx.millSourcePath
}

写一个初始版本号到 version 这个文本文件中去。

1@nixos ~/projects/static-snap (feature/09-github-release-workflow*?) $ echo '0.0.1-SNAPSHOT' > version
1@nixos ~/projects/static-snap (feature/09-github-release-workflow*?) $ cat version
0.0.1-SNAPSHOT

我们来遛一遛这个新玩具:

↑ Version File

可见 version file 的工作原理其实就是把版本号写入文本文件 version, 其版本命名遵循语义化版本(Semantic versioning).

我们考虑如下的发布工作流:

  1. 本地,代码格式化: mill __.reformat
  2. 本地,清理: mill clean
  3. 本地,校验: mill __.test
  4. 本地,确定一个将要发布的版本号 RELEASE_VERSION
  5. 本地,打一个 git tag v{RELEASE_VERSION}
  6. 本地,git push tag v{RELEASE_VERSION} 到 Github
  7. Github, 自动触发发布构建的 github action, 生成 Github Release Draft(v{RELEASE_VERSION}), 以及可供下载的附件(Assets)
  8. 本地,生成下一个版本号 NEXT_VERSION, 这是个 snapshot 版本,以 -SNAPSHOT 结尾
  9. 本地,打一个 git tag v{NEXT_VERSION}
  10. 本地,git push tag v{NEXT_VERSION} 到 Github
  11. Github, 自动触发发布构建的 github action, 生成 Github Release Draft(v{NEXT_VERSION}), 以及可供下载的附件(Assets)
  12. 浏览器页面,检查 Github Release Draft v{RELEASE_VERSION}v{NEXT_VERSION}, 酌情发布为正式版本

请注意,这只是一个可能比较适合单人开发的开源软件的发布工作流,不同的项目性质、团队结构、迭代需求、权限管理要求等等诸多因素综合起来,可能会需要不 同的工作流。本节仅仅是一个满足我们当前需求的例子,旨在说明事情的大概思路和可行性。

在开始之前,我们先做一些简单的调整。

build.sc:

object app extends ScalaModule with ScalafmtModule with JavaAppPackagingModule with GraalvmNativeImageModule {

+  // Simple Scala Static Snap Server: s5
+  val appName = "s5"
+
   // Define the main class
   def mainClass = Some("simple.Main")

+  def executableScriptName = T(appName)
+
+  def packageName: T[String] = appName + "-" + packageVersion()
+
   // Define the top-level directory name for the archived package
   def topLevelDirectory = Some(packageName())

   // Define the version of the package
-  def packageVersion = "0.0.1-SNAPSHOT"
+  def packageVersion = T { "v" + versionFile.currentVersion().toString }

+  def maintainer: T[String] = "weiwen99 <weiwen@weiwen.org>"

-  // Simple Scala Static Snap Server: s5
-  def graalvmExecutableScriptName: T[String] = T("s5")
+  def graalvmExecutableScriptName: T[String] = T(appName)
 }

上述更改主要把包名和可执行文件从 app 改为 s5, 为了便于发布以后可供下载的文件更好读。

检验一下:

↑ New pacakge structure

考虑创建一个 release Shell 脚本文件,来处理我们的本地发布工作流,一个 .github/workflows/release.yml 来响应 Github 端的构建任务。

release:

#!/usr/bin/env sh

set -e

RELEASE_VERSION=""

NEXT_BUMP="patch"

while [[ $# -gt 0 ]]; do
  case $1 in
    -v|--version)
      RELEASE_VERSION="$2"
      shift # past argument
      shift # past value
      ;;
    -b|--bump)
      if [[ $2 == 'patch' || $2 == 'minor' || $2 == 'major' ]]; then
        NEXT_BUMP="$2";
      fi
      shift # past argument
      shift # past value
      ;;
    -*|--*)
      echo "Unknown option $1"
      exit 1
      ;;
    *)
      shift # past argument
      ;;
  esac
done

echo "release process started."

# 格式化 Scala 代码
mill __.reformat
echo "code reformated."

mill clean

mill __.test

if [[ $RELEASE_VERSION != "" ]]; then
  # 设置 release 版本。这个版本号会写入 version 文件。
  mill versionFile.setVersion --version $RELEASE_VERSION
else
  # 设置 release 版本。这个版本号会写入 version 文件。
  # 例如,如果当前版本是 0.1.0-SNAPSHOT,那么设置 release 版本后,version 文件的内容会变成 0.1.0
  mill versionFile.setReleaseVersion
  # 读取 version 文件中的发布版本号, 去掉双引号
  RELEASE_VERSION=$(mill show versionFile.currentVersion | awk -F'"' '{print $2}')
fi

TAG=v$RELEASE_VERSION

echo "set release version to $RELEASE_VERSION, git tag to $TAG"

git commit -am "release $TAG"
git tag -f $TAG
git push -f origin
git push origin -f $TAG

mill versionFile.setNextVersion --bump $NEXT_BUMP

# 读取 version 文件中的下一个版本号, 去掉双引号
NEXT_VERSION=$(mill show versionFile.currentVersion | awk -F'"' '{print $2}')
TAG=v$NEXT_VERSION

echo "set version to $NEXT_VERSION, git tag to $TAG ."

git commit -am "snapshot version $TAG"
git tag -f $TAG
git push -f origin $TAG
git push -f

echo "release completed. the released version is $RELEASE_VERSION, next version is $NEXT_VERSION."
echo "check https://github.com/weiwen99/static-snap/actions for build status."
echo "check https://github.com/weiwen99/static-snap/releases for draft release status."
echo "the release will be available for download after the build is completed (it might take a few minutes)."

exit 0

需要赋予可执行权限:

1@nixos ~/projects/static-snap (feature/09-github-release-workflow*?) $ chmod u+x release
1@nixos ~/projects/static-snap (feature/09-github-release-workflow*?) $ file release
release: a sh script, Unicode text, UTF-8 text executable

可执行脚本 release 接受两个可选参数 --version(-v)--bump(-b) :

  • --version(-v): 指定 RELEASE_VERSION. 如果不指定,则通过 mill versionFile.setReleaseVersion 读取 version 文件记录的版本号并转化为发布版本号(即去掉 -SNAPSHOT 后缀)。
  • --bump(-b): 指定下一个版本号的跳跃级别,有 patch, minormajor 三种可选,默认为 patch. 具体请参见 Semantic versioning.

.github/workflows/release.yml:

name: Release

on:
  push:
    # Sequence of patterns matched against refs/tags
    tags:
    # Push events to matching v*, i.e. v1.0, v20.15.10    
    - 'v*'
    # Ignore push events to matching v*-SNAPSHOT, uncomment this line if you want to ignore push events to matching v*-SNAPSHOT
    # - '!v*-SNAPSHOT'

jobs:

  build:

    runs-on: ubuntu-latest

    steps:

    - uses: actions/checkout@v4

    - uses: graalvm/setup-graalvm@v1
      with:
        java-version: '22'
        distribution: 'graalvm-community'
        github-token: ${{ secrets.GITHUB_TOKEN }}

    - name: Setup Mill
      run: |
        mkdir -pv ~/.local/bin
        curl -L https://github.com/com-lihaoyi/mill/releases/download/0.11.7/0.11.7 > ~/.local/bin/mill
        chmod +x ~/.local/bin/mill
        echo "export PATH=\$HOME/.local/bin:\$PATH" >> $GITHUB_PATH

    - name: Get Version
      id: get_version
      run: echo "VERSION=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_OUTPUT

    - name: Test
      run: mill __.test

    # to cache the stage directory for the next step
    - name: Universal Staging
      run: mill app.universalStage

    - name: Build Universal Packages
      run: |
        mill app.universalStagePackageTarZstd
        mill app.universalStagePackageTarBzip2
        mill app.universalStagePackageTarGzip

    - name: Build GraalVM Native Image
      run: |
        mill app.graalvmNativeImage
        cd out/app/graalvmNativeImage.dest/
        tar jcpvf s5-graalvm-native-image-${{ steps.get_version.outputs.VERSION }}.tar.bz2 s5

    - name: Release
      uses: softprops/action-gh-release@v2
      with:
        draft: true
        files: |
          out/app/universalStagePackageTarZstd.dest/s5-${{ steps.get_version.outputs.VERSION }}.tar.zstd
          out/app/universalStagePackageTarBzip2.dest/s5-${{ steps.get_version.outputs.VERSION }}.tar.bz2
          out/app/universalStagePackageTarGzip.dest/s5-${{ steps.get_version.outputs.VERSION }}.tar.gz
          out/app/graalvmNativeImage.dest/s5-graalvm-native-image-${{ steps.get_version.outputs.VERSION }}.tar.bz2

我们在每次发布的下载列表中提供了 GraalVM Native Image 的压缩包,还有以 jar + Shell script 组成的 universal 压缩包(有 .tar.zstd, .tar.ba2, .tar.gz 三种格式)。

请注意,我们使用了 tar jcpvf s5-graalvm-native-image-${{ steps.get_version.outputs.VERSION }}.tar.bz2 s5 把编译生成的 GraalVM Native Image 压 缩成 .tar.bz2, 这一步可以把 50MB 左右的可执行文件压缩到 14.5MB 左右。对于供下载的文件而言,这个体积的减小是非常有价值的。

下面这几行指定了触发发布构建的条件是有以 v 开头的 git tags 被推送到 github 上:

on:
  push:
    tags:
    - 'v*'

如果不希望以 -SNAPSHOT 结尾的 tag 被推送时引发任务,可以加上 '!v*-SNAPSHOT' 这个正则表达式进行过滤。

因为我们的 workflow 需要上传附件,所有需要打开 Github 上 Workflow permissions 的写权限。在页面 https://github.com/weiwen99/static-snap/settings/actions 上的 Workflow permissions 一节中,把 Read and write permissions 选中保存即可。

↑ 设置 Github workflow 权限

万事俱备!我们来发布第一个版本 v0.0.1:

↑ 本地发布第一个版本

我们可以通过日志很清楚看到,这个脚本执行的时候按顺序做了以下步骤:

  1. 代码格式化: mill __.reformat
  2. 代码清理: mill clean
  3. 单元测试: mill __.test
  4. 从项目(文件 version) 中读出当前版本号 0.0.1-SNAPSHOT
  5. 打了一个 git tag v0.0.1
  6. git push tag v0.0.1 到 Github
  7. 生成下一个版本号 0.0.2-SNAPSHOT
  8. 打了一个 git tag v0.0.2-SNAPSHOT
  9. git push tag v0.0.2-SNAPSHOT 到 Github

因为刚才运行的脚本先后 push git tag v0.0.1v0.0.2-SNAPSHOT 到 Github, 所以 Github 上会触发两次 Release Draft 构建。我们可以在页面 https://github.com/weiwen99/static-snap/actions 上浏览所有的构建任务。

↑ Github actions

可以看到有 4 个 workflow 被触发,其中两个是 Scala CI, 两个是我们刚配置好的 Release.

release v0.0.1 的详情如下:

↑ Github action detail

Workflow 中每一步的执行细节都可以展开来查看详细日志输出。我的的 release v0.0.1 一共耗时 3m 38s, 其中大部分时间花在了 Build GraalVM Native Image 这一步上了。

等这些 workflow 被成功执行完成以后,我们可以在 Release 页面 https://github.com/weiwen99/static-snap/releases 查看当前所有的发布:

↑ Github releases

可以看到我们一共有两个 Release Draft v0.0.10.0.2-SNAPSHOT, 因为它们都还是 Draft 状态,对外(非项目维护者)不可见。检查它们的 Assets, 下载回来测试运行是没问题的。其中特别值得注意的是 s5-graalvm-native-image-v0.0.1.tar.bz2 这个包,下载下来解开,得到的 s5 就是个实用的可执行文 件, 直接复制到系统的 PATH 路径中就算安装完成。

接下来我们需要正式发布 v0.0.1, 直接在 Github 页面操作就行:

↑ Github release v0.0.1

现在我本人访问 https://github.com/weiwen99/static-snap/releases 是这样的:

↑ Github releases viewed by self

而普通用户看到的界面是这样的:

↑ Github releases viewed by others

小结

本节我们构建了在 Github 上打包发布的半自动化工作流,发现 Github 对 mill 和 GraalVM Native Image 构建 Scala 应用程序的支持本身还是挺完善的。

至此,我们基本上完成了一个使用 Scala 构建的开源项目的技术工作。

本节所有的阶段性代码,都可以在这个 Github tag 链接上找到:https://github.com/weiwen99/static-snap/tree/v0.0.1 .

(本系列完。)