Vapor 框架学习记录(2)SwiftHtml

SwiftHtml

An awesome Swift HTML DSL library using result builders.

首先我们来认识一下SwiftHtml,因为之后会使用到SwiftHtml 这个库去搭建html,所以这里先介绍一个这个库的功能和使用。下面先看一段官方的demo代码:

import SwiftHtml 

let doc = Document(.html) {
    Html {
        Head {
            Title("Hello Swift HTML DSL")
            
            Meta().charset("utf-8")
            Meta().name(.viewport).content("width=device-width, initial-scale=1")

            Link(rel: .stylesheet).href("./css/style.css")
        }
        Body {
            Main {
                Div {
                    Section {
                        Img(src: "./images/swift.png", alt: "Swift Logo")
                            .title("Picture of the Swift Logo")
                        H1("Lorem ipsum")
                            .class("red")
                        P("Lorem ipsum dolor sit amet, consectetur adipiscing elit.")
                            .class(["green", "blue"])
                            .spellcheck(false)
                    }

                    A("Download SwiftHtml now!")
                        .href("https://github.com/binarybirds/swift-html/")
                        .target(.blank)
                        .download()
                        
                    Abbr("WTFPL")
                        .title("Do What The Fuck You Want To Public License")
                }
            }
            .class("container")

            Script().src("./js/main.js").async()
        }
    }
}

let html = DocumentRenderer(minify: false, indent: 2).render(doc)
print(html)

如果使用过或熟悉html 的话,应该能看明白上面的demo,其实就是通过swift 的 DSL 代码来代替直接写html文件,好处就是写起来比较省事,不用写开始和结束标签,同时会因为使用了DSL,编译器可以在构建时对所有内容进行类型检查,这样就可以 100% 确保我们的 HTML 代码不会出现语法问题。

添加SwiftHtml依赖

跟上一节一样,我们通过Swift package manager添加SwiftHtml库。

  • 打开Package.swift文件, 在dependencies添加:
.package(url: "https://github.com/binarybirds/swift-html", from: "1.2.0")
  • App target下添加:
.product(name: "SwiftHtml", package: "swift-html"),
.product(name: "SwiftSvg", package: "swift-html")

添加完后就可以cmd + s 保存,等待Xcode 拉取完成就可以了。

但是在这里我遇到了Xcode 报错: SwiftHtml The repository could not be found.的问题,看起来是访问SwiftHtml 的git仓库失败,但是我在浏览器是能打开SwiftHtml的github 网页的。查了一下,发现是Xcode 设置不了代理,就算是设置全局代理也没有效果。这也解答了我心里的一个疑虑,就是Xcode自动更新 Swift page Manager 会比通过命令行(命令行有设置代理)更新 Swift page Manager 慢很多。

要解决这个问题我找到有2个方法:

方法1

去到 DerivedData 对应项目的文件夹里面删除对应项目文件,然后回到Xcode 重新更新SPM

可以设置git的全局代理加速拉取过程。

$ git config --global http.proxy http://127.0.0.1:7890 
$ git config --global https.proxy http://127.0.0.1:7890 

使用完记得取消全局代理,防止拉取私有库出错:

$ git config --global --unset http.proxy
$ git config --global --unset https.proxy

不过我尝试配置感觉拉取速度没提升。。。

方法2

首先要配置好终端代理,然后终端cd 到项目目录,最后输入命令更新 swift package

$ swift package resolve

等待处理完后,可以看到项目目录.build已经有对应的依赖了。

.build.png

然后去到Xcode项目的DerivedData文件夹(做iOS开发的同学应该很熟悉这个文件夹),找到对应的当前项目,进入项目可以看到一个SourcePackages文件夹,这时候将之前.build目录下的文件全部copy到这个文件下面。这样就手动地更新了Xcode管理的 swift package仓库了。
SourcePackages.png

这个替换我理解的原理就是,我们通过终端输入的swift package resolve处理的依赖仓库会在.build目录里面,但是Xcode管理的依赖在DerivedDataSourcePackages里面。所以Xcode拉取失败的话,可以通过手动方式替换。

如果上面两个方案都一直拉取失败的话,可以 clean一下项目,移除对应的DerivedData文件让Xcode 重新拉取依赖

使用

install完成后,我们开始敲代码吧。切换到 configure.swift 文件,在vapor框架中, configure.swift文件负责注册诸如路由、数据库、模型、设置中间件等。

/// FILE: Sources/App/configure.swift import Vapor
// configures your application
public func configure(_ app: Application) throws {
    // uncomment to serve files from /Public folder
    app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
    
    app.routes.get("lqbk") { req -> Response in
        let doc = Document(.html) {
            Html {
                Head {
                    Title("Hello, World!")
                }
                Body {
                    H1("i am here!")
                    Text("start with vapor")
                }
            }
        }
        
        let body = DocumentRenderer(minify: false, indent: 4).render(doc)
        return Response(status: .ok, headers: ["content-type": "text/html"], body: .init(string: body))
    }

    // register routes
    try routes(app)
}

上面的代码主要是向应用路由注册了lqbk这个路径,这个路径的响应是通过生成Document类描述的HTML 页面,然后DocumentRenderer转化成String类型的Html字符串。最后通过Response对象返回出去。

可以看到Response对象需要这里需要3个参数分别是status headers body,分别是http协议的状态码,请求头和请求体。这里可以根据对应情况设置。

现在我们可以Run起来项目,然后在浏览器输入http://127.0.0.1:8080/lqbk 可以看到浏览器出现下面的样式:

lqbk.png

Address already in use 报错

Xcode停止运行项目时,会自动关闭http服务器, 但是有时候会失败(我就遇到过一两次),关闭失败的话,再次运行项目出现 Thread 1: Fatal error: Error raised at top level: bind(descriptor:ptr:bytes:): Address already in use (errno: 48) 这个报错的时候,说明之前的服务器未关闭,还占有着默认的地址。这时候可以手动在终端输入:

lsof -i :8080 -sTCP:LISTEN |awk 'NR > 1 {print $2}'|xargs kill -15

这个命令的意思是找到当前使用8080端口的应用然后关闭掉。
当然每次出错都要输入这个命令就太麻烦了,所以我们可以在Xcode通过Edit Schems添加到RunPre actions中,这样每次Run项目的时候就会自动地提前关闭当前8080端口的应用一次。

TemplateRenderer

接下来我们定义一个TemplateRenderer类将html模版转换的流程抽象出来,方便使用。

/// FILE: Sources/App/Template/TemplateRenderer.swift
import Vapor
import SwiftSvg
import SwiftSgml

public protocol TemplateRepresentable {
    
    @TagBuilder
    func render(_ req: Request) -> Tag
}

public struct TemplateRenderer {
    var req: Request
    
    init(_ req: Request) {
        self.req = req
    }
    
    public func renderHtml(_ template: TemplateRepresentable, minify: Bool = false, indent: Int = 4) -> Response {
        let doc = Document(.html) {
            template.render(req)
        }
        let body = DocumentRenderer(minify: minify, indent: indent).render(doc)
        return Response(status: .ok, headers: ["content-type": "text/html"], body: .init(string: body))
    }
}

这样模版的渲染流程就隐藏在TemplateRenderer中了,只需要实现 TemplateRepresentable协议关注Tag的拼装。TemplateRenderer 有一个内部的 init 方法,我们不应该直接创建这个结构体,我们可以将扩展 Request 对象以获取渲染器的实例。

extension Request {
    var templates: TemplateRenderer { .init(self) }
}

现在,如果我们回到configuration.swift文件,我们可以创建一个新模板并使用 req.templates 渲染它。

struct MyTemplate: TemplateRepresentable {
    let title: String
    let text1: String
    let text2: String
    
    func render(_ req: Request) -> Tag {
        Html {
            Head {
                Title(title)
            }
            Body {
                H1(text1)
                Text(text2)
            }
        }
    }
}

public func configure(_ app: Application) throws {
    // ...
    app.routes.get("lqbk") { req -> Response in
        return req.templates.renderHtml(MyTemplate(title: "Hello, World!", text1: "i am here!", text2: "start with vapor"))
    }
    // ...
}

可以看到现在我们使用生成MyTemplate实例去代替之前的代码,这样可以达到复用模版的效果。

模板和上下文

到这里,我们已经学会使用 template renderer去渲染模版了,现在让我们创建一个可复用的index模版,这个模版可以作为之后我们使用的到web 页面的基础模版。由于我们将使用模块化方法来管理所有内容,因此我们应该创建一个新的 Modules 文件夹和一个 Web 子目录。
我们将把所有模板放在 Templates/Html 目录中,每个template都有一个关联的Contexts对象,我们将把它们存储在 Templates/Contexts 目录中。


/// FILE: Sources/App/Modules/Web/Templates/Html/WebIndexTemplate.swift
public struct WebIndexTemplate: TemplateRepresentable {
    
    public var context: WebIndexContext
    
    public init(_ context: WebIndexContext) {
        self.context = context
    }
    
    @TagBuilder
    public func render(_ req: Request) -> Tag {
        Html {
            Head {
                Meta()
                    .charset("utf-8")
                Meta()
                    .name(.viewport)
                    .content("width=device-width, initial-scale=1")
                Link(rel: .shortcutIcon)
                    .href("/image/favicon.ico")
                    .type("image/x-icon")
                Link(rel: .stylesheet)
                    .href("https://cdn.jsdelivr.net/gh/feathercms/feather-core@1.0.0-beta.44/feather.min.css")
                Link(rel: .stylesheet)
                    .href("/css/web.css")
                
                Title(context.title)
            }
            Body {
                Main {
                    Section {
                        H1(context.message)
                    }
                    .class("wrapper")
                }
            }
        }
        .lang("en-US")
    }
}


/// FILE: Sources/App/Modules/Web/Templates/Contexts/WebIndexContext.swift
public struct WebIndexContext {
    
    public let title: String
    public let message: String
    
}

我们实现了一个新的模版WebIndexTemplate, 作为index 的基础模版,然后通过WebIndexContext对象去驱动内容。在WebIndexTemplate使用到外部的样式表Feather CSS来设置基础组件。关于feather css 可以看这个文档

可以看到设置.href("/image/favicon.ico"), 其中/image/favicon.icoPublic 文件夹里面对应路径的文件,没有的话我们可以创建一个 Public 文件夹

最后一步,回到我们的 router.swift 文件, 使用 req.templates.renderHtml去渲染我们的新模版试试吧。

// FILE: Sources/App/routes.swift
import Vapor

func routes(_ app: Application) throws {
    app.routes.get { req -> Response in
        req.templates.renderHtml(WebIndexTemplate.init(WebIndexContext(title: "lqbk.space", message: "Hi there, welcome to my page!")))
    }
}

模版层次结构

因为我们打算建立一个多页面网站,拆分模板将是必不可少的。 我们可以创建可重用的部分,你可以稍后在其他模板文件中共享和渲染它们。 在下面的示例中,我们将创建三个单独的页面。

首先,我们必须更新索引模板,因为它将被整个网站重用。


/// FILE: Sources/App/Modules/Web/Templates/Html/WebIndexTemplate.swift
extension Svg {
    static func menuIcon() -> Svg {
        Svg {
            Line(x1: 3, y1: 12, x2: 21, y2: 12)
            Line(x1: 3, y1: 6, x2: 21, y2: 6)
            Line(x1: 3, y1: 18, x2: 21, y2: 18)
        }
        .width(24)
        .height(24)
        .viewBox(minX: 0, minY: 0, width: 24, height: 24)
        .fill("none")
        .stroke("currentColor")
        .strokeWidth(2)
        .strokeLinecap("round")
        .strokeLinejoin("round")
    }
}
    
public struct WebIndexTemplate: TemplateRepresentable {
    
    public var context: WebIndexContext
    var body: Tag
    
    public init(_ context: WebIndexContext, @TagBuilder _ builder: () -> Tag) {
        self.context = context
        self.body = builder()
    }
    
    @TagBuilder
    public func render(_ req: Request) -> Tag {
        Html {
            Head {
                Meta()
                    .charset("utf-8")
                Meta()
                    .name(.viewport)
                    .content("width=device-width, initial-scale=1")
                Link(rel: .shortcutIcon)
                    .href("/image/favicon.ico")
                    .type("image/x-icon")
                Link(rel: .stylesheet)
                    .href("https://cdn.jsdelivr.net/gh/feathercms/feather-core@1.0.0-beta.44/feather.min.css")
                Link(rel: .stylesheet)
                    .href("/css/web.css")
                
                Title(context.title)
            }
            Body {
                Header {
                    Div {
                        A {
                            Img(src: "/img/logo.png", alt: "Logo")
                        }
                        .id("site-logo")
                        .href("/")
                        
                        Nav {
                            Input()
                                .type(.checkbox)
                                .id("primary-menu-button")
                                .name("menu-button")
                                .class("menu-button")
                            
                            Label {
                                Svg.menuIcon()
                            }.for("primary-menu-button")
                            
                            Div {
                                A("Home")
                                    .href("/")
                                    .class("selected", req.url.path == "/")
                                A("Blog")
                                    .href("/blog/")
                                    .class("selected", req.url.path == "/blog/")
                                A("About")
                                    .href("#")
                                    .onClick("javascript:about();")
                            }
                                .class("menu-items")
                        }
                        .id("primary-menu")
                    }
                    .id("navigation")
                }
                
                Main {
                    body
                }
                
                Footer {
                    Section {
                        P {
                            Text("This site is powered by ")
                            A("Swift")
                                .href("https://swift.org")
                                .target(.blank)
                            Text(" & ")
                            A("Vapor")
                                .href("https://vapor.codes")
                                .target(.blank)
                            Text(".")
                        }
                        P("lqbk.space © 2020-2022")
                    }
                }
                
                Script()
                    .type(.javascript)
                    .src("/js/web.js")
                
            }
        }
        .lang("en-US")
    }
}

这里的一个重要的变化是可以为模板文件传递的一个builder 参数, 是一个 @TagBuilder 的构建器,这个构建器可以为我们创建 body ,这样就能和index 模版组合使用了。

现在我们的WebIndexContext 也不需要message参数了,我们可以删除它。

/// FILE: Sources/App/Modules/Web/Templates/Contexts/WebIndexContext.swift
public struct WebIndexContext {
    
    public let title: String
    
}

作为 index 模板的最后一个部分,我们将从 Public/js 目录中加入一些基本的 javascript 文件。现在我们可以提前创建对应的文件夹和一个空的 web.js 文件,之后就会使用到。

HomePage

主页会比较简单,首先我们需要一个 WebHomeContext struct 来表示想要呈现的数据。

public struct WebHomeContext {
    public let title: String
    public let message: String
}

接下来我们搭建主页的模版 WebHomeTemplate

/// FILE: Sources/App/Modules/Web/Templates/Html/WebHomeTemplate.swift
import Vapor
import SwiftHtml
import SwiftSgml

public struct WebHomeTemplate: TemplateRepresentable {
    
    var context: WebHomeContext
    
    public init(_ context: WebHomeContext) {
        self.context = context
    }
    
    @TagBuilder
    public func render(_ req: Request) -> Tag {
        WebIndexTemplate(.init(title: context.title)) {
            Div {
                Section {
                    H1(context.title)
                    P(context.message)
                }
                .class("lead")
            }
            .id("home")
            .class("container")
        }
        .render(req)
    }   
}

WebHomeTemplaterender 方法, 我们组合了WebIndexTemplate模版一起使用。类似这样,我们能将重复使用的模版代码抽出一个独立的模版,然后组合使用它们。

在之前,我们将很多代码直接放在了 configure or routes 文件里面,这不是一个好的管理代码的方式,所以现在我们应该分模版得管理代码,我们将渲染模版的功能放在WebFrontendController对象中。


/// FILE: Sources/App/Modules/Web/Controllers/WebFrontendController.swift
import Vapor

struct WebFrontendController {
    func homePage(req: Request) throws -> Response {
        req.templates.renderHtml(WebHomeTemplate(.init(title: "HomePage", message: "你好,欢迎来到我的主页")))
    }
}

接下来我们需要一个专门的Router去管理页面路由。

/// FILE: Sources/App/Modules/Web/WebRouter.swift

import Vapor

struct WebRouter: RouteCollection {
    
    let frontendController = WebFrontendController()
    
    func boot(routes: RoutesBuilder) throws {
        routes.get(use: frontendController.homeView)
    }
}

RoutesBuilder 除了使用get方法,同时也支持其他HTTP 方法。

现在我们可以在 configure 中配置 WebRouter, 通过 app.routes 去作为 RoutesBuilder

/// FILE: Sources/App/configure.swift

import Vapor
public func configure(_ app: Application) throws { 
     //...

    /// setup web routes
    let router = WebRouter()
    try router.boot(routes: app.routes)
}

运行程序,你可以看到现在由两个模版组合的效果了。

渲染子模版

之前提到了可以在模版中渲染模版,所以我们现在来做这个事情。因为之后会用到很多链接,所以我们需要一个 WebLinkContext 携带 labelurl

/// FILE: Sources/App/Modules/Web/Templates/Contexts/WebLinkContext.swift
public struct WebLinkContext {
    public let label: String
    public let url: String
}

使用相应的 WebLinkTemplate 我们可以渲染我们的 WebLinkContext 对象,当然我们可以添加更多属性,例如样式类或布尔值来确定链接是否为空白链接,但为了简单起见,我们先从 labelurl开始。 后续有对应的需求,也可以进行扩展。

/// FILE: Sources/App/Modules/Web/Templates/Html/WebLinkTemplate.swift

import Vapor
import SwiftHtml
import SwiftSgml

public struct WebLinkTemplate: TemplateRepresentable {
    var context: WebLinkContext

    init(_ context: WebLinkContext) {
        self.context = context
    }
    
    @TagBuilder
    public func render(_ req: Request) -> Tag {
        A(context.label)
            .href(context.url)
    }
}

我们还应该改变 WebHomeContext 结构,这样我们就可以利用新创建的WebLinkContext。 我们还将添加一个新的图标属性,以使我们的主页更漂亮一点。

/// FILE: Sources/App/Modules/Web/Templates/Contexts/WebHomeContext.swift
public struct WebHomeContext {
    public let icon: String
    public let title: String
    public let message: String
    public let paragraphs: [String]
    public let link: WebLinkContext
}

相应地我们也需要对WebHomeTemplate做对应的改进。

public struct WebHomeTemplate: TemplateRepresentable {
    
    var context: WebHomeContext
    
    public init(_ context: WebHomeContext) {
        self.context = context
    }
    
    @TagBuilder
    public func render(_ req: Request) -> Tag {
        WebIndexTemplate(.init(title: context.title)) {
            Div {
                Section {
                    P(context.icon)
                    H1(context.title)
                    P(context.message)
                }
                .class("lead")
                
                for paragraph in context.paragraphs {
                    P(paragraph)
                }
                
                WebLinkTemplate(context.link).render(req)
            }
            .id("home")
            .class("container")
        }
        .render(req)
    }
    
}

如你所见,我们可以将 WebLinkTemplatelink context一起使用,并使用模板上的 render 方法返回一个标签。 返回的 Tag 对象就像我们可以手动创建的任何其他标签一样,因此将模板嵌入到另一个模板中是安全的。
请注意,我们仍然可以在模板文件中使用常规的 for 循环(也可以使用 if-else)。 这很棒,因为我们可以遍历段落值并使用 P Tag呈现它们。

struct WebFrontendController {
    func homeView(req: Request) throws -> Response {
        req.templates.renderHtml(WebHomeTemplate(.init(icon: "👋 ",
                                                       title: "i am lqbk",
                                                       message: "你好,欢迎来到我的主页",
                                                       paragraphs: ["我在学习Vapor框架",
                                                                    "希望能搭建一个自己喜欢的Blog",
                                                                    "提升自己的技能"],
                                                       link: WebLinkContext(label: "Read my blog →", url: "/blog/"))))
    }
}

最后回到WebFrontendController, 对之前的修改做内容得填充。

Blog List

由于我们正在使用模块化架构构建应用程序,因此我们不能简单地将与博客相关的内容放入 Web 模块中。 Web 模块在我们的案例中有些特殊,因为它为我们提供了呈现网站的主要元素。 它还包含 Index 模板、Web 样式表和 javascript 文件。

我们将创建一个名为 Blog 的新模块。 每个模块都将遵循我们之前创建的相同模式。 这意味着我们将拥有专用的routercontroller。 在开始之前,我们将创建一个 BlogPost struct来表示我们的文章。 在 Sources/App/Modules/Blog 目录下新建一个 Swift 文件。


/// FILE: Sources/App/Modules/Blog/BlogPost.swift

import Foundation

public struct BlogPost: Codable {
    public let title: String //标题
    public let slug: String //标识符
    public let image: String //突破路径
    public let excerpt: String //摘要
    public let date: Date// 日期
    public let category: String? //文章分类
    public let content: String //文章内容
}

现在我们定义了 BlogPost 的内容,不过因为当前我们还没有数据层去获取存放这些数据,所以当前我们暂时用随机生成的数据去调试页面。 这块逻辑交给 BlogFrontendController处理。

/// FILE: Sources/App/Modules/Blog/Controllers/BlogFrontendController.swift

import Vapor

struct BlogFrontendController {
    
    var post: [BlogPost] = {
        stride(from: 1, to: 9, by: 1).map { index in
            BlogPost(title: "Sample post #\(index)",
                     slug: "sample-post-\(index)",
                     image: "/img/posts/\(String(format: "%02d", index + 1)).jpg",
                     excerpt: "Lorem ipsum",
                     date: Date().addingTimeInterval(-Double.random(in: 0...(86400 * 60))),
                     category: Bool.random() ? "Sample category" : nil,
                     content: "Lorem ipsum dolor sit amet.")
        }.sorted {
            $0.date > $1.date
        }
    }()
    
}

BlogFrontendController 负责处理所有在网络上公开的与博客相关的路由。 这就是为什么它被称为前端控制器。 稍后我们将使用相同的逻辑来创建其他类型的内容通道,例如admin控制器和 API 控制器。
现在对于我们的博客文章页面,我们将需要一个新的 BlogPostsContext 结构,我们可以使用它来呈现页面。

/// FILE: Sources/App/Modules/Blog/Templates/Contexts/BlogPostsContext.swift

struct BlogPostsContext {
    let icon: String
    let title: String
    let message: String
    let posts: [BlogPost]
}

然后一样的需要一个BlogPostsTemplate模版来渲染数据

/// FILE: Sources/App/Modules/Blog/Templates/Html/BlogPostsTemplate.swift

import Vapor
import SwiftHtml
import SwiftSgml


struct BlogPostsTemplate: TemplateRepresentable {
    
    var context: BlogPostsContext
    
    init(_ context: BlogPostsContext) {
        self.context = context
    }
    
    @TagBuilder
    func render(_ req: Request) -> Tag {
        WebIndexTemplate(.init(title: context.title)) {
            Div {
                Section {
                    P(context.icon)
                    H1(context.title)
                    P(context.message)
                }
                .class("lead")
                
                Div {
                    for post in context.posts {
                        Article {
                            A {
                                Img(src: post.image, alt: post.title)
                                H2(post.title)
                                P(post.excerpt)
                            }
                            .href("/\(post.slug)/")
                        }
                    }
                }
                .class("grid-221")
            }
            .id("blog")
        }
        .render(req)
    }
}

使用第三方 Feather CSS 框架的好处是我们可以直接使用大部分组件。 例如,我们的列表将是响应式的,因为我们使用的是 grid-221 类。
这意味着网格将在台式机和平板设备上使用 2 列布局,而在移动设备上将使用单列。 当我们在列表中显示帖子时,我们必须调整帖子的标准标题元素,我们将在web.css 文件中添加一个小改动。

/* FILE: Public/css/web.css */
#blog h2 {
    margin: 0.5rem 0;
}

现在我们回到 BlogFrontendController 去处理请求后的这个模版的渲染



struct BlogFrontendController {
    
    //...
    
    func blogView(req: Request) throws -> Response {
        
        let ctx = BlogPostsContext(icon: "🔥 ", title: "Blog", message: "Hot news and stories about everything.", posts: posts)
        
        return req.templates.renderHtml(BlogPostsTemplate(ctx))
    }
    
    
}

同样的Blog 模块也应该有专门的路由类管理页面,我们在对应模块下创建BlogRouter.swift 文件,处理对应的路由跳转。


/// FILE: Sources/App/configure.swift
import Vapor

struct BlogRouter: RouteCollection {
    
    let controller = BlogFrontendController()
    
    func boot(routes: RoutesBuilder) throws {
        routes.get("blog", use: controller.blogView)
    }
    
}

最后一步我们回到configure 文件,将BlogRouter添加到App中。

// configures your application
public func configure(_ app: Application) throws {
    //...

    //setup web routes
    let routers: [RouteCollection] = [
        WebRouter(),
        BlogRouter()
    ]
    for router in routers {
        try router.boot(routes: app.routes)
    }
}

最后运行应用,点击 Blog tab 查看效果吧。

blog list

如果出现 Pubilc有对应图片,但是图片展示不出来的问题,你需要配置一下 working directory
可以参考:https://theswiftdev.com/custom-working-directory-in-xcode/

Blog 详情页

列表页已经搭建完成了,现在我们将进一步完成详情页的搭建。我们将首先在 Templates 文件夹中创建一个新的 BlogPostTemplate 文件 和 BlogPostContext文件。


/// FILE: Sources/App/Modules/Blog/Templates/Html/BlogPostTemplate.swift
import Vapor
import SwiftHtml
import SwiftSgml

struct BlogPostTemplate: TemplateRepresentable {
    
    var context: BlogPostContext
    
    init(_ context: BlogPostContext) {
        self.context = context
    }
    
    var dateFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateStyle = .long
        formatter.timeStyle = .short
        return formatter
    }()
    
    @TagBuilder
    func render(_ req: Request) -> Tag {
        WebIndexTemplate(.init(title: context.post.title)) {
            Div {
                Section {
                    P(dateFormatter.string(from: context.post.date))
                    H1(context.post.title)
                    P(context.post.excerpt)
                }
                .class(["lead", "container"])
                
                Img(src: context.post.image, alt: context.post.title)
                
                Article {
                    Text(context.post.content)
                }
                .class("container")
            }
            .id("post")
        }
        .render(req)
    }
    
}
/// FILE: Sources/App/Modules/Blog/Templates/Contexts/BlogPostContext.swift
struct BlogPostContext {
    let post: BlogPost
}

接着我们回到BlogFrontendController处理详情页跳转的逻辑,我们通过路径中的 slug标识去找到对应的BlogPost数据,如果没有找到就重定向回主页。


struct BlogFrontendController {
    
    //...

    func postView(req: Request) throws -> Response {
        let slug = req.url.path.trimmingCharacters(in: .init(charactersIn: "/"))
        guard let post = posts.first(where: { $0.slug == slug }) else {
            return req.redirect(to: "/")
        }
        return req.templates.renderHtml(BlogPostTemplate(.init(post: post)))
    }
}

现在我们剩下的问题就是: 如何修改路由通过url 的path去匹配对应的控制器。

因为每个详情页的路径应该是不同的,所以这里我们可以使用 .anything 匹配。

import Vapor

struct BlogRouter: RouteCollection {
    
    let controller = BlogFrontendController()
    
    func boot(routes: RoutesBuilder) throws {
        routes.get("blog", use: controller.blogView)
        routes.get(.anything, use: controller.postView)
    }
}

现在你可以运行应用,可以点击文章详情去查看内容了。

自定义中间件

现在我们可以使用同一路径的两个版本(例如 /blog/ vs /blog)访问每个 URL,我们将实现一个中间件去统一这2个url 的表现。

/// FILE: Sources/App/Middlewares/ExtendPathMiddleware.swift
import Vapor

struct ExtendPathMiddleware: AsyncMiddleware {
    
    func respond(to request: Request, chainingTo next: AsyncResponder) async throws -> Response {
        if !request.url.path.hasSuffix("/"), !request.url.path.contains(".") {
            return request.redirect(to: request.url.path + "/", type: .permanent)
        }
        return try await next.respond(to: request)
    }
    
}

然后在configure文件配置上这个中间件。

/// FILE: Sources/App/configure.swift

// configures your application
public func configure(_ app: Application) throws {
    // uncomment to serve files from /Public folder
    app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
    
    /// extend paths to always contain a trailing slash
    app.middleware.use(ExtendPathMiddleware())


    //...
}

这样输入没有带"/"后缀的的url时,中间件会自动带上"/"后缀。类似这样我们通过使用中间件做一些串行的处理。因为 AsyncAwait 需要macOS 12才支持,所以需要改一下Package.swiftplatforms.macOS(.v12)

最后一个菜单项呢? 让我们使用在教程开始时创建的那个空的 web.js 文件。 我们将简单地显示一个alert,但当然您可以使用此模板通过一些精美的动画来为网站增添趣味。

/* FILE: Public/js/web.js */
function about() { 
    alert("myPage\n\nversion 1.0.0");
}

总结

这次我们学习到了通过 SwiftHtml 去搭建 Web 页面的模版,模块化得管理代码,了解到路由的基础知识。下一步,我们会开始使用Fluent 实现我们的数据层。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,294评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,493评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,790评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,595评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,718评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,906评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,053评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,797评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,250评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,570评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,711评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,388评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,018评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,796评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,023评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,461评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,595评论 2 350

推荐阅读更多精彩内容