Vapor 框架學(xué)習(xí)記錄(2)SwiftHtml

SwiftHtml

An awesome Swift HTML DSL library using result builders.

首先我們來認(rèn)識(shí)一下SwiftHtml庐氮,因?yàn)橹髸?huì)使用到SwiftHtml 這個(gè)庫去搭建html,所以這里先介紹一個(gè)這個(gè)庫的功能和使用业簿。下面先看一段官方的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 的話,應(yīng)該能看明白上面的demo批狱,其實(shí)就是通過swift 的 DSL 代碼來代替直接寫html文件裸准,好處就是寫起來比較省事,不用寫開始和結(jié)束標(biāo)簽赔硫,同時(shí)會(huì)因?yàn)槭褂昧薉SL炒俱,編譯器可以在構(gòu)建時(shí)對(duì)所有內(nèi)容進(jìn)行類型檢查,這樣就可以 100% 確保我們的 HTML 代碼不會(huì)出現(xiàn)語法問題爪膊。

添加SwiftHtml依賴

跟上一節(jié)一樣权悟,我們通過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 報(bào)錯(cuò): SwiftHtml The repository could not be found.的問題,看起來是訪問SwiftHtml 的git倉庫失敗耘成,但是我在瀏覽器是能打開SwiftHtml的github 網(wǎng)頁的拇派。查了一下,發(fā)現(xiàn)是Xcode 設(shè)置不了代理凿跳,就算是設(shè)置全局代理也沒有效果件豌。這也解答了我心里的一個(gè)疑慮,就是Xcode自動(dòng)更新 Swift page Manager 會(huì)比通過命令行(命令行有設(shè)置代理)更新 Swift page Manager 慢很多控嗜。

要解決這個(gè)問題我找到有2個(gè)方法:

方法1

去到 DerivedData 對(duì)應(yīng)項(xiàng)目的文件夾里面刪除對(duì)應(yīng)項(xiàng)目文件茧彤,然后回到Xcode 重新更新SPM

可以設(shè)置git的全局代理加速拉取過程。

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

使用完記得取消全局代理疆栏,防止拉取私有庫出錯(cuò):

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

不過我嘗試配置感覺拉取速度沒提升曾掂。。壁顶。

方法2

首先要配置好終端代理珠洗,然后終端cd 到項(xiàng)目目錄,最后輸入命令更新 swift package

$ swift package resolve

等待處理完后若专,可以看到項(xiàng)目目錄.build已經(jīng)有對(duì)應(yīng)的依賴了许蓖。

.build.png

然后去到Xcode項(xiàng)目的DerivedData文件夾(做iOS開發(fā)的同學(xué)應(yīng)該很熟悉這個(gè)文件夾),找到對(duì)應(yīng)的當(dāng)前項(xiàng)目调衰,進(jìn)入項(xiàng)目可以看到一個(gè)SourcePackages文件夾膊爪,這時(shí)候?qū)⒅?code>.build目錄下的文件全部copy到這個(gè)文件下面。這樣就手動(dòng)地更新了Xcode管理的 swift package倉庫了嚎莉。
SourcePackages.png

這個(gè)替換我理解的原理就是米酬,我們通過終端輸入的swift package resolve處理的依賴倉庫會(huì)在.build目錄里面,但是Xcode管理的依賴在DerivedDataSourcePackages里面趋箩。所以Xcode拉取失敗的話赃额,可以通過手動(dòng)方式替換加派。

如果上面兩個(gè)方案都一直拉取失敗的話,可以 clean一下項(xiàng)目跳芳,移除對(duì)應(yīng)的DerivedData文件讓Xcode 重新拉取依賴

使用

install完成后芍锦,我們開始敲代碼吧。切換到 configure.swift 文件筛严,在vapor框架中醉旦, configure.swift文件負(fù)責(zé)注冊(cè)諸如路由饶米、數(shù)據(jù)庫桨啃、模型、設(shè)置中間件等檬输。

/// 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)
}

上面的代碼主要是向應(yīng)用路由注冊(cè)了lqbk這個(gè)路徑照瘾,這個(gè)路徑的響應(yīng)是通過生成Document類描述的HTML 頁面,然后DocumentRenderer轉(zhuǎn)化成String類型的Html字符串丧慈。最后通過Response對(duì)象返回出去析命。

可以看到Response對(duì)象需要這里需要3個(gè)參數(shù)分別是status headers body,分別是http協(xié)議的狀態(tài)碼,請(qǐng)求頭和請(qǐng)求體逃默。這里可以根據(jù)對(duì)應(yīng)情況設(shè)置鹃愤。

現(xiàn)在我們可以Run起來項(xiàng)目,然后在瀏覽器輸入http://127.0.0.1:8080/lqbk 可以看到瀏覽器出現(xiàn)下面的樣式:

lqbk.png

Address already in use 報(bào)錯(cuò)

Xcode停止運(yùn)行項(xiàng)目時(shí)完域,會(huì)自動(dòng)關(guān)閉http服務(wù)器, 但是有時(shí)候會(huì)失斎硗隆(我就遇到過一兩次),關(guān)閉失敗的話吟税,再次運(yùn)行項(xiàng)目出現(xiàn) Thread 1: Fatal error: Error raised at top level: bind(descriptor:ptr:bytes:): Address already in use (errno: 48) 這個(gè)報(bào)錯(cuò)的時(shí)候凹耙,說明之前的服務(wù)器未關(guān)閉,還占有著默認(rèn)的地址肠仪。這時(shí)候可以手動(dòng)在終端輸入:

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

這個(gè)命令的意思是找到當(dāng)前使用8080端口的應(yīng)用然后關(guān)閉掉肖抱。
當(dāng)然每次出錯(cuò)都要輸入這個(gè)命令就太麻煩了,所以我們可以在Xcode通過Edit Schems添加到RunPre actions中异旧,這樣每次Run項(xiàng)目的時(shí)候就會(huì)自動(dòng)地提前關(guān)閉當(dāng)前8080端口的應(yīng)用一次意述。

TemplateRenderer

接下來我們定義一個(gè)TemplateRenderer類將html模版轉(zhuǎn)換的流程抽象出來,方便使用吮蛹。

/// 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中了欲险,只需要實(shí)現(xiàn) TemplateRepresentable協(xié)議關(guān)注Tag的拼裝。TemplateRenderer 有一個(gè)內(nèi)部的 init 方法匹涮,我們不應(yīng)該直接創(chuàng)建這個(gè)結(jié)構(gòu)體天试,我們可以將擴(kuò)展 Request 對(duì)象以獲取渲染器的實(shí)例。

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

現(xiàn)在然低,如果我們回到configuration.swift文件喜每,我們可以創(chuàng)建一個(gè)新模板并使用 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"))
    }
    // ...
}

可以看到現(xiàn)在我們使用生成MyTemplate實(shí)例去代替之前的代碼,這樣可以達(dá)到復(fù)用模版的效果带兜。

模板和上下文

到這里枫笛,我們已經(jīng)學(xué)會(huì)使用 template renderer去渲染模版了,現(xiàn)在讓我們創(chuàng)建一個(gè)可復(fù)用的index模版刚照,這個(gè)模版可以作為之后我們使用的到web 頁面的基礎(chǔ)模版刑巧。由于我們將使用模塊化方法來管理所有內(nèi)容,因此我們應(yīng)該創(chuàng)建一個(gè)新的 Modules 文件夾和一個(gè) Web 子目錄无畔。
我們將把所有模板放在 Templates/Html 目錄中啊楚,每個(gè)template都有一個(gè)關(guān)聯(lián)的Contexts對(duì)象,我們將把它們存儲(chǔ)在 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
    
}

我們實(shí)現(xiàn)了一個(gè)新的模版WebIndexTemplate, 作為index 的基礎(chǔ)模版恭理,然后通過WebIndexContext對(duì)象去驅(qū)動(dòng)內(nèi)容。在WebIndexTemplate使用到外部的樣式表Feather CSS來設(shè)置基礎(chǔ)組件郭变。關(guān)于feather css 可以看這個(gè)文檔

可以看到設(shè)置.href("/image/favicon.ico"), 其中/image/favicon.icoPublic 文件夾里面對(duì)應(yīng)路徑的文件颜价,沒有的話我們可以創(chuàng)建一個(gè) 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!")))
    }
}

模版層次結(jié)構(gòu)

因?yàn)槲覀兇蛩憬⒁粋€(gè)多頁面網(wǎng)站周伦,拆分模板將是必不可少的。 我們可以創(chuàng)建可重用的部分未荒,你可以稍后在其他模板文件中共享和渲染它們专挪。 在下面的示例中,我們將創(chuàng)建三個(gè)單獨(dú)的頁面茄猫。

首先狈蚤,我們必須更新索引模板,因?yàn)樗鼘⒈徽麄€(gè)網(wǎng)站重用划纽。


/// 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")
    }
}

這里的一個(gè)重要的變化是可以為模板文件傳遞的一個(gè)builder 參數(shù), 是一個(gè) @TagBuilder 的構(gòu)建器脆侮,這個(gè)構(gòu)建器可以為我們創(chuàng)建 body ,這樣就能和index 模版組合使用了勇劣。

現(xiàn)在我們的WebIndexContext 也不需要message參數(shù)了靖避,我們可以刪除它。

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

作為 index 模板的最后一個(gè)部分比默,我們將從 Public/js 目錄中加入一些基本的 javascript 文件』媚螅現(xiàn)在我們可以提前創(chuàng)建對(duì)應(yīng)的文件夾和一個(gè)空的 web.js 文件,之后就會(huì)使用到命咐。

HomePage

主頁會(huì)比較簡單篡九,首先我們需要一個(gè) WebHomeContext struct 來表示想要呈現(xiàn)的數(shù)據(jù)。

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模版一起使用榛臼。類似這樣伊佃,我們能將重復(fù)使用的模版代碼抽出一個(gè)獨(dú)立的模版,然后組合使用它們沛善。

在之前航揉,我們將很多代碼直接放在了 configure or routes 文件里面,這不是一個(gè)好的管理代碼的方式金刁,所以現(xiàn)在我們應(yīng)該分模版得管理代碼帅涂,我們將渲染模版的功能放在WebFrontendController對(duì)象中。


/// 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: "你好尤蛮,歡迎來到我的主頁")))
    }
}

接下來我們需要一個(gè)專門的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方法,同時(shí)也支持其他HTTP 方法抵屿。

現(xiàn)在我們可以在 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)
}

運(yùn)行程序捅位,你可以看到現(xiàn)在由兩個(gè)模版組合的效果了轧葛。

渲染子模版

之前提到了可以在模版中渲染模版,所以我們現(xiàn)在來做這個(gè)事情艇搀。因?yàn)橹髸?huì)用到很多鏈接尿扯,所以我們需要一個(gè) WebLinkContext 攜帶 labelurl

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

使用相應(yīng)的 WebLinkTemplate 我們可以渲染我們的 WebLinkContext 對(duì)象焰雕,當(dāng)然我們可以添加更多屬性衷笋,例如樣式類或布爾值來確定鏈接是否為空白鏈接,但為了簡單起見矩屁,我們先從 labelurl開始辟宗。 后續(xù)有對(duì)應(yīng)的需求,也可以進(jìn)行擴(kuò)展吝秕。

/// 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)
    }
}

我們還應(yīng)該改變 WebHomeContext 結(jié)構(gòu)泊脐,這樣我們就可以利用新創(chuàng)建的WebLinkContext。 我們還將添加一個(gè)新的圖標(biāo)屬性烁峭,以使我們的主頁更漂亮一點(diǎn)容客。

/// 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
}

相應(yīng)地我們也需要對(duì)WebHomeTemplate做對(duì)應(yīng)的改進(jìn)。

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 方法返回一個(gè)標(biāo)簽。 返回的 Tag 對(duì)象就像我們可以手動(dòng)創(chuàng)建的任何其他標(biāo)簽一樣鬓梅,因此將模板嵌入到另一個(gè)模板中是安全的供置。
請(qǐng)注意,我們?nèi)匀豢梢栽谀0逦募惺褂贸R?guī)的 for 循環(huán)(也可以使用 if-else)绽快。 這很棒芥丧,因?yàn)槲覀兛梢员闅v段落值并使用 P Tag呈現(xiàn)它們悲关。

struct WebFrontendController {
    func homeView(req: Request) throws -> Response {
        req.templates.renderHtml(WebHomeTemplate(.init(icon: "?? ",
                                                       title: "i am lqbk",
                                                       message: "你好,歡迎來到我的主頁",
                                                       paragraphs: ["我在學(xué)習(xí)Vapor框架",
                                                                    "希望能搭建一個(gè)自己喜歡的Blog",
                                                                    "提升自己的技能"],
                                                       link: WebLinkContext(label: "Read my blog →", url: "/blog/"))))
    }
}

最后回到WebFrontendController, 對(duì)之前的修改做內(nèi)容得填充娄柳。

Blog List

由于我們正在使用模塊化架構(gòu)構(gòu)建應(yīng)用程序,因此我們不能簡單地將與博客相關(guān)的內(nèi)容放入 Web 模塊中赤拒。 Web 模塊在我們的案例中有些特殊秫筏,因?yàn)樗鼮槲覀兲峁┝顺尸F(xiàn)網(wǎng)站的主要元素。 它還包含 Index 模板挎挖、Web 樣式表和 javascript 文件这敬。

我們將創(chuàng)建一個(gè)名為 Blog 的新模塊。 每個(gè)模塊都將遵循我們之前創(chuàng)建的相同模式蕉朵。 這意味著我們將擁有專用的routercontroller崔涂。 在開始之前,我們將創(chuàng)建一個(gè) BlogPost struct來表示我們的文章始衅。 在 Sources/App/Modules/Blog 目錄下新建一個(gè) Swift 文件冷蚂。


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

import Foundation

public struct BlogPost: Codable {
    public let title: String //標(biāo)題
    public let slug: String //標(biāo)識(shí)符
    public let image: String //突破路徑
    public let excerpt: String //摘要
    public let date: Date// 日期
    public let category: String? //文章分類
    public let content: String //文章內(nèi)容
}

現(xiàn)在我們定義了 BlogPost 的內(nèi)容,不過因?yàn)楫?dāng)前我們還沒有數(shù)據(jù)層去獲取存放這些數(shù)據(jù)汛闸,所以當(dāng)前我們暫時(shí)用隨機(jī)生成的數(shù)據(jù)去調(diào)試頁面蝙茶。 這塊邏輯交給 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 負(fù)責(zé)處理所有在網(wǎng)絡(luò)上公開的與博客相關(guān)的路由诸老。 這就是為什么它被稱為前端控制器隆夯。 稍后我們將使用相同的邏輯來創(chuàng)建其他類型的內(nèi)容通道,例如admin控制器和 API 控制器别伏。
現(xiàn)在對(duì)于我們的博客文章頁面蹄衷,我們將需要一個(gè)新的 BlogPostsContext 結(jié)構(gòu),我們可以使用它來呈現(xiàn)頁面厘肮。

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

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

然后一樣的需要一個(gè)BlogPostsTemplate模版來渲染數(shù)據(jù)

/// 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 框架的好處是我們可以直接使用大部分組件愧口。 例如,我們的列表將是響應(yīng)式的轴脐,因?yàn)槲覀兪褂玫氖?grid-221 類调卑。
這意味著網(wǎng)格將在臺(tái)式機(jī)和平板設(shè)備上使用 2 列布局,而在移動(dòng)設(shè)備上將使用單列大咱。 當(dāng)我們?cè)诹斜碇酗@示帖子時(shí)恬涧,我們必須調(diào)整帖子的標(biāo)準(zhǔn)標(biāo)題元素,我們將在web.css 文件中添加一個(gè)小改動(dòng)碴巾。

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

現(xiàn)在我們回到 BlogFrontendController 去處理請(qǐng)求后的這個(gè)模版的渲染



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 模塊也應(yīng)該有專門的路由類管理頁面溯捆,我們?cè)趯?duì)應(yīng)模塊下創(chuàng)建BlogRouter.swift 文件,處理對(duì)應(yīng)的路由跳轉(zhuǎn)。


/// 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)
    }
}

最后運(yùn)行應(yīng)用,點(diǎn)擊 Blog tab 查看效果吧劳跃。

blog list

如果出現(xiàn) Pubilc有對(duì)應(yīng)圖片谎仲,但是圖片展示不出來的問題,你需要配置一下 working directory
可以參考:https://theswiftdev.com/custom-working-directory-in-xcode/

Blog 詳情頁

列表頁已經(jīng)搭建完成了刨仑,現(xiàn)在我們將進(jìn)一步完成詳情頁的搭建郑诺。我們將首先在 Templates 文件夾中創(chuàng)建一個(gè)新的 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處理詳情頁跳轉(zhuǎn)的邏輯杉武,我們通過路徑中的 slug標(biāo)識(shí)去找到對(duì)應(yīng)的BlogPost數(shù)據(jù)辙诞,如果沒有找到就重定向回主頁。


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)))
    }
}

現(xiàn)在我們剩下的問題就是: 如何修改路由通過url 的path去匹配對(duì)應(yīng)的控制器轻抱。

因?yàn)槊總€(gè)詳情頁的路徑應(yīng)該是不同的蝇恶,所以這里我們可以使用 .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)
    }
}

現(xiàn)在你可以運(yùn)行應(yīng)用豺憔,可以點(diǎn)擊文章詳情去查看內(nèi)容了证舟。

自定義中間件

現(xiàn)在我們可以使用同一路徑的兩個(gè)版本(例如 /blog/ vs /blog)訪問每個(gè) URL释树,我們將實(shí)現(xiàn)一個(gè)中間件去統(tǒng)一這2個(gè)url 的表現(xiàn)叙量。

/// 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文件配置上這個(gè)中間件烛卧。

/// 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時(shí)晃痴,中間件會(huì)自動(dòng)帶上"/"后綴曹铃。類似這樣我們通過使用中間件做一些串行的處理缰趋。因?yàn)?AsyncAwait 需要macOS 12才支持,所以需要改一下Package.swiftplatforms.macOS(.v12)

最后一個(gè)菜單項(xiàng)呢陕见? 讓我們使用在教程開始時(shí)創(chuàng)建的那個(gè)空的 web.js 文件秘血。 我們將簡單地顯示一個(gè)alert,但當(dāng)然您可以使用此模板通過一些精美的動(dòng)畫來為網(wǎng)站增添趣味评甜。

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

總結(jié)

這次我們學(xué)習(xí)到了通過 SwiftHtml 去搭建 Web 頁面的模版灰粮,模塊化得管理代碼,了解到路由的基礎(chǔ)知識(shí)忍坷。下一步粘舟,我們會(huì)開始使用Fluent 實(shí)現(xiàn)我們的數(shù)據(jù)層。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末佩研,一起剝皮案震驚了整個(gè)濱河市柑肴,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌旬薯,老刑警劉巖晰骑,帶你破解...
    沈念sama閱讀 211,123評(píng)論 6 490
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異绊序,居然都是意外死亡硕舆,警方通過查閱死者的電腦和手機(jī)秽荞,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,031評(píng)論 2 384
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來抚官,“玉大人扬跋,你說我怎么就攤上這事×杞冢” “怎么了胁住?”我有些...
    開封第一講書人閱讀 156,723評(píng)論 0 345
  • 文/不壞的土叔 我叫張陵,是天一觀的道長刊咳。 經(jīng)常有香客問我彪见,道長,這世上最難降的妖魔是什么娱挨? 我笑而不...
    開封第一講書人閱讀 56,357評(píng)論 1 283
  • 正文 為了忘掉前任余指,我火速辦了婚禮,結(jié)果婚禮上跷坝,老公的妹妹穿的比我還像新娘酵镜。我一直安慰自己,他們只是感情好柴钻,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,412評(píng)論 5 384
  • 文/花漫 我一把揭開白布淮韭。 她就那樣靜靜地躺著,像睡著了一般贴届。 火紅的嫁衣襯著肌膚如雪靠粪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,760評(píng)論 1 289
  • 那天毫蚓,我揣著相機(jī)與錄音占键,去河邊找鬼。 笑死元潘,一個(gè)胖子當(dāng)著我的面吹牛畔乙,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播翩概,決...
    沈念sama閱讀 38,904評(píng)論 3 405
  • 文/蒼蘭香墨 我猛地睜開眼牲距,長吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來了钥庇?” 一聲冷哼從身側(cè)響起牍鞠,我...
    開封第一講書人閱讀 37,672評(píng)論 0 266
  • 序言:老撾萬榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎上沐,沒想到半個(gè)月后皮服,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 44,118評(píng)論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,456評(píng)論 2 325
  • 正文 我和宋清朗相戀三年龄广,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了硫眯。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,599評(píng)論 1 340
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡择同,死狀恐怖两入,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情敲才,我是刑警寧澤裹纳,帶...
    沈念sama閱讀 34,264評(píng)論 4 328
  • 正文 年R本政府宣布,位于F島的核電站紧武,受9級(jí)特大地震影響剃氧,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜阻星,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,857評(píng)論 3 312
  • 文/蒙蒙 一朋鞍、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧妥箕,春花似錦滥酥、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,731評(píng)論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至宇葱,卻和暖如春瘦真,著一層夾襖步出監(jiān)牢的瞬間贝搁,已是汗流浹背吗氏。 一陣腳步聲響...
    開封第一講書人閱讀 31,956評(píng)論 1 264
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留污尉,地道東北人膀哲。 一個(gè)月前我還...
    沈念sama閱讀 46,286評(píng)論 2 360
  • 正文 我出身青樓被碗,卻偏偏與公主長得像,于是被迫代替她去往敵國和親锐朴。 傳聞我的和親對(duì)象是個(gè)殘疾皇子兴喂,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,465評(píng)論 2 348

推薦閱讀更多精彩內(nèi)容