覺得以前純粹分享自己做了什麼事情的文章實在是太沒意義了,所以從今天開始建立了新的分類叫做心情隨筆,大概會是每週日有空的時間來整理一下這個禮拜做的事情中,有什麼可以分享給大家的東西,然後將它們寫成一篇文章,讓大家如果有遇到相同的問題可以透過我的分享去解決自身的問題。不過如果當週實在沒什麼想分享的事情的話,應該就不會硬是去寫一篇文章出來就是了。
今年一月剛結束的時候,發現自己還是渾渾噩噩地在過生活,一直覺得自從上班後的這幾年來都在蹉跎人生,也因此希望能夠開始管理好自己的生活,藉以達成自己除了工作以外想要完成的目標。去年的鐵人賽給了我一個契機,讓我知道自己還是可以透過一些壓力來去完成一些自己想要完成的目標。就這樣我在 Youtube 上開始找找看有沒有人分享一些相關的技巧,讓我可以對管理生活有一些基本的概念,最後讓我找到了一個叫做子彈筆記規劃術的技巧。
其實我本來對子彈筆記規劃術是完全沒有概念的,一直以為是什麼做筆記的技巧,結果發現其實是用來做全年規劃的一種筆記方法,不過詳細的技術內容和概念我也不是很清楚,主要的認知都是來自這部影片。也因為這部影片,我開始學習怎麼使用 Notion 這個軟體,發現這個軟體真的還蠻好用的,就開始將自己之前整理的一些東西放上來,也開始利用這個軟體來整理我的帳本,真的非常方便。底下分享一些我這個禮拜看的使用 Notion 整理自己生活的技術影片:
至少這禮拜真的因為 Notion 的關係,開始慢慢能夠了解自己每天到底花了多少時間在無意義的事情上,開始慢慢了解自己總共能夠花的錢有多少,慢慢對自己的生活有了掌控感,也開始慢慢有推動一些我想做的事情了!希望接下來的幾個禮拜可以繼續維持下去,完成更多更棒的事情。
]]>
漫長的九月終於過去了,終於成功地將這三十天的專案和文章都寫完了!明天正好就是連假,可以好好休息了。
首先要在這裡跟大家提到的是,如果在看前面天數的文章中,程式碼的部分有任何不清楚的地方,我已經將這次鐵人賽系列中處理的三個專案都開源出來了,專案網址分別在底下的地方:
關於如何執行這些專案的部分,在伺服器上沒有 intelliJ IDEA 的環境下,可以嘗試在專案根目錄利用 gradlew run
來執行專案,當然前提是伺服器上要有可以執行 Kotlin 的環境就是了。
對於專案之後還可以繼續往哪些方向改進,這裡提供了一些方向讓大家參考:
Let's Encrypt
這樣的服務去設定。最後就是要來寫一下這 30 天鐵人賽挑戰下來的心得了。完整地製作一份簡單的 Online Judge 一直是我很想嘗試的內容,雖然大學的時候曾經嘗試設計過其架構,然後碩士班的時候有幫忙實作了第二版的批改娘,但一直以來就是沒有很完整地從頭開始實作這一整個系統。
今年參加 COSCUP 完後,腦子裡突然又開始想要做做看這個專案,但是一直下定不了決心。就在這時,我剛好開始嘗試使用 Kotlin 語言,又剛好開始嘗試玩玩 Ktor 這個套件,又剛好開始嘗試玩玩官網上 Kotlin-React 的教學,又剛好這個鐵人賽要開賽,所以我就將這些東西結合起來成為一個主題,就帶著這個主題跑來參賽了。
開賽前其實專案的部分我什麼都沒寫,在這 30 天裡面,其實我在撰寫專案的過程中碰到了很多問題,每次都讓我蠻擔心專案會寫不下去、文章會沒法寫出來。最可怕的幾次大概就是在不知道該用 Ktor 的哪種驗證機制的部分、不知道該怎麼讓 Docker 執行程式的部分,以及前後端對接後卻發現一定得要使用 HTTPS 連線的問題等等,最後幸好都有找到資料並克服了這些問題,真是萬幸。下次如果還有機會參賽的話,我會想要至少先把專案做完,再來參加 30 天鐵人賽去撰寫文章,這種邊寫專案邊寫文章的過程,還是不要再體驗第二次比較好,太可怕了。
在這 30 天的結尾我想要感謝一些人。首先是要感謝「Kotlin 鐵人陣」團隊,雖然我因為開始得早,沒有與他們團隊一起開始比賽,但是他們還是讓我在他們的 Line 群裡面跟大家一起交流、一起寫文章,在裡面真的很開心,也成為了我能夠一直將這系列寫下去的動力之一。再來要感謝我的同事們,雖然常常說我每天經歷趕稿地獄根本是自作自受,但是還是很鼓勵我繼續寫下去。第三,要感謝我的 Facebook 和 Plurk 好友群,常常會按讚或是在底下留言跟我交流,讓我能夠了解到我不足的地方。最後要感謝我的家人,必須要忍受我平日和假日得拼命在家裡趕稿,導致我不能跟著大家一起出門去吃飯。
這三十天下來我在寫稿的期間都是聽著森口博子的「鳥籠の少年」和新版的「君を見つめて -The time I’m seeing you-」在寫的,真的是兩首很棒的音樂,陪伴我度過煎熬的鐵人賽寫稿時光,在這裡順便推薦這兩首歌給大家。最後宣傳一下我的個人網站–翼世界夢想領域,這個系列預計應該會在我的網站和 Kotlin.tips 上轉載,期待之後還能與大家有更多的交流,感謝大家!
]]>昨日基本上我們已經完成了大致的 Online Judge 系統,剩下基本上就是看你打算要怎麼設計你的 Online Judge 系統來決定該怎麼打造你前端網頁的架構了。今天我們就稍微將尚未完成的重新審核程式碼功能以及一些其他地方補足起來,剩下的就是讓你自行透過這幾天所嘗試的內容自行發揮了!
為了要能夠知道目前的使用者是否能夠進行重新審核程式碼的動作,首先先在資料管理系統的 API 部分,讓傳回來的程式碼列表資料中,順便也帶回整體程式碼是否可以被使用者重新審核的權限,以及個別程式碼是否可以被重新審核的權限這些資訊,如下所示:
route("/submissions") {
authenticate(NORMAL_USER_AUTHENTICAION_NAME, optional = true) {
get {
var userIdAuthorityPrincipal = call.sessions.get<UserIdAuthorityPrincipal>()
var submissions: List<Map<String, Any>>? = null
transaction {
submissions = (SubmissionTable innerJoin ProblemTable innerJoin UserTable)
.slice(
SubmissionTable.id,
UserTable.id,
UserTable.name,
ProblemTable.id,
ProblemTable.title,
SubmissionTable.language,
SubmissionTable.result,
SubmissionTable.executedTime
).selectAll()
.orderBy(SubmissionTable.id, SortOrder.DESC)
.map {
mapOf(
"id" to it[SubmissionTable.id].toString(),
"name" to it[UserTable.name],
"problemId" to it[ProblemTable.id],
"title" to it[ProblemTable.title],
"language" to it[SubmissionTable.language],
"result" to it[SubmissionTable.result],
"executedTime" to it[SubmissionTable.executedTime],
// 增加下面這筆欄位
"isRefreshable" to (userIdAuthorityPrincipal != null &&
it[UserTable.id].toString() == userIdAuthorityPrincipal.userId)
)
}
}
call.respond(
mapOf(
"data" to submissions,
// 增加下面這筆欄位
"isRefreshable" to (userIdAuthorityPrincipal != null && userIdAuthorityPrincipal.authority.toInt() > 1)
)
)
}
}
/* ...... 其他的程式碼部分 ...... */
}
在 GET /submissions
的 API 部分,我們修改成會對使用者進行驗證的行為,接著就是個別程式碼資料的欄位部分以及整體的欄位部分新增一個 isRefreshable
的欄位,去代表使用者可否對程式碼進行重新審核操作的布林值欄位。根據之前我們的定義,能夠重新審核個別筆程式碼的只有當初遞交該程式碼的使用者,而能夠對全部未審核的程式碼進行重新審核的使用者則只有超級管理員而已。
在資料管理系統有了這筆資料後,接著就來讓我們把網頁專案這邊,承接其資料的類別也可以去讀取這個欄位的資料,如下程式碼所示:
data class SubmissionsData(
val data: Array<SubmissionData>,
val isRefreshable: Boolean
)
data class SubmissionData(
val id: String,
val name: String,
val problemId: String,
val title: String,
val language: String,
val result: String,
val executedTime: String,
val isRefreshable: Boolean
)
接著就根據這個欄位的資料,在程式碼總列表的頁面上增加「重新審核」的按鈕,整體程式碼如下所示:
external interface SubmissionsArticleState: RState {
var submissionsData: List<SubmissionData>
var isRefreshable: Boolean
}
class SubmissionsArticle: RComponent<RProps, SubmissionsArticleState>() {
override fun SubmissionsArticleState.init() {
submissionsData = listOf()
isRefreshable = false
val mainScope = MainScope()
mainScope.launch {
val remoteSubmissionsData = Fetcher.createSubmissionsFetcher().fetch()
setState {
submissionsData = remoteSubmissionsData.data.toList()
isRefreshable = remoteSubmissionsData.isRefreshable
}
}
}
override fun RBuilder.render() {
mainArticle {
// 改變標題的部分,增加「重新審核未審核的程式碼」按鈕
div {
attrs.classes = setOf("row")
h1 {
attrs.classes = setOf("col")
+"遞交程式碼列表"
}
if (state.isRefreshable) {
div {
attrs.classes = setOf("col-md-2")
routeLink("/submissions/restart", className = "btn btn-primary") {
+"重新審核未審核的程式碼"
}
}
}
}
// 在最後一欄增加「重新審核」的按鈕欄位
val isDisplayRefreshableColumn = state.submissionsData.any { it.isRefreshable }
table {
attrs.classes = setOf("table", "table-bordered", "table-striped")
thead {
attrs.classes = setOf("thead-dark")
tr {
th { +"編號" }
th { +"使用者名稱" }
th { +"題目名稱" }
th { +"使用程式語言" }
th { +"審核結果" }
th { +"執行時間(秒)" }
if (isDisplayRefreshableColumn) {
th { +"操作" }
}
}
}
tbody {
for (item in state.submissionsData) {
tr {
td { +item.id }
td { +item.name }
td { routeLink("/problems/${item.problemId}") { +item.title } }
td { +item.language }
td { +item.result }
td { +item.executedTime }
if (isDisplayRefreshableColumn) {
td {
if (item.isRefreshable) {
routeLink("/submissions/${item.id}/restart", className = "btn btn-primary") {
+"重新審核"
}
}
}
}
}
}
}
}
}
}
}
有了連結按鈕後,接著就要來建置會發送重新審核程式碼需求的 component 了。首先先將會發送這個需求的 Fetcher
定義好,如下程式碼所示:
fun createSubmissionsRestartFetcher() = Fetcher<JustFetch>("$DATA_URL/submissions/restart")
fun createSubmissionRestartFetcher(id: Int) = Fetcher<JustFetch>("$DATA_URL/submissions/$id/restart")
接著設計會去使用上面這兩個 Fetcher
的 RestartSubmissionComponent
。這個元件裡面可以透過 props 是否有傳入特定程式碼編號,來判斷看看究竟要使用上面兩個 Fetcher
中的哪一個 Fetcher
,整體程式碼如下所示:
external interface RestartSubmissionComponentProps: RProps {
var submissionId: Int?
}
external interface RestartSubmissionComponentState: RState {
var isRestart: Boolean
var onRestart: (Int?) -> Unit
}
class RestartSubmissionComponent: RComponent<RestartSubmissionComponentProps, RestartSubmissionComponentState>() {
override fun RestartSubmissionComponentState.init() {
isRestart = false;
onRestart = {
val mainScope = MainScope()
mainScope.launch {
if (it == null) {
Fetcher.createSubmissionsRestartFetcher().fetch("POST")
} else {
Fetcher.createSubmissionRestartFetcher(it).fetch("POST")
}
setState {
isRestart = true
}
}
}
}
override fun RBuilder.render() {
if (!state.isRestart) {
state.onRestart(props.submissionId)
} else {
redirect(to = "/submissions")
}
}
}
fun RBuilder.restartSubmissionComponent(handler: RElementBuilder<RestartSubmissionComponentProps>.() -> Unit): ReactElement =
child(RestartSubmissionComponent::class, handler)
最後在 App
的地方加上路由即可完成。
route("/submissions/restart", exact = true) {
restartSubmissionComponent { }
}
route<IdProps>("/submissions/:id/restart") {
restartSubmissionComponent {
attrs.submissionId = it.match.params.id
}
}
重新執行網頁專案後,就可以在程式碼總列表的地方看到「重新審核」的按鈕了,如下所示:
不過如果你按下單筆程式碼的重新審核的話,可能會發現它沒有什麼變化,要等一陣子之後才會看到有變化,這是因為我們在重新審核單筆程式碼的時候,不會將原本程式碼的結果給洗掉,關於這點就看你有沒有打算設計成會洗掉原本結果的形式。
我們完成了大部分麻煩的頁面,但好像一直沒有去製作首頁的部分。基本上首頁可以自己自由地去決定該怎麼做,這裡利用 Bootstrap 常見的 Jumbotron 樣式來進行製作,整個 IndexArticle
component 程式碼如下所示:
class IndexArticle: RComponent<RProps, RState>() {
override fun RBuilder.render() {
mainArticle {
div {
attrs.classes = setOf("jumbotron")
h1 {
attrs.classes = setOf("display-4")
+"歡迎光臨 Knight Online Judge"
}
p {
attrs.classes = setOf("lead")
+"快點來解些題目吧!"
}
}
}
}
}
fun RBuilder.indexArticle(handler: RElementBuilder<RProps>.() -> Unit): ReactElement =
child(IndexArticle::class, handler)
在路由的部分將根目錄換成使用 IndexArticle
即可。
route("/", exact = true) { indexArticle { } }
這樣就可以看首頁稍微有點花樣了。
最後,就讓我們來談一談怎麼處理 Fetcher
傳回來的錯誤吧!以 /problems/:id
為例,假設使用者現在輸入了一個不存在的 id
,則以我們目前 component 的設計,它就會不斷地去查詢題目詳細資料,造成無止境的錯誤發生,非常危險。這裡可以簡單利用 try-catch
的方式去將 fetch()
所丟出來的例外給接住,並在該 componenet 的 state 去紀錄已經錯誤的訊息,最後在 render()
的部分遇到錯誤就顯示錯誤資訊即可,整體程式碼如下所示:
external interface ProblemDetailArticleState: RState {
/* ...... 其餘資料 ...... */
// 增加已經錯誤的欄位
var isError: Boolean
}
class ProblemDetailArticle: RComponent<ProblemDetailArticleProps, ProblemDetailArticleState>() {
override fun ProblemDetailArticleState.init() {
problemDetailData = null
isError = false // 初始化為 false
onLoad = {
val mainScope = MainScope()
mainScope.launch {
// 利用 try-catch 的方式接住錯誤
try {
val remoteProblemDetailData = Fetcher.createProblemDetailFetcher(it).fetch()
setState {
problemDetailData = remoteProblemDetailData.data
}
} catch(e: Throwable) {
// 接到錯誤後設定 state 為 true
setState {
isError = true
}
}
}
}
}
override fun RBuilder.render() {
mainArticle {
// 收到錯誤後直接顯示錯誤訊息即可
if (state.isError) {
div {
attrs.classes = setOf("alert", "alert-danger")
+"找不到題目資訊。"
}
return@mainArticle
}
/* ...... 其餘的程式碼內容 ...... */
}
}
}
這樣實作完後,我們就可以隨意在網址列的 /problems
後面隨意輸入一個數字,即可看到錯誤訊息了。
今天將重新審核程式碼、首頁以及如何處理 Fetcher
回傳回來的錯誤該怎麼處理給大致上帶過了,基本上都是運用我們這幾天所嘗試過的技巧,接著下來就是利用這些技巧去設計一個屬於你自己的 Online Judge 系統吧!明天最後一天會稍微給大家一些接下來還可以進行哪些事情的想法,以及最後我對於整個 30 天的內容下來的感想。感謝各位追隨這個系列到了今天,明天終於能夠有個 Happy Ending 了!
昨天我們將獲取資料的網頁部分給完成了,今天就讓我們繼續將操作資料的網頁部分給完成吧!
在操作資料的部分,很常會使用到一些 <input>
輸入框的標籤以及 <textarea>
文字框的標籤,而這些標籤都會有一些預設樣式會套用上去,故我們可以將這兩組標籤與樣式利用 React 形成一個可以重複使用的元件,底下分別是 FormInputComponent
和 FormTextAreaComponent
的程式碼:
// FormInputComponent.kt
import kotlinx.html.*
import kotlinx.html.js.onChangeFunction
import org.w3c.dom.events.Event
import react.*
import react.dom.*
external interface FormInputComponentProps: RProps {
var id: String
var name: String
var currentValue: String
var inputType: InputType
var onChange: (Event) -> Unit
}
class FormInputComponent: RComponent<FormInputComponentProps, RState>() {
override fun RBuilder.render() {
div {
attrs.classes = setOf("form-group")
label {
attrs.htmlFor = props.id
+props.name
}
input {
attrs.type = props.inputType
attrs.id = props.id
attrs.classes = setOf("form-control")
attrs.value = props.currentValue
attrs.onChangeFunction = props.onChange
}
}
}
}
fun RBuilder.formInputComponent(handler: RElementBuilder<FormInputComponentProps>.() -> Unit): ReactElement =
child(FormInputComponent::class, handler)
// FormTextAreaComponent.kt
import kotlinx.html.*
import kotlinx.html.js.onChangeFunction
import org.w3c.dom.events.Event
import react.*
import react.dom.*
external interface FormTextAreaComponentProps: RProps {
var id: String
var name: String
var currentValue: String
var onChange: (Event) -> Unit
}
class FormTextAreaComponent: RComponent<FormTextAreaComponentProps, RState>() {
override fun RBuilder.render() {
div {
attrs.classes = setOf("form-group")
label {
attrs.htmlFor = props.id
+props.name
}
textArea {
attrs.id = props.id
attrs.classes = setOf("form-control")
attrs.value = props.currentValue
attrs.onChangeFunction = props.onChange
}
}
}
}
fun RBuilder.formTextAreaComponent(handler: RElementBuilder<FormTextAreaComponentProps>.() -> Unit): ReactElement =
child(FormTextAreaComponent::class, handler)
基本上這兩個元件都只要讓你分別代入元件 ID、元件標題名稱、元件目前的值以及元件變更時要呼叫的函式這四個值即可使用。有了這兩個元件以後,我們就可以利用這兩個元件來組裝接下來會需要製作的表單。
首先,就先讓我們來製作題目的表單吧!題目的表單會運用在「新增題目」和「編輯題目」的兩個功能上,分辨的方式就是看看在使用表單的時候是否已經有給預設的問題編號,所以其表單的 props 就是持有一個 problemId
這個變數去記錄這件事情,如下程式碼所示:
external interface ProblemFormProps: RProps {
var problemId: Int?
}
而 state 的部分則是要紀錄目前的表單內容、是否已經遞交表單、是否已經得到遞交表單後的結果,以及在「編輯題目」的時候,要能先從資料管理系統拿到原本的題目資料用的函式,共這四個不同的狀態值,定義如下程式碼所示:
external interface ProblemFormState: RState {
var problemFormData: ProblemFormData
var isSubmitted: Boolean
var isResultGet: Boolean
var onLoad: (Int) -> Unit
}
而 state 初始化的部分就依照上面的定義進行初始化的工作,如下程式碼所示:
class ProblemForm: RComponent<ProblemFormProps, ProblemFormState>() {
override fun ProblemFormState.init() {
isSubmitted = false
isResultGet = false
problemFormData = ProblemFormData(
null,
"",
"",
mutableListOf()
)
onLoad = /* ...... 獲取題目資料的程式碼 ...... */
/* ...... 其餘的程式碼 ...... */
}
其中,onLoad
的部分會需要先獲得原本題目的資料,還記得昨天我們將資料管理系統中,原本用來獲取題目資料的 API GET /problems/{id}
,改到了 GET /problems/{id}/all
嗎?現在就是要拿來使用的時候了,將這個 API 改成如下所示的樣子:
authenticate(SUPER_USER_AUTHENTICATION_NAME) {
get("/all") {
val requestId =
call.parameters["id"]?.toInt() ?: throw BadRequestException("The type of Id is wrong.")
var responseData: Problem? = null
transaction {
val requestProblem = ProblemTable.select {
ProblemTable.id.eq(requestId)
}.first()
val requestTestCases = TestCaseTable.select {
TestCaseTable.problemId.eq(requestId)
}.map {
TestCase(
id = it[TestCaseTable.id].toString(),
input = it[TestCaseTable.input],
expectedOutput = it[TestCaseTable.expectedOutput],
comment = it[TestCaseTable.comment],
score = it[TestCaseTable.score],
timeOutSeconds = it[TestCaseTable.timeOutSeconds]
)
}.toList()
responseData = Problem(
id = requestProblem[ProblemTable.id].toString(),
title = requestProblem[ProblemTable.title],
description = requestProblem[ProblemTable.description],
testCases = requestTestCases
)
}
call.respond(mapOf("data" to responseData))
}
}
與之前不同的地方大概在於這次要獲取資料前,必須先通過超級管理員的認證,不然的話一般的使用者就可以把題目的測資看光光了。回到網頁專案的部分,我們建立一個 Fetcher
以及資料型態可以用來對應這個 API,如下程式碼所示:
fun createProblemAllFetcher(id: Int) = Fetcher<ProblemFormWrapperData>("$DATA_URL/problems/$id/all")
data class ProblemFormWrapperData(
val data: ProblemFormArrayData
)
data class ProblemFormArrayData(
var id: String?,
var title: String,
var description: String,
var testCases: Array<TestCaseData>
)
data class ProblemFormData(
var id: String?,
var title: String,
var description: String,
var testCases: MutableList<TestCaseData>
)
data class TestCaseData(
var id: String?,
var input: String,
var expectedOutput: String,
var comment: String,
var score: Int,
var timeOutSeconds: Double
)
這裡我們定義了 ProblemFormWrapperData
用來獲取 API 傳來的資料,比較麻煩的地方是在於我們獲取題目的測資 TestCaseData
的部分得用 Array
來接,但在使用的時候會比較希望能夠用 MutableList
來做處理,所以這裡定義了 ProblemFormArrayData
來接 API 的資料,但使用的時候會轉成 ProblemFormData
以作為後續處理時的資料型態。
有了這個 Fetcher
後,我們的 ProblemFormState.onLoad
就可以做初始化了,如下程式碼所示:
onLoad = {
val mainScope = MainScope()
mainScope.launch {
val remoteProblemFormData = Fetcher.createProblemAllFetcher(it).fetch()
setState {
problemFormData = remoteProblemFormData.data.let {
ProblemFormData(
it.id,
it.title,
it.description,
it.testCases.toMutableList()
)
}
}
}
}
再來是表單內容的部分,首先我們先根據目前 state 的狀態來確定現在要做什麼事情,如下所示:
override fun RBuilder.render() {
styledDiv {
css {
width = LinearDimension("80%")
margin = "30px auto"
}
val problemId = props.problemId
if (state.isResultGet) {
redirect(to = if (problemId != null) "/problems/$problemId" else "/problems")
} else {
if (problemId != null && state.problemFormData.id == null) {
state.onLoad(problemId)
} else {
/* ...... 表單內容 ...... */
}
}
}
}
fun RBuilder.problemForm(handler: RElementBuilder<ProblemFormProps>.() -> Unit): ReactElement =
child(ProblemForm::class, handler)
先根據 state.isResultGet
來判斷表單是否已經遞送且獲得結果了。如果已經得到結果了,就直接跳轉到相對應的網頁,「編輯題目」就跳轉到剛編輯的題目的詳細頁面,而「新增題目」則跳轉到題目總列表的頁面。如果題目表單尚未遞交的話,則再判斷是否為「編輯題目」的狀況但還尚未拿到該題的資料,如果是的話就呼叫 state.onLoad()
函式去獲得資料;如果是「新增題目」或是已經獲得題目資料的話,就顯示表單內容。表單內容的部分如下程式碼所示:
h1 { +"題目表單" }
form {
attrs.onSubmitFunction = /* ...... 遞交表單的動作 ...... */
formInputComponent {
attrs.inputType = InputType.text
attrs.id = "titleInput"
attrs.name = "題目標題"
attrs.currentValue = state.problemFormData.title
attrs.onChange = {
val target = it.target as HTMLInputElement
setState {
problemFormData.title = target.value
}
}
}
formTextAreaComponent {
attrs.id = "descriptionInput"
attrs.name = "題目描述"
attrs.currentValue = state.problemFormData.description
attrs.onChange = {
val target = it.target as HTMLTextAreaElement
setState {
problemFormData.description = target.value
}
}
}
/* ...... 測資表單內容 ...... */
button {
attrs.type = ButtonType.submit
attrs.classes = setOf("btn", "btn-primary")
+"完成"
}
}
基本上就是根據題目的欄位去選用我們在最開始定義的輸入框或是文字框來使用。至於測資表單內容的部分,由於會有不定數量的測資需要填入,這裡除了測資欄位以外,還會需要有「新增測資」以及「刪除測資」的按鈕,程式碼如下所示:
div {
attrs.classes = setOf("form-group")
div {
attrs.classes = setOf("row")
h2 {
attrs.classes = setOf("col")
+"測試資料編輯"
}
div {
attrs.classes = setOf("col-md-2")
button {
attrs.type = ButtonType.button
attrs.classes = setOf("btn", "btn-primary")
attrs.onClickFunction = {
setState {
state.problemFormData.testCases.add(
TestCaseData(
null,
"",
"",
"",
0,
10.0
)
)
}
}
+"新增一筆測資"
}
}
}
for ((index, testCase) in state.problemFormData.testCases.withIndex()) {
styledDiv {
css {
padding = "20px"
margin = "10px auto"
borderWidth = LinearDimension("1px")
borderStyle = BorderStyle.solid
borderColor = Color.aliceBlue
backgroundColor = Color.antiqueWhite
}
attrs.classes = setOf("container", "form-group")
div {
attrs.classes = setOf("row")
h3 {
attrs.classes = setOf("col")
+"測試資料 ${index + 1}"
}
div {
attrs.classes = setOf("col-md-2")
button {
attrs.type = ButtonType.button
attrs.classes = setOf("btn", "btn-danger")
attrs.onClickFunction = {
if (window.confirm("確定要刪除這筆測試資料嗎?")) {
setState {
state.problemFormData.testCases.removeAt(index)
}
}
}
+"刪除這筆測資"
}
}
}
formTextAreaComponent {
attrs.id = "inputInput"
attrs.name = "測資輸入"
attrs.currentValue = testCase.input
attrs.onChange = {
val target = it.target as HTMLTextAreaElement
setState {
testCase.input = target.value
}
}
}
formTextAreaComponent {
attrs.id = "expectedOutputInput"
attrs.name = "預期測資輸出"
attrs.currentValue = testCase.expectedOutput
attrs.onChange = {
val target = it.target as HTMLTextAreaElement
setState {
testCase.expectedOutput = target.value
}
}
}
formTextAreaComponent {
attrs.id = "commentInput"
attrs.name = "註解"
attrs.currentValue = testCase.comment
attrs.onChange = {
val target = it.target as HTMLTextAreaElement
setState {
testCase.comment = target.value
}
}
}
formInputComponent {
attrs.inputType = InputType.number
attrs.id = "scoreInput"
attrs.name = "分數"
attrs.currentValue = testCase.score.toString()
attrs.onChange = {
val target = it.target as HTMLInputElement
setState {
testCase.score = target.value.toIntOrNull() ?: 0
}
}
}
formInputComponent {
attrs.inputType = InputType.number
attrs.id = "timeOutSecondsInput"
attrs.name = "最高執行時間限制(秒)"
attrs.currentValue = testCase.timeOutSeconds.toString()
attrs.onChange = {
val target = it.target as HTMLInputElement
setState {
testCase.timeOutSeconds = target.value.toDoubleOrNull() ?: 10.0
}
}
}
}
}
}
程式碼由於欄位比較多的關係,所以稍微長了一點。幾個跟前面比較不一樣的地方就是它是利用 for
迴圈一筆一筆加進來的,並且有兩個按鈕分別用來做「新增測資」與「刪除測資」的部分,然後每一筆測資的編輯區塊我在這裡有用一些 CSS 樣式去將它們給一塊一塊區分出來,這樣編輯的時候就會比較好看一點。另外就是在「刪除測資」的部分,這裡有利用 window.confirm("確定要刪除這筆測試資料嗎?")
去跳出確認視窗,讓使用者在確認過後才會進行刪除的動作,做一個防呆的效果,避免使用者誤觸後直接刪除了測資。
最後是要能進行遞交表單的動作,會需要先有編輯和新增題目資料的 Fetcher
和資料型態,程式碼如下所示:
fun createProblemEditFetcher(id: Int) = Fetcher<JustFetch>("$DATA_URL/problems/$id")
fun createProblemDeleteFetcher(id: Int) = Fetcher<JustFetch>("$DATA_URL/problems/$id")
fun createHeaders(method: String): Headers {
val headers = Headers()
if (method == "POST" || method == "PUT") { // 記得加上 "PUT"
headers.append("Content-Type", "application/json")
}
return headers
}
data class ProblemPostDTO(
val title: String,
val description: String,
val testCases: List<TestCasePostDTO>
)
data class ProblemPutDTO(
val id: String,
val title: String,
val description: String,
val testCases: List<TestCasePutDTO>
)
data class TestCasePostDTO(
val input: String,
val expectedOutput: String,
val comment: String,
val score: Int,
val timeOutSeconds: Double
)
data class TestCasePutDTO(
val id: String?,
val input: String,
val expectedOutput: String,
val comment: String,
val score: Int,
val timeOutSeconds: Double
)
class JustFetch
這裡由於我目前還不想處理回傳回來的錯誤,所以就先用一個空類別 JustFetch
來接回回傳的結果。
有了「新增題目」與「編輯題目」的兩個 Fetcher
,基本上遞交表單的部分就利用這兩個 Fetcher
實作即可,程式碼如下所示:
attrs.onSubmitFunction = onSubmitFunction@{
it.preventDefault()
if (state.isSubmitted) return@onSubmitFunction
if (problemId != null) {
val mainScope = MainScope()
mainScope.launch {
Fetcher.createProblemEditFetcher(problemId).fetch(
"PUT",
ProblemPutDTO(
state.problemFormData.id.toString(),
state.problemFormData.title,
state.problemFormData.description,
state.problemFormData.testCases.map {
TestCasePutDTO(
it.id,
it.input,
it.expectedOutput,
it.comment,
it.score,
it.timeOutSeconds
)
}
)
)
setState {
isResultGet = true
}
}
} else {
val mainScope = MainScope()
mainScope.launch {
Fetcher.createProblemCreateFetcher().fetch(
"POST",
ProblemPostDTO(
state.problemFormData.title,
state.problemFormData.description,
state.problemFormData.testCases.map {
TestCasePostDTO(
it.input,
it.expectedOutput,
it.comment,
it.score,
it.timeOutSeconds
)
}
)
)
setState {
isResultGet = true
}
}
}
}
表單實作完後,接著就是在題目總列表中加上新增與編輯的按鈕。為了要能夠辨識現在的用戶是否有權限可以進行新增與編輯,你可以用兩種方式來進行判斷:一種是利用 Redux 中的使用者登入狀態來進行判斷,另外一種則是趁得到總列表表單的時候,順便獲得是否具有能夠編輯的權限資訊。這裡我就試著修改資料管理系統去回傳是否可以編輯的資訊吧!
首先是在資料管理系統 GET /problems
的地方,根據 Session 的內容值去回傳一個 isEditable
的資訊去告知網頁專案是否此使用者具有可編輯的權限,如下所示:
call.respond(
mapOf(
"data" to problems,
"isEditable" to ((userIdAuthorityPrincipal?.authority?.toInt() ?: 0) > 1)
)
)
有了這個值之後,在前端網頁專案的部分就先讓資料能夠獲取這個值的資訊,如下所示:
data class ProblemsData(
val data: Array<ProblemData>,
val isEditable: Boolean
)
接著就是在 ProblemsArticle
中,設置一個狀態值來記錄這件事情,並在抓取完資料後更新它。
external interface ProblemsArticleState: RState {
var problemsData: List<ProblemData>
var isEditable: Boolean
}
class ProblemsArticle: RComponent<RProps, ProblemsArticleState>() {
override fun ProblemsArticleState.init() {
problemsData = listOf()
// 初始化
isEditable = false
val mainScope = MainScope()
mainScope.launch {
val remoteProblemData = Fetcher.createProblemsFetcher().fetch()
setState {
problemsData = remoteProblemData.data.toList()
// 更新其值
isEditable = remoteProblemData.isEditable
}
}
}
/* ...... 其餘的內容 ...... */
}
最後就是在內容中,增加「新增題目」與「編輯題目」的兩個按鈕,這裡我們也先將「刪除題目」的按鈕給加上去,如下所示:
override fun RBuilder.render() {
mainArticle {
div {
attrs.classes = setOf("row")
h1 {
attrs.classes = setOf("col")
+"題目列表"
}
// 「新增題目」的按鈕
if (state.isEditable) {
div {
attrs.classes = setOf("col-md-2")
routeLink("/problems/new", className = "btn btn-primary") {
+"新增題目"
}
}
}
}
table {
attrs.classes = setOf("table", "table-bordered", "table-striped")
thead {
attrs.classes = setOf("thead-dark")
tr {
th { +"編號" }
th { +"標題" }
// 「編輯題目」與「刪除題目」按鈕的欄位
if (state.isEditable) {
th { +"操作" }
}
}
}
tbody {
for (item in state.problemsData) {
tr {
if (item.isAccepted == "true") {
attrs.classes = setOf("table-success")
} else if (item.isSubmitted == "true") {
attrs.classes = setOf("table-danger")
}
td { +item.id }
td {
routeLink("/problems/${item.id}") {
+item.title
}
}
// 「編輯題目」與「刪除題目」按鈕的欄位
if (state.isEditable) {
td {
routeLink("/problems/${item.id}/edit", className = "btn btn-primary") {
+"編輯"
}
routeLink("/problems/${item.id}/delete", className = "btn btn-danger") {
+"刪除"
}
}
}
}
}
}
}
}
}
我們讓「新增按鈕」會路由到 /problems/new
,「編輯按鈕」會路由到 problems/:id/edit
,「刪除按鈕」會路由到 problems/:id/delete
。關於「新增題目」和「編輯題目」的路由設定部分如下所示:
route("/problems", exact = true) { problemsArticle { } }
route("/problems/new", exact = true) { problemForm { } }
route<IdProps>("/problems/:id", exact = true) { problemDetailArticle {
attrs.problemId = it.match.params.id
}}
route<IdProps>("/problems/:id/edit") {
problemForm {
attrs.problemId = it.match.params.id
}
}
要注意一下這個順序,如果將 /problems/:id
放在 /problems/new
前面的話,你的 new
就會被 :id
吃成參數,就會輸出錯誤的內容,所以要記得將 /problems/new
放在 /problems/:id
路由的前面。
那接著就可以嘗試執行看看了!首先先進入題目總列表的地方,登入我們之前測試用的高級使用者,應該就會看到剛剛加的這些按鈕,如下圖所示:
點選「新增題目」後,可以看到一個全空的題目表單頁面,如下圖所示:
可以嘗試輸入一份題目的資料,如下圖所示:
按下完成後,應該就可以在總列表中看到該題目出現了。
那接著可以嘗試按下「編輯」按鈕,應該就可以看到剛剛新增的資料直接被填在表格上,可以嘗試編輯完後再存入一次,這次應該就會直接跳到題目內文,看看剛剛修改的內容是否有呈現在上面了。
在剛才處理「新增題目」與「編輯題目」的功能時,我們已經先將「刪除題目」的按鈕做好了,所以現在只要來做刪除題目的 component 即可,底下是 ProblemDeleteComponent
的內容:
import kotlinx.coroutines.*
import kotlinx.html.classes
import react.*
import react.dom.*
import react.redux.rConnect
import react.router.dom.redirect
import react.router.dom.routeLink
import redux.RAction
import redux.WrapperAction
external interface ProblemDeleteComponentProps: RProps {
var problemId: Int
}
external interface ProblemDeleteComponentState: RState {
var isDelete: Boolean
var onDelete: (Int) -> Unit
}
class ProblemDeleteComponent: RComponent<ProblemDeleteComponentProps, ProblemDeleteComponentState>() {
override fun ProblemDeleteComponentState.init() {
isDelete = false;
onDelete = {
val mainScope = MainScope()
mainScope.launch {
Fetcher.createProblemDeleteFetcher(it).fetch("DELETE")
setState {
isDelete = true
}
}
}
}
override fun RBuilder.render() {
if (!state.isDelete) {
state.onDelete(props.problemId)
} else {
redirect(to = "/problems")
}
}
}
fun RBuilder.problemDeleteComponent(handler: RElementBuilder<ProblemDeleteComponentProps>.() -> Unit): ReactElement =
child(ProblemDeleteComponent::class, handler)
基本上與登出的部分相同,看到尚未開始進行刪除的動作就執行刪除用的 state.onDelete()
函式,做完後就會回到總題目列表的頁面。接著在 App
的路由處,讓 /problems/:id/delete
會導向到這個 component 去吧!
route<IdProps>("/problems/:id/delete") {
problemDeleteComponent {
attrs.problemId = it.match.params.id
}
}
做完後就可以嘗試按按看總列表的「刪除」按鈕,你會發現它按下去之後就直接將題目刪掉了,感覺有點可怕。我們可以將原本「刪除按鈕」的部分,改成與之前「刪除測資」的按鈕一樣,利用 window.confirm()
函式做一個防呆的效果,如下程式碼所示:
external interface ProblemsArticleState: RState {
/* ...... 其餘的資料 ...... */
// 增加要導向刪除的題目編號
var redirectToProblemId: Int?
}
class ProblemsArticle: RComponent<RProps, ProblemsArticleState>() {
override fun ProblemsArticleState.init() {
// 先設定為 null
redirectToProblemId = null
/* ...... 其餘的程式碼 ...... */
}
override fun RBuilder.render() {
mainArticle {
// 如果有要刪除題目的話,導向到刪除題目的路由
if (state.redirectToProblemId != null) {
redirect(to = "problems/${state.redirectToProblemId}/delete")
} else {
/* ...... 其餘的程式碼 ...... */
// 從 routeLink 換成 button
button {
attrs.type = ButtonType.button
attrs.classes = setOf("btn", "btn-danger")
// 做防呆確認
attrs.onClickFunction = {
if (window.confirm("確定要刪除這筆題目嗎?")) {
setState {
redirectToProblemId = item.id.toInt()
}
}
}
+"刪除"
}
}
}
}
}
完成後,在刪除題目的時候就會跳出防呆提示了:
剩下的會員資料與遞交程式碼資料的部分,在目前的情況來說,可以只做新增的部分即可,能夠編輯會員、刪除會員、編輯遞交的程式碼或是刪除遞交的程式碼在目前的系統設計中比較沒什麼用,當然如果要讓會員可以修改密碼或是要讓超級管理員可以修改某個會員的權限的話,當然可以在編輯會員的部分進行處理,但這裡我們就先不管這些情況,就先來製作能夠註冊會員的功能吧!先建立能夠註冊會員用的 Fetcher
以及承裝資料的類別,如下程式碼所示:
fun createUserRegisterFetcher() = Fetcher<JustFetch>("$DATA_URL/users")
data class UserPostDTO (
val username: String,
val password: String,
val name: String,
val email: String
)
接著建立表單的 componet UserRegisterForm
,如下所示:
import kotlinx.browser.window
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import kotlinx.css.*
import kotlinx.html.*
import kotlinx.html.js.onChangeFunction
import kotlinx.html.js.onClickFunction
import kotlinx.html.js.onSubmitFunction
import org.w3c.dom.HTMLInputElement
import org.w3c.dom.HTMLTextAreaElement
import react.*
import react.dom.*
import react.router.dom.redirect
import styled.css
import styled.styledDiv
external interface UserRegisterFormState: RState {
var userPostDTO: UserPostDTO
var isSubmitted: Boolean
var isResultGet: Boolean
}
class UserRegisterForm: RComponent<RProps, UserRegisterFormState>() {
override fun UserRegisterFormState.init() {
isSubmitted = false
isResultGet = false
userPostDTO = UserPostDTO(
"",
"",
"",
""
)
}
override fun RBuilder.render() {
styledDiv {
css {
width = LinearDimension("80%")
margin = "30px auto"
}
if (state.isResultGet) {
redirect(to = "/")
} else {
h1 { +"註冊會員" }
form {
attrs.onSubmitFunction = onSubmitFunction@{
it.preventDefault()
if (state.isSubmitted) return@onSubmitFunction
val mainScope = MainScope()
mainScope.launch {
Fetcher.createUserRegisterFetcher().fetch(
"POST",
state.userPostDTO
)
setState {
isResultGet = true
}
}
}
formInputComponent {
attrs.inputType = InputType.text
attrs.id = "usernameInput"
attrs.name = "帳號"
attrs.currentValue = state.userPostDTO.username
attrs.onChange = {
val target = it.target as HTMLInputElement
setState {
userPostDTO.username = target.value
}
}
}
formInputComponent {
attrs.inputType = InputType.password
attrs.id = "passwordInput"
attrs.name = "密碼"
attrs.currentValue = state.userPostDTO.password
attrs.onChange = {
val target = it.target as HTMLInputElement
setState {
userPostDTO.password = target.value
}
}
}
formInputComponent {
attrs.inputType = InputType.text
attrs.id = "nameInput"
attrs.name = "顯示名稱"
attrs.currentValue = state.userPostDTO.name
attrs.onChange = {
val target = it.target as HTMLInputElement
setState {
userPostDTO.name = target.value
}
}
}
formInputComponent {
attrs.inputType = InputType.text
attrs.id = "emailInput"
attrs.name = "電子郵件信箱"
attrs.currentValue = state.userPostDTO.email
attrs.onChange = {
val target = it.target as HTMLInputElement
setState {
userPostDTO.email = target.value
}
}
}
button {
attrs.type = ButtonType.submit
attrs.classes = setOf("btn", "btn-primary")
+"註冊"
}
}
}
}
}
}
fun RBuilder.userRegisterForm(handler: RElementBuilder<RProps>.() -> Unit): ReactElement =
child(UserRegisterForm::class, handler)
依照其需要的資料,我們可以在表單中建立四個輸入欄位,分別是帳號、密碼、顯示名稱和電子郵件信箱,並讓註冊後可以利用 Fetcher
將資料傳上資料管理系統。
最後,就是在 LoginStatus
中,當使用者未登入的時候,增加一個進入註冊表單的按鈕,並在 App
裡面將路由掛上去,如下所示:
// LoginStatus.kt
routeLink("/register", className = "btn btn-primary") {
+"註冊"
}
// App.kt
route("/register") { userRegisterForm { } }
實作完後就可以嘗試執行看看,登出原本的帳號後,就可以看到「註冊」按鈕在上面了。
接著點進「註冊」按鈕,就會看到會員註冊的表單,可以試著註冊一個新會員試試看。
註冊完後會回到首頁,接著試著登入看看。
最後就會看到登入成功的結果了。
在使用表單製作的功能中,最後一個就是來做上傳程式碼的表單部分了。與之前的步驟相同,先將 Fetcher
與承裝資料的類型給設計出來,如下所示:
fun createSubmitCodeFetcher() = Fetcher<JustFetch>("$DATA_URL/submissions")
data class SubmissionPostDTO(
var language: String,
var code: String,
var problemId: Int
)
接下來就是實作能填寫程式碼的表單元件 SubmissionForm
,如下所示:
import kotlinx.browser.window
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import kotlinx.css.*
import kotlinx.html.*
import kotlinx.html.js.onChangeFunction
import kotlinx.html.js.onClickFunction
import kotlinx.html.js.onSubmitFunction
import org.w3c.dom.HTMLInputElement
import org.w3c.dom.HTMLSelectElement
import org.w3c.dom.HTMLTextAreaElement
import react.*
import react.dom.*
import react.router.dom.redirect
import styled.css
import styled.styledDiv
external interface SubmissionFormProps: RProps {
var problemId: Int
}
external interface SubmissionFormState: RState {
var submissionPostDTO: SubmissionPostDTO
var isSubmitted: Boolean
var isResultGet: Boolean
}
class SubmissionForm: RComponent<SubmissionFormProps, SubmissionFormState>() {
override fun SubmissionFormState.init() {
isSubmitted = false
isResultGet = false
submissionPostDTO = SubmissionPostDTO(
"kotlin",
"",
-1 // 會在送出時填入
)
}
override fun RBuilder.render() {
styledDiv {
css {
width = LinearDimension("80%")
margin = "30px auto"
}
if (state.isResultGet) {
redirect(to = "/submissions")
} else {
h2 { +"上傳程式碼" }
form {
attrs.onSubmitFunction = onSubmitFunction@{
it.preventDefault()
if (state.isSubmitted) return@onSubmitFunction
val mainScope = MainScope()
mainScope.launch {
Fetcher.createSubmitCodeFetcher().fetch(
"POST",
state.submissionPostDTO.apply {
problemId = props.problemId
}
)
setState {
isResultGet = true
}
}
}
div {
attrs.classes = setOf("form-group")
label {
attrs.htmlFor = "languageInput"
+"使用的程式語言"
}
select {
attrs.classes = setOf("form-control")
attrs.id = "languageInput"
attrs.value = state.submissionPostDTO.language
attrs.onChangeFunction = {
val target = it.target as HTMLSelectElement
setState {
submissionPostDTO.language = target.value
}
}
option {
attrs.value = "kotlin"
+"Kotlin"
}
option {
attrs.value = "c"
+"C"
}
option {
attrs.value = "java"
+"Java"
}
option {
attrs.value = "python"
+"Python"
}
}
}
formTextAreaComponent {
attrs.id = "codeInput"
attrs.name = "程式碼"
attrs.currentValue = state.submissionPostDTO.code
attrs.onChange = {
val target = it.target as HTMLTextAreaElement
setState {
submissionPostDTO.code = target.value
}
}
}
button {
attrs.type = ButtonType.submit
attrs.classes = setOf("btn", "btn-primary")
+"遞交程式碼"
}
}
}
}
}
}
fun RBuilder.submissionForm(handler: RElementBuilder<SubmissionFormProps>.() -> Unit): ReactElement =
child(SubmissionForm::class, handler)
這個表單中有幾處是與之前表單不同的地方,主要是選擇「使用程式語言」的部分從輸入框變成利用 <select>
與 <option>
建立一個下拉式選單以避免使用者填錯資料,以及傳入要解哪一題的 SubmissionFormProps.problemId
會在要傳送的時候再利用 apply()
這個函式附加進資料內。其餘的部分都和之前差不多。
建立了上傳程式碼的表單後,就可以在題目詳細資料頁面 ProblemDetailArticle
的下方加上這個表單,以方便使用者去遞交程式碼了。
h1 {
+"${problemDetailData.id}. ${problemDetailData.title}"
}
pre {
+problemDetailData.description
}
// 增加此行
submissionForm { attrs.problemId = props.problemId }
實作完後,就讓我們測試看看吧!首先先進題目的詳細頁面,就會看到遞交程式碼的表單在下方出現,如下圖所示:
試著解題試試看,接著送出你的程式碼後,就會進入遞交程式碼列表的頁面。
如果你的審核程式與 Redis 資料庫是有開啟的話,隔個幾分鐘後再重整一下網頁,應該就可以看到結果了。
在今天總算可以說是將整套 Online Judge 系統給完成了!如果你也跟著一起完成到這裡的話,給自己一個掌聲,慰勞自己一下吧!明天我們會再重新審視網頁專案,將一些尚未實作的部分(例如:錯誤處理、重新審核程式碼……等等)給補足,今天就先到這邊吧!
昨天我們完成了登入與登出相關的操作,接下來就讓我們一步一步完成接下來的頁面吧!
首先先讓我們從獲得題目總列表的資料來顯示的頁面開始吧!雖然我們已經在前面的天數中完成了題目總列表的頁面,但是我們還會希望在看題目總列表的時候,能夠順便顯示目前這個題目是否已經有被遞交程式碼過,甚至是有被解成功過,那該怎麼做呢?可以修改資料管理系統中的程式碼,在其與資料庫抓取題目資料後,另外將相對應題目的遞交程式碼資料找出來,查找是否有該使用者的遞交紀錄,並且是否其中有一筆紀錄的結果字串中出現了 Accepted
的字樣,就讓我們來改寫一下資料管理系統中的 GET /problems
的回傳內容吧!
route("/problems") {
authenticate(NORMAL_USER_AUTHENTICAION_NAME, optional = true) {
get {
val userIdAuthorityPrincipal = call.sessions.get<UserIdAuthorityPrincipal>()
var problems: List<Map<String, Any>>? = null
transaction {
val problemContents = ProblemTable.selectAll().map {
mutableMapOf(
"id" to it[ProblemTable.id].toString(),
"title" to it[ProblemTable.title]
)
}
if (userIdAuthorityPrincipal == null) {
problems = problemContents
} else {
val problemIds = problemContents.mapNotNull { it?.get("id")?.toInt() }
val minProblemId = problemIds.min()
val maxProblemId = problemIds.max()
if (minProblemId != null && maxProblemId != null) {
val distinctIdCount = SubmissionTable.id.countDistinct()
val acceptedResultSum = SubmissionTable.result.like("Accepted%")
.castTo<Int>(IntegerColumnType())
.sum()
val submissions = SubmissionTable
.slice(
SubmissionTable.problemId,
distinctIdCount,
acceptedResultSum
).select {
SubmissionTable.problemId.lessEq(maxProblemId).and(
SubmissionTable.problemId.greaterEq(minProblemId)
)}.groupBy(SubmissionTable.problemId)
.forEach { row ->
val problemElement = problemContents.first {
it?.get("id") == row[SubmissionTable.problemId].toString()
}
val acceptedResultSum = row[acceptedResultSum]
problemElement["isSubmitted"] = (row[distinctIdCount] > 0).toString()
problemElement["isAccepted"] = (acceptedResultSum != null && acceptedResultSum > 0).toString()
}
}
problems = problemContents
}
}
call.respond(
mapOf(
"data" to problems
)
)
}
}
/* ...... 其餘的內容 ...... */
}
這裡我們增加了驗證機制,在驗證到使用者有登入的情況,就會多做查找其遞交程式碼的紀錄。在這些紀錄中,我們利用 groupBy()
函式讓資料庫以題目編號去群組起來這些資料,並算出提交的程式碼筆數 SubmissionTable.id.countDistinct()
與 AC 的程式碼筆數 SubmissionTable.result.like("Accepted%").castTo<Int>(IntegerColumnType()).sum()
。透過這兩筆計算,我們就可以在回傳的資料裡面多回傳是否有遞交過 isSubmitted
與是否有 AC 過的 isAccepted
這兩項資料欄位。
接著在批改系統網頁專案的 ProblemData
裡,增加可以接受資料管理系統回傳的 isSubmitted
與 isAccepted
這兩筆欄位的資料,如下程式碼所示:
data class ProblemData(
val id: String,
val title: String,
val isSubmitted: String? = null,
val isAccepted: String? = null
)
最後就可以在 ProblemArticle
中,根據這兩筆資料去顯示每道題目的狀態,如下所示:
tbody {
for (item in state.problemsData) {
tr {
if (item.isAccepted == "true") {
attrs.classes = setOf("table-success")
} else if (item.isSubmitted == "true") {
attrs.classes = setOf("table-danger")
}
td { +item.id }
td {
routeLink("/problems/${item.id}") {
+item.title
}
}
}
}
}
在 tbody
裡顯示各筆題目資料的部分,我們透過判斷各筆題目是否有遞交過以及是否 AC 過的狀態,來對該行題目資料代入不同的 Boostrap 表格樣式,就可以藉此顯示這道題目目前的狀態為何。實作完後,應該就可以看到如下的結果了:
實作完題目總列表頁面後,接著就要去獲取每題題目的詳細資料並將之顯示出來了。首先,先將題目詳細資料的 Fetcher
產生函式寫出來吧!如下程式碼所示:
fun createProblemDetailFetcher(id: Int) = Fetcher<ProblemDetailWrapperData>("$DATA_URL/problems/$id")
data class ProblemDetailWrapperData(
val data: ProblemDetailData
)
data class ProblemDetailData(
val id: String,
val title: String,
val description: String
)
有了 Fetcher
後,將相對應要去顯示題目詳細資料的 component 給製作出來,底下是其 component ProblemDetailArticle
的程式碼內容:
external interface ProblemDetailArticleProps: RProps {
var problemId: Int
}
external interface ProblemDetailArticleState: RState {
var problemDetailData: ProblemDetailData?
var onLoad: (Int) -> Unit
}
class ProblemDetailArticle: RComponent<ProblemDetailArticleProps, ProblemDetailArticleState>() {
override fun ProblemDetailArticleState.init() {
problemDetailData = null
onLoad = {
val mainScope = MainScope()
mainScope.launch {
val remoteProblemDetailData = Fetcher.createProblemDetailFetcher(it).fetch()
setState {
problemDetailData = remoteProblemDetailData.data
}
}
}
}
override fun RBuilder.render() {
mainArticle {
val problemDetailData = state.problemDetailData
if (problemDetailData == null || problemDetailData.id != props.problemId.toString()) {
state.onLoad(props.problemId)
} else {
h1 {
+"${problemDetailData.id}. ${problemDetailData.title}"
}
pre {
+problemDetailData.description
}
}
}
}
}
fun RBuilder.problemDetailArticle(handler: RElementBuilder<ProblemDetailArticleProps>.() -> Unit): ReactElement =
child(ProblemDetailArticle::class, handler)
我們讓這個元件可以透過 props 來知道要顯示的題目編號為多少,接著就利用 state 定義中的 onLoad()
函式在尚未拿到資料 state.problemDetailData
時,會去與資料管理系統進行抓取資料的動作。抓到資料後,就會更新其內容為抓到的資料。
資料管理系統的部分,要注意我們不可以讓使用者得知該筆題目有哪些測資,故要將回傳的資料中帶有 TestCase
的部分拿掉。由於之後要進行題目修改的時候,我們可能還是會需要這些測試資料,故我們先把原本的路由部分換成 GET /problems/:id/all
,而再來實作新的 GET /problem/:id
,如下所示:
// Problem.kt
data class ProblemDetailData(
val id: String,
val title: String,
val description: String
)
// Application.kt
get {
val requestId = call.parameters["id"]?.toInt() ?:
throw BadRequestException("The type of Id is wrong.")
var responseData: ProblemDetailData? = null
transaction {
val requestProblem = ProblemTable.select {
ProblemTable.id.eq(requestId)
}.first()
responseData = ProblemDetailData(
id = requestProblem[ProblemTable.id].toString(),
title = requestProblem[ProblemTable.title],
description = requestProblem[ProblemTable.description]
)
}
call.respond(mapOf("data" to responseData))
}
最後讓我們在批改系統網頁專案的路由區塊改成如下程式碼所示的樣子:
route<IdProps>("/problems/:id") { problemDetailArticle {
attrs.problemId = it.match.params.id
}}
這樣應該就可以看到題目內容了!
有了上面的實作經驗後,基本上另外兩種資料的總列表頁面應該就可以隨之做出來了。首先先來實作使用者列表頁面,讓使用者列表資料可以從資料管理系統取得,如下所示:
route("/users") {
get {
var users: List<Map<String, Any>>? = null
transaction {
val userContents = UserTable.selectAll().map {
mutableMapOf(
"id" to it[UserTable.id].toString(),
"name" to it[UserTable.name]
)
}
val solvedProblemCount = mutableMapOf<Int, Int>()
val acPairs = SubmissionTable
.slice(SubmissionTable.userId, SubmissionTable.problemId)
.select {
SubmissionTable.result.like("Accepted%")
}.groupBy(SubmissionTable.userId, SubmissionTable.problemId)
.forEach {
val userId = it[SubmissionTable.userId]
solvedProblemCount[userId] = solvedProblemCount.getOrDefault(userId, 0) + 1
}
for (userContent in userContents) {
val userContentId = userContent["id"]
if (userContentId != null) {
userContent["solvedProblemCount"] = solvedProblemCount.getOrDefault(
userContentId.toInt(),
0
).toString()
}
}
users = userContents
}
call.respond(
mapOf(
"data" to users
)
)
}
}
除了使用者的編號與名稱外,我們讓使用者列表會順便回傳其解成功的題目數量。透過與資料庫進行遞交程式碼的查詢,利用 userId
與 problemId
兩者進行群組的動作,最後再算每個使用者有解了幾題即可。實作完後,接著一樣在網頁專案中設計可以把上面回傳的資料給接下來的 Fetcher
與資料型態:
fun createUsersFetcher() = Fetcher<UsersData>("$DATA_URL/users")
data class UsersData(
val data: Array<UserData>
)
data class UserData(
val id: String,
val name: String,
val solvedProblemCount: String
)
然後設計出可以顯示使用者列表資料的 component,大體內容與 ProblemsArticle
相同,這裡就不再贅述了,程式碼如下:
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import kotlinx.html.classes
import react.*
import react.dom.*
import react.router.dom.routeLink
external interface UsersArticleState: RState {
var usersData: List<UserData>
}
class UsersArticle: RComponent<RProps, UsersArticleState>() {
override fun UsersArticleState.init() {
usersData = listOf()
val mainScope = MainScope()
mainScope.launch {
val remoteUsersData = Fetcher.createUsersFetcher().fetch()
setState {
usersData = remoteUsersData.data.toList()
}
}
}
override fun RBuilder.render() {
mainArticle {
h1 {
+"使用者列表"
}
table {
attrs.classes = setOf("table", "table-bordered", "table-striped")
thead {
attrs.classes = setOf("thead-dark")
tr {
th { +"編號" }
th { +"名稱" }
th { +"解題數" }
}
}
tbody {
for (item in state.usersData) {
tr {
td { +item.id }
td { +item.name }
td { +item.solvedProblemCount }
}
}
}
}
}
}
}
fun RBuilder.usersArticle(handler: RElementBuilder<RProps>.() -> Unit): ReactElement =
child(UsersArticle::class, handler)
最後讓網頁的路由能夠顯示其內容即可。
route("/users", exact = true) { usersArticle { } }
重新執行網頁專案,點選「使用者列表」,應該就可以看到結果了。
剩下最後一個遞交程式碼列表頁面的做法也與上述相同,先做出相對應的資料管理系統 API:
route("/submissions") {
get {
var submissions: List<Map<String, Any>>? = null
transaction {
submissions = (SubmissionTable innerJoin ProblemTable innerJoin UserTable)
.slice(
SubmissionTable.id,
UserTable.name,
ProblemTable.id,
ProblemTable.title,
SubmissionTable.language,
SubmissionTable.result,
SubmissionTable.executedTime
).selectAll()
.orderBy(SubmissionTable.id, SortOrder.DESC)
.map {
mapOf(
"id" to it[SubmissionTable.id].toString(),
"name" to it[UserTable.name],
"problemId" to it[ProblemTable.id],
"title" to it[ProblemTable.title],
"language" to it[SubmissionTable.language],
"result" to it[SubmissionTable.result],
"executedTime" to it[SubmissionTable.executedTime]
)
}
}
}
這裡為了要讓 SubmissionTable
中所記錄的 userId
和 problemId
可以變成其名稱,故我們就將 SubmissionTable
、UserTable
和 ProblemTable
利用 Join 去結合起來,並且由於一般遞交程式碼顯示的排序都是由新到舊,所以這裡就將查詢的結果依照編號由大到小排序,利用 orderBy(SubmissionTable.id, SortOrder.DESC)
即可得到此效果。
實作完 API 後,接著就在批改系統網頁專案中實作可以接取此資料的 Fetcher
,如下所示:
fun createSubmissionsFetcher() = Fetcher<SubmissionsData>("$DATA_URL/submissions")
data class SubmissionsData(
val data: Array<SubmissionData>
)
data class SubmissionData(
val id: String,
val name: String,
val problemId: String,
val title: String,
val language: String,
val result: String,
val executedTime: String
)
接著實作會將抓取資料結果顯示的 component SubmissionsArticle
,如下程式碼所示:
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import kotlinx.html.classes
import react.*
import react.dom.*
import react.router.dom.routeLink
external interface SubmissionsArticleState: RState {
var submissionsData: List<SubmissionData>
}
class SubmissionsArticle: RComponent<RProps, SubmissionsArticleState>() {
override fun SubmissionsArticleState.init() {
submissionsData = listOf()
val mainScope = MainScope()
mainScope.launch {
val remoteSubmissionsData = Fetcher.createSubmissionsFetcher().fetch()
setState {
submissionsData = remoteSubmissionsData.data.toList()
}
}
}
override fun RBuilder.render() {
mainArticle {
h1 {
+"遞交程式碼列表"
}
table {
attrs.classes = setOf("table", "table-bordered", "table-striped")
thead {
attrs.classes = setOf("thead-dark")
tr {
th { +"編號" }
th { +"使用者名稱" }
th { +"題目名稱" }
th { +"使用程式語言" }
th { +"審核結果" }
th { +"執行時間(秒)" }
}
}
tbody {
for (item in state.submissionsData) {
tr {
td { +item.id }
td { +item.name }
td { routeLink("/problems/${item.problemId}") { +item.title } }
td { +item.language }
td { +item.result }
td { +item.executedTime }
}
}
}
}
}
}
}
fun RBuilder.submissionsArticle(handler: RElementBuilder<RProps>.() -> Unit): ReactElement =
child(SubmissionsArticle::class, handler)
最後讓路由可以顯示其內容即可。
route("/submissions", exact = true) { submissionsArticle { } }
重新執行網頁專案,應該就可以看到遞交程式碼的列表了。
今天我們完成了與資料管理系統獲取資料相關操作的頁面顯示。由於使用者與遞交程式碼的詳細資料顯示部分,以目前存在資料庫的資料來說,似乎就沒有其他一定要丟出來給別人看的資料欄位,故這裡就忽略不實作了。如果你覺得欄位上還有其他詳細資料可以顯示出來的話,也可以自己試著實作看看,基本上都是同樣的步驟去處理就可以了。那麼明天就讓我們繼續將其他的操作頁面完成吧,敬請期待!
昨天我們建立了 HTTPS 連線,藉以讓使用者可以登入網站。不過雖然已經可以登入網站了,但是卻還是有登入後各個元件之間狀態無法同步資料的問題,究竟我們該如何解決這個問題呢?
首先,我們先從網頁的 Virtual DOM 結構圖來找出我們碰到的問題發生在哪裡,底下是這個網頁目前的 Virtual DOM 結構圖:
我們想要做的事情是,在 LoginForm
登入後能夠通知 LoginStatus
重新去確認使用者的登入狀態,其資料傳遞的過程如下圖紅線所標示之處:
你會發現在圖中,LoginForm
必須要在登入完後,利用一些方式(例如:在 props 裡面放入一個可以回傳資料回來的函式)來將已經登入的資訊傳到上層的上層 App
中,App
又要再將這個資訊傳給下層的下層 LoginStatus
裡面,其流程非常的複雜,而且寫成程式也不好維護。那究竟有沒有一個比較好的作法呢?
這時候就要來介紹一下 Redux 這個套件啦!Redux 是一個用來處理資料、邏輯與視覺元件之間連接關係的框架,它利用單向流的方式來解決這三者之間交錯複雜的關係。除此之外,Redux 也可以利用 React Redux 這個銜接套件去與 React 良好的結合在一起使用。那 Redux 的架構到底長什麼樣子呢?以上面的例子來說,用 Redux 的架構就會變成如下所示的樣子:
首先,LoginForm
會在登入後發送 Action
通知要重新確認會員登入狀態的動作請求,這個 Action
會被 Dispatcher
接收,並執行相對應的工作,接著 Dispatcher
就會將剛執行的 Action
告訴用來存放整個網頁狀態資料的 Store
內的 Reducer
,讓 Reducer
可以透過 Action
以及目前的狀態 State
去產生下一個狀態,並存放回 Store
中。最後,由於 Store
中的狀態被改變,這時就會通知位於 View
中的 LoginStatus
,讓它重新更新目前登入的狀態。
在這個架構之中,你不用擔心某兩個元件之間如果需要互相影響該怎麼辦。所有元件要進行變更時,都是透過丟出 Action
去更新 Store
中的 State
,而與該 State
相關的元件就可以直接根據 Store
中的 State
的變更去顯示不一樣的內容,這樣也就解決了在一個複雜的 Virtual DOM 架構中,元件與元件之間互相溝通的複雜資料流問題。
了解了 Redux 的原理後,就讓我們先在專案中安裝 Redux 和 React Redux 吧!在 build.gradle.kts
的 dependencies
區塊中,增加下面兩行來進行安裝:
implementation("org.jetbrains:kotlin-redux:4.0.0-pre.117-kotlin-1.4.10")
implementation("org.jetbrains:kotlin-react-redux:5.0.7-pre.117-kotlin-1.4.10")
安裝完以後,就讓我們一步一步把登入會員流程導入 Redux 吧!首先先從整個網站的 State
開始定義,目前因為只有登入會員的部分,所以我們就先從會員資料的狀態開始定義,如底下程式碼所示:
// 定義 Fetcher 結束後的狀態
enum class FetchState {
Pending, Rejected, Fulfilled
}
// 定義會員資料的狀態
data class UserDataState (
val fetchState: FetchState,
val userCheckDTO: UserCheckDTO
)
// 定義整個網頁專案的狀態
data class AppState(
val userDataState: UserDataState
)
// 幫助產生預設狀態的函式
fun createAppState() = AppState(UserDataState(FetchState.Pending, UserCheckDTO()))
首先先定義了整個網頁專案狀態的類別 AppState
,接著在裡面放入一個用來記得會員相關資料狀態的類別 UserDataState
,裡面包含了從 Fetcher
物件回傳的結果狀態與代表回傳內容的 UserCheckDTO
物件。Fetcher
回傳的狀態在這裡定義有三個不同的值,分別是需要傳送的 Pending
、被拒絕的 Rejected
和已經完成的 Fulfilled
。定義完這些狀態類別後,我們就再定義一個可以用來產生預設狀態的函式 createAppState()
,讓我們可以方便在一開始的時候就能產生出初始狀態出來。
有了 State
以後,接著要來定義 Reducer
的部分。由於 Reducer
會吃要進行的 Action
以及目前狀態 State
的值,去產生下一個 State
狀態出來,故我們就一併將 Action
的類別也一起定義出來,如下程式碼所示:
import redux.RAction
class CheckUserAction: RAction
class UpdateUserAction(val userCheckDTO: UserCheckDTO): RAction
fun reducer(state: AppState, action: RAction) =
when (action) {
is CheckUserAction ->
AppState(UserDataState(FetchState.Pending, state.userDataState.userCheckDTO))
is UpdateUserAction ->
AppState(UserDataState(FetchState.Fulfilled, action.userCheckDTO))
else -> state
}
在這裡我們定義了兩個 Action
,分別是「要求確認用戶資料」的 CheckUserAction
以及「更新用戶資料狀態」的 UpdateUserAction
。每一個 Action
都必須繼承 RAction
這個類別,藉以讓 Redux 知道這是有可能發生的 Action
。
而 Reducer
的部分則是透過目前的 State
和上述的兩個 Action
去決定下一個 State
會長什麼樣子。如果 Reducer
收到「要求確認用戶資料」的動作 CheckUserAction
的話,則就將會員資料狀態中的 fetchState
改成 Pending
,其餘狀態不變,產生一個新的 AppState
物件回傳回來讓 Store
更新。而如果 Reducer
收到「更新用戶資料狀態」的動作 UpdateUserAction
的話,則就將 fetchState
改成已完成的 Fulfilled
,並將需要更新的資料更新進 State
內,一樣產生出一個新的 AppState
物件回傳回來讓 Store
更新。
有了 State
和 Reducer
後,我們就可以利用這兩個東西生出網頁要用來存放 State
的 Store
了。在 main()
函式中,我們需要生成一個新的 Store
,並將這個 Store
利用 Provider
這個 component 將產生出來的 Store
物件與我們的 App
component 進行綁定的動作,如下程式碼所示:
fun main() {
val store = createStore(::reducer, createAppState(), rEnhancer())
render(document.getElementById("root")) {
provider(store) {
app { }
}
}
}
做完了前置作業後,接著就可以讓我們來將登入流程修改成會透過 Action
和 Store
來更新狀態吧!首先,先將 LoginStatus
原本利用 React 的 state 來做變更的部分,改成只透過傳遞進來的參數進行狀態變更的 props,其 props 的資料定義如下所示:
external interface LoginStatusProps: RProps {
var isFetchPending: Boolean
var userCheckDTO: UserCheckDTO
var onFetchPending: () -> Unit
}
裡面三個值分別是目前會員資料拉取的狀態是否為 Pending
、使用者的會員資料以及當會員資料拉取狀態為 Pending
時要呼叫的函式。有了這個 props 後,我們的 LoginStatus
就可以改成如下的形式:
class LoginStatus: RComponent<LoginStatusProps, RState>() {
override fun RBuilder.render() {
div {
attrs.classes = setOf("ml-md-auto")
if (props.isFetchPending) {
props.onFetchPending()
} else {
if (props.userCheckDTO.userId != null) {
div {
attrs.classes = setOf("navbar-text")
+"歡迎光臨,${props.userCheckDTO.name}!"
}
routeLink("/logout", className = "btn btn-primary") {
+"登出"
}
} else {
div {
attrs.classes = setOf("navbar-text")
+"歡迎光臨,訪客!"
}
routeLink("/login", className = "btn btn-primary") {
+"登入"
}
}
}
}
}
}
fun RBuilder.loginStatus(handler: RElementBuilder<LoginStatusProps>.() -> Unit): ReactElement =
child(LoginStatus::class, handler)
LoginStatus
不再吃 state 去變更資料,而是利用 props 來改變其元件內容的值。與上次不同的部分除了 state 換成了 props 以外,也多了一個動作是「如果目前會員資料的拉取狀態為 Pending
的話,要呼叫 props 中的 onFetchPending
函式」。
有了這樣的 component 後,我們就要來讓這個 component 中使用的 props 資料能夠被 Redux 變更。我們必須要先將 props 裡的變數分成兩類:一類是當 Redux 的 Store
中所使用的 State
出現變更時,我們要跟著隨之改變的 props 內容值;而另外一類則是當 component 中需要發送 Action
時,要利用該 props 的內容值(通常是函式)去將 Action
發給 Dispatcher
。在我們的例子中,可以分成下面程式碼所示的兩類 props:
internal interface LoginStatusStateProps: RProps {
var isFetchPending: Boolean
var userCheckDTO: UserCheckDTO
}
internal interface LoginStatusDispatchProps: RProps {
var onFetchPending: () -> Unit
}
isFetchPending
和 userCheckDTO
都是要代給 LoginStatus
的 props 參數,而 onFetchPending
則是要讓 LoginStatus
可以呼叫去發送 Action
給 Dispacther
的 props 參數,故上面的兩類參數就各自分進 LoginStatusStateProps
以及 LoginStatusDispatchProps
中就分類完成了。
有了這兩類 props 後,我們就可以利用 Redux 提供的 rConnect()
函式將這兩類資料該怎麼改的函式以及 LoginStatus
給綁定起來,並生出一個新的 component,如下程式碼所示:
val connectedLoginStatus: RClass<LoginStatusProps> =
rConnect<AppState, RAction, WrapperAction, RProps, LoginStatusStateProps, LoginStatusDispatchProps, LoginStatusProps>({
state, _ ->
isFetchPending = state.userDataState.fetchState == FetchState.Pending
userCheckDTO = state.userDataState.userCheckDTO
}, {
dispatch, _ ->
onFetchPending = {
val mainScope = MainScope()
mainScope.launch {
val remoteUserCheckDTO = Fetcher.createUserCheckFetcher().fetch()
dispatch(UpdateUserAction(remoteUserCheckDTO))
}
}
})(LoginStatus::class.rClass)
程式碼看起來有點複雜,但你可以先不要去管那長長的類別參數 <AppState, ......>
。基本上 connectedLoginStatus
就是利用 rConnect()
代入兩個匿名函式後所產生的物件,去綁定原有的 component 而產生出來的新 component。兩個匿名函式分別代表的是 State
怎麼去改變剛剛分類出來的 LoginStatusStateProps
裡的參數,以及如何讓 component 對 Dispatcher
發送 Action
的 LoginStatusDispatchProps
裡的參數。最後將綁定好函式的物件再與 LoginStatus
的類別進行綁定即可。至於 rConnect
與 connectedLoginStatus
所要填的類別參數 <AppState, ......>
就根據你要做的事情填入正確的類別參數即可。WrapperAction
和 RProps
為預設 rConnect()
會使用到的類別參數,由於我們沒有用到這兩者,故就放預設類別即可。
在 rConnect()
設定的函式裡面,isFetchPending
的值就是根據 State
中的會員資料狀態的 fetchState
是否為 Pending
去做判斷。而 userCheckDTO
則是 State
中的會員資料狀態裡,來自資料管理系統回傳回來的值。最後 onFetchPending
函式就是讓 LoginStatus
能夠呼叫 Fetcher
去抓取目前會員登入的資料,並在抓到資料後對 Dispatcher
發送要更新會員資料狀態的 Action
去更新 Store
中的資料,這樣就改完 LoginStatus
的部分了。
相同的道理,我們也可以來將 LoginForm
綁定 Redux 架構,整體程式碼如下所示:
external interface LoginFormState: RState {
var username: String
var password: String
}
external interface LoginFormProps: RProps {
var isUserIdExisted: Boolean
var onSubmit: (String, String) -> Unit
}
internal interface LoginFormStateProps: RProps {
var isUserIdExisted: Boolean
}
internal interface LoginFormDispatchProps: RProps {
var onSubmit: (String, String) -> Unit
}
val ConnectedLoginForm: RClass<LoginFormProps> =
rConnect<AppState, RAction, WrapperAction, RProps, LoginFormStateProps, LoginFormDispatchProps, LoginFormProps>({
state, _ ->
isUserIdExisted = state.userDataState.userCheckDTO.userId != null
}, {
dispatch, _ ->
onSubmit = { username, password ->
val mainScope = MainScope()
mainScope.launch {
Fetcher.createUserLoginFetcher().fetch(
"POST",
UserLoginDTO(username, password)
)
dispatch(CheckUserAction())
}
}
})(LoginForm::class.rClass)
class LoginForm: RComponent<LoginFormProps, LoginFormState>() {
override fun LoginFormState.init() {
username = ""
password = ""
}
override fun RBuilder.render() {
styledDiv {
css {
width = LinearDimension("80%")
margin = "30px auto"
}
if (props.isUserIdExisted) {
redirect(to = "/")
}
else {
form {
attrs.onSubmitFunction = {
it.preventDefault()
props.onSubmit(state.username, state.password)
}
/* ...... 輸入框的部分 ...... */
}
}
}
}
}
fun RBuilder.loginForm(handler: RElementBuilder<LoginFormProps>.() -> Unit): ReactElement =
child(LoginForm::class, handler)
與上面 LoginStatus
比較不一樣的地方在於,我們在這個 component 中並沒有完全捨棄 state。由於輸入框輸入資料的更新並不會特別去影響到別的元件上的顯示,故我們還是可以讓輸入框的更新僅只更新自己的 state 即可,就不用再特別去利用 Redux 架構更新其值。
LoginForm
所使用的 props 總共有兩個值,分別是「確認資料內使用者 ID 是否存在的狀態」以及「表單遞交時要呼叫的函式」。「確認資料內使用者 ID 是否存在的狀態」會根據 Store
中存放的會員資料狀態裡,是否有已經登入會員後所得到的會員 ID 來做判斷。在 LoginForm
中如果發現狀態中已有會員 ID 的話,就表示用戶已經登入過了,所以就直接跳轉到首頁就好。而「表單遞交時要呼叫的函式」則就是將使用者輸入的帳密,透過 Fetcher
丟給資料管理系統,並在登入完後利用 CheckUserAction
讓網頁重新檢查登入狀態一次。如果在發送 CheckUserAction
後,檢查發現用戶已經確實成功登入了,那麼網頁就會透過上面的「確認資料內使用者 ID 是否存在的狀態」判斷而跳轉到首頁的位置去。
在設定好這兩個元件後,就讓我們將這兩個綁定好的元件換掉原本用在 Header
和 App
中的 LoginStatus
以及 LoginForm
元件吧!如下程式碼所示:
// Header.kt
connectedLoginStatus { } // 將 loginStatus { } 換掉
// App.kt
route("/login") { mainArticle { connectedLoginForm { } } } // 將 loginForm { } 換掉
換完後,將網站重新執行起來,並重新登入看看。應該就可以在登入後,看到右上角的登入狀態元件會即時的切換了!
能夠登入後就讓我們來寫登出元件吧!與前面的方式相同,我們可以定義出 LogoutComponent
以及綁定 Redux 的 connectedLogoutComponent
,如下所示:
external interface LogoutComponentProps: RProps {
var isUserIdExisted: Boolean
var onLogout: () -> Unit
}
private interface LogoutStateProps: RProps {
var isUserIdExisted: Boolean
}
private interface LogoutDispatchProps: RProps {
var onLogout: () -> Unit
}
val connectedLogoutComponent: RClass<LogoutComponentProps> =
rConnect<AppState, RAction, WrapperAction, RProps, LogoutStateProps, LogoutDispatchProps, LogoutComponentProps>({
state, _ ->
isUserIdExisted = state.userDataState.userCheckDTO.userId != null
}, {
dispatch, _ ->
onLogout = {
val mainScope = MainScope()
mainScope.launch {
Fetcher.createUserLogoutFetcher().fetch("POST")
dispatch(CheckUserAction())
}
}
})(LogoutComponent::class.rClass)
class LogoutComponent: RComponent<LogoutComponentProps, RState>() {
override fun RBuilder.render() {
if (props.isUserIdExisted) {
props.onLogout()
} else {
redirect(to = "/")
}
}
}
fun RBuilder.logoutComponent(handler: RElementBuilder<LogoutComponentProps>.() -> Unit): ReactElement =
child(LogoutComponent::class, handler)
LogoutComponent
所使用的 props 僅有兩個值,一個是與 LoginForm
相同的 isUserIdExisted
,用來確認會員是否已經登入;另外一個則是用來進行登出動作用的函式。LogoutComponent
內容則非常單純,如果使用者已登入,就呼叫登出函式;如果使用者已登出,就導向到首頁即可。登出函式的部分就利用登出的 Fetcher
去對資料管理系統發出登出的請求,登出完一樣發送需要重新檢查會員登入狀態的 CheckUserAction
即可。登出用的 Fetcher
創建函式如下程式碼所示:
fun createUserLogoutFetcher() = Fetcher<FetchResult>("$DATA_URL/users/logout")
最後就是在 App
增加登出的路由即可,如下所示:
route("/logout") { mainArticle { connectedLogoutComponent { } }}
重新執行網頁專案,在登入後點選登出按鈕,即可看到右上角的元件又再度變回「歡迎光臨,訪客!」的樣子了。
最後,就讓我們稍微來處理一下登入錯誤的話該怎麼辦吧!
首先先讓資料管理系統在遇到登入錯誤的時候,會回傳 {"OK": false}
的結果,如下所示:
post("/login") {
try {
/* ...... 原本的登入流程 ...... */
call.respond(mapOf("OK" to true))
} catch (e: Exception) {
call.respond(mapOf("OK" to false))
}
}
我們很簡單的利用 try-catch
的方式在出錯的時候直接 catch 起來回傳 OK
的值為 false
即可。這個部分當然你可以做得更細緻一點,例如你可以將究竟發生什麼錯誤給回傳回來,讓使用者更了解他在操作上發生了什麼錯誤,但這裡由於文章篇幅的關係,就先用這個簡單的方式去處理了。
接著讓我們在 UserDataState
中新增一個代表登入失敗的值:
data class UserDataState (
/* ...... 其餘的資料 ...... */
val isLoginError: Boolean = false
)
並新增兩個新的 Action
以及其在 Reducer
中要怎麼產生新 State
的流程:
class ResetLoginUserStateAction: RAction
class LoginUserErrorAction: RAction
fun reducer(state: AppState, action: RAction) =
when (action) {
/* ...... 其餘的 Action ...... */
is ResetLoginUserStateAction ->
AppState(UserDataState(state.userDataState.fetchState, state.userDataState.userCheckDTO, false))
is LoginUserErrorAction ->
AppState(UserDataState(state.userDataState.fetchState, state.userDataState.userCheckDTO, true))
else -> state
}
ResetLoginUserStateAction
是讓登入失敗的狀態被重設回去的 Action
,而 LoginUserErrorAction
則是在使用者登入錯誤後,將 State
改成登入失敗的狀態用的 Action
。
定義完這些要給 Redux 使用的部分後,接著就要來修改我們的 component 的部分。首先,在 LoginForm
所使用的 props 中,新增一個 isError
代表登入是否錯誤的狀態值:
external interface LoginFormProps: RProps {
/* ...... 其他的值 ...... */
var isError: Boolean
}
internal interface LoginFormStateProps: RProps {
/* ...... 其他的值 ...... */
var isError: Boolean
}
多了這個 isError
的值後,接著就來修改要給 rConnect
綁定的函式內的內容,如下程式碼所示:
val connectedLoginForm: RClass<LoginFormProps> =
rConnect<AppState, RAction, WrapperAction, RProps, LoginFormStateProps, LoginFormDispatchProps, LoginFormProps>({
state, _ ->
isError = state.userDataState.isLoginError
/* ...... 其他的內容 ...... */
}, {
dispatch, _ ->
onSubmit = { username, password ->
dispatch(ResetLoginUserStateAction())
val mainScope = MainScope()
mainScope.launch {
val result = Fetcher.createUserLoginFetcher().fetch(
"POST",
UserLoginDTO(username, password)
)
if (result?.OK == true) {
dispatch(CheckUserAction())
} else {
dispatch(LoginUserErrorAction())
}
}
}
})(LoginForm::class.rClass)
props 內的 isError
的值直接利用 Store
中所存的是否登入錯誤的狀態來更新即可,而在 onSubmit
遞交表單動作的函式內,則在一開始先重設登入錯誤的狀態,接著再用 Fetcher
進行登入動作後,判斷其結果是否成功。如果成功的話就照舊發送 CheckUserAction
重新確認使用者的登入狀態;而如果失敗的話就發送 LoginUserErrorAction
讓 Store
變更登入錯誤的狀態為 true
。
邏輯的部分做完後,最後就是在 LoginForm
component 中,放置表單的地方的前面新增一個顯示錯誤用的區塊,如下所示:
override fun RBuilder.render() {
styledDiv {
/* ...... 前面的程式碼部分 ...... */
if (props.isError) {
div {
attrs.classes = setOf("alert", "alert-danger")
+"登入失敗!請確認您輸入的帳號密碼是否正確。"
}
}
/* ...... 表單的程式碼部分 ...... */
}
}
在這裡我們判斷了 props 中的 isError
的值是否為 true
,如果是 true
的話,就顯示一個 div
區塊,裡面含有登入失敗的錯誤訊息,並且利用 Bootstrap 的 Alert 樣式去美化它。
完成後,應該就可以在登入失敗的時候看到如下的畫面了:
如果你希望在登入失敗的時候輸入框還能夠留住之前輸入的值的話,可以在 input
區塊裡面修改其 attrs.value
值為 state 所記錄下來的值,如下程式碼是記住帳號資料於輸入框的方式:
input {
attrs.value = state.username
}
今天我們利用 Redux 和 React Redux 去解決了複雜的元件與元件間互相更新資訊的資料流問題。有了這樣的結構後,我們就可以繼續來寫完其他資料的抓取與顯示內容的部分,就請各位繼續期待明天的內容囉!
昨天我們建立了登入頁面,但是卻遇到了連線不安全,無法進行跨領域修改 Cookie 的問題。究竟我們該如何建立一個安全的網路,來讓我們的資料管理系統能夠順利地去修改批改系統網頁這邊的 Cookie 值呢?
在網路傳輸的 Protocol 中,最常見的安全加密驗證方式就是使用 HTTPS(Hyper Text Transfer Protocol Secure)的方式傳遞資料。HTTPS 表示網站受到了 SSL/TLS 憑證的保護,資料傳輸之間會進行加密與驗證,以確保資料不會被竄改或是被竊取。而 SSL(Secure Sockets Layer)為一種用於保護網路傳輸以及防止資料在傳輸中間被人竊取的標準技術,可以防止之前我們在第十天的 MitM(中間人攻擊)的資安問題。不過由於 SSL 已經開始有安全性的問題出現,所以目前大多都是換成使用 TLS(Transport Layer Security)技術在處理安全傳輸資料的事情。TLS 是更新且更安全的 SSL 版本,一般現在提到 SSL 這個技術時,通常都是指 TLS 的意思,有時我們也可以將它用 SSL/TLS 來表示。
伺服器端必須先設定一份 SSL/TLS 憑證,藉以讓伺服器了解該如何對資料進行驗證、加密與解密,接著伺服器就會以 HTTPS 的 Protocol 去與客戶端進行溝通,在溝通過程中就會利用憑證對資料進行加密的傳輸與驗證,詳細的流程可以透過查找 SSL
、TLS
或是 HTTPS
來了解,這裡就先不贅述了。主要在這裡想強調的是,如果要讓資料管理系統去修改網頁這邊的 Cookie 值,我們勢必要先讓資料管理系統支援 SSL/TLS 的連線機制才行,那究竟該如何做呢?
為了要讓 SSL/TLS 憑證能夠被正常的使用,其生成的要求會蠻麻煩的。你會需要一個能夠生成並認證其憑證具有合法效力的數位憑證認證機構(Certificate Authority,縮寫為CA),來進行發放憑證與認證憑證的工作。一般常見可以使用的機構像是「Let’s Encrypt」,其為一個可以免費使用且生成憑證過程也比較不會太麻煩的 CA。但是如果我們現在只是要在測試環境建立這樣的連線,有沒有比較簡單的做法呢?我們可以使用 mkcert
、openssl
與 keytool
來生成可以讓 Ktor 運作的 SSL/TLS 憑證。首先,先讓我們安裝這些套件吧!
mkcert
的安裝方式可以見這個網站的 Installation
區塊,而 openssl
則在一般的 Unix 和Linux 系統內應該都有內建,如果是 Windows 的話可以來這個頁面進行下載。keytool
的部分在有安裝 Java 的情況下,應該就會有這個指令,你可以嘗試看看。
首先先利用終端機輸入以下指令,來讓自己的電腦變成一個本地端的 CA:
mkcert -install
接著就來生成允許常見的本地端網域的 SSL/TLS 憑證,輸入以下指令:
mkcert example.com "*.example.com" example.test localhost 127.0.0.1 ::1
生成完以後會產生出 ./example.com+5.pem
憑證檔和 ./example.com+5-key.pem
鑰匙檔這兩個檔案。有了這兩個檔案後,接著就將它們利用 OpenSSL 生成相對應的 .p12
檔案。
openssl pkcs12 -export -out ./keystore.p12 -inkey ./example.com+5-key.pem -in ./example.com+5.pem -name devtest
接著可能會需要輸入一個密碼,這裡由於是測試的環境,可以指定一個簡單的密碼即可。設定完後,會輸出 keystore.p12
這個檔案,再利用 keytool
讀取 keystore.p12
檔案後,生成出 keystore.jks
這個檔案即可,中間需要輸入的密碼與剛剛指定使用的密碼相同就可以了。
keytool -importkeystore -alias devtest -destkeystore ./keystore.jks -srcstoretype PKCS12 -srckeystore ./keystore.p12
最後,將生成出來的 keystore.jks
放置進資料管理系統專案的根目錄中,接著將專案中的 resources/application.conf
裡面增加使用 HTTPS 連線的 port,以及要加密使用的檔案和密碼即可,如下所示:
ktor {
deployment {
port = 8081
port = ${?PORT}
sslPort = 8082
sslPort = ${?PORT_SSL}
}
/* ...... 中間的部分 ...... */
security {
ssl {
keyStore = keystore.jks
keyAlias = devtest
keyStorePassword = [剛設定的密碼]
privateKeyPassword = [剛設定的密碼]
}
}
}
在這裡我們將 HTTPS 連線所使用的 port 設定在 8082,這樣就可以在批改系統網頁專案中,將 Fetcher
所使用的 DATA_URL
換成 https://127.0.0.1:8082
這個網址,讓批改系統網頁專案與資料管理系統進行 HTTPS 連線。
重新開啟批改系統網頁,在登入頁面的部分輸入帳號密碼,如下圖所示:
接著登入後可能會發現,雖然導向回了首頁,但好像還是沒有登入的狀態。
但是在重新整理網頁後,就會看到右上角變成登入後的狀態了。
如果你使用 Safari 瀏覽器,並且在重新整理網頁後還是沒有成功登入的話,記得進「Safari」內的「偏好設定…」中,選擇「隱私權」的 Tab,將「網站追蹤:防止跨網站追蹤」的選項關掉,如下圖所示:
今天我們成功在測試環境中建立了 HTTPS 連線,並且能夠利用帳號密碼去登入會員系統了。不過在登入完成後,卻發現狀態無法即時地反應在右上方的元件中。如果你有試著思考看看怎麼解決這個問題的話,就會發現你必須將你從 LoginForm
得到的登入成功的資訊,往上傳給根節點,再傳下來到 LoginStatus
告知要重新確認會員登入的狀態。這條路線實在有點過於複雜,究竟我們能不能有更方便的方式去更新這件事情呢?就請各位敬請期待明天的內容吧!
昨天我們美化了網頁的各個元件,讓它們看起來不再是那麼醜醜的了。在美化完網頁後,我們可以先來把網站的會員系統銜接進來,藉以讓我們方便去處理其他的資料顯示與操作用的介面。
首先,先讓我們在資料管理系統上,新增一個新的路由 /users/check
,以方便我們對目前使用者登入狀態的確認。
route("/users") {
authenticate(NORMAL_USER_AUTHENTICAION_NAME, optional = true) {
get("/check") {
val userIdAuthorityPrincipal = call.sessions.get<UserIdAuthorityPrincipal>()
if (userIdAuthorityPrincipal == null) {
call.respond(UserCheckDTO(null))
} else {
val userId = userIdAuthorityPrincipal.userId.toInt()
var authority = userIdAuthorityPrincipal.authority.toInt()
var name = ""
transaction {
val userData = UserTable.select { UserTable.id.eq(userId) }.first()
name = userData[UserTable.name]
authority = userData[UserTable.authority]
call.sessions.set(
SESSION_LOGIN_DATA_NAME,
UserIdAuthorityPrincipal(userId.toString(), authority.toString())
)
}
call.respond(UserCheckDTO(userId, name, authority))
}
}
}
}
利用 authenticate()
先對使用者進行驗證,並利用 optional = true
的引數讓這條路由不會因為使用者沒登入就發生認證失敗的錯誤。接著藉由取得 Session 內的資料來判斷使用者是否登入,沒有登入的話就回傳代表沒有登入的 null
,而有登入的話就從資料庫重新拉出使用者的狀態,更新 Session 內的資料,並回傳使用者的 ID、名稱以及權限大小。
這裡定義了 UserCheckDTO
作為回傳的參數型態,詳細程式碼如下所示:
data class UserCheckDTO (
val userId: Int? = null,
val name: String = "",
val authority: Int = 0
)
有了這個 API 之後,我們就可以在前端網頁上判斷使用者是否登入,藉以讓使用者可以看到登入前或是登入後的資訊,並且讓資料管理伺服器能夠重新更新 Session 的資料,以避免 Session 內的資料過舊。
在批改系統網頁的部分,我們一樣設計一個可以創建使用 /users/check
這個路由獲得資料的 Fetcher
物件,如下程式碼所示:
data class UserCheckDTO (
val userId: Int? = null,
val userName: String = "",
val authority: Int = 0
)
class Fetcher<T>(val path: String) {
companion object {
/* ...... 其餘的 create 函式內容 ...... */
fun createUserCheckFetcher() = Fetcher<UserCheckDTO>("$DATA_URL/users/check")
}
/* ...... fetch() 函式的內容 ...... */
}
在這裡我們設計了可以與資料管理系統對接資料的 UserCheckDTO
類別,並且設計了 createUserCheckFetcher()
去生出實際與 API /users/check
拉取資料的 Fetcher
物件。
有了這個 Fetcher
後,我們就可以試著在網頁的右上角新增一個登入與登出用的按鈕元件,創建一個新的 component LoginStatus
來處理這個事情,程式碼如下:
import kotlinx.coroutines.*
import kotlinx.html.classes
import react.*
import react.dom.*
import react.router.dom.routeLink
external interface LoginStatusState: RState {
var needCheck: Boolean
var userCheckDTO: UserCheckDTO
}
class LoginStatus: RComponent<RProps, LoginStatusState>() {
override fun LoginStatusState.init() {
needCheck = true
userCheckDTO = UserCheckDTO()
val mainScope = MainScope()
mainScope.launch {
val remoteUserCheckDTO = Fetcher.createUserCheckFetcher().fetch()
setState {
needCheck = false
userCheckDTO = remoteUserCheckDTO
}
}
}
override fun RBuilder.render() {
div {
attrs.classes = setOf("ml-md-auto")
if (!state.needCheck) {
if (state.userCheckDTO.userId != null) {
div {
attrs.classes = setOf("navbar-text")
+"歡迎光臨,${state.userCheckDTO.userName}!"
}
routeLink("/logout", className = "btn btn-primary") {
+"登出"
}
} else {
div {
attrs.classes = setOf("navbar-text")
+"歡迎光臨,訪客!"
}
routeLink("/login", className = "btn btn-primary") {
+"登入"
}
}
}
}
}
}
fun RBuilder.loginStatus(handler: RElementBuilder<RProps>.() -> Unit): ReactElement =
child(LoginStatus::class, handler)
程式碼與我們在 ProblemsArticle
的部分看到的差不多,基本上就是建立一個專屬於這個 component 可以用的 state 類別 LoginStatusState
,在裡面記錄兩個值,一個是紀錄是否需要與資料管理系統確認資料的變數,另外一個是透過資料管理系統回傳回來的 UserCheckDTO
物件。接著在 LoginStatus
的 component 裡面,先對 LoginStatusState.Init()
去做 state 的初始化,並利用 Fetcher.createUserCheckFetcher()
去獲得使用者的會員資料,更新進 state 中。
接著 RBuilder.render()
的部分,我們利用了 ml-md-auto
這個 Bootstrap 預設的 class
讓整個元件會位於最右側。透過 state 去判斷要回傳什麼樣的內容給使用這個 component 的元件,如果在已經不用跟資料管理系統確認的情況下,就會根據目前的狀態是否為已經登入的狀態,去回傳「登入」或是「登出」所要輸出的內容。裡面使用 navbar-text
這個 class
去調整裡面的文字顏色,並在 routeLink()
上使用 btn
去讓它變成一個按鈕的樣子,並用 btn-primary
設定好它的顏色。最後,就是將可以方便使用此 component 的擴充函式定義完,基本上這個 component 就定義好了。
我們將這個 component 放到 Header
的部分來使用,如下程式碼所示:
class Header: RComponent<RProps, RState>() {
override fun RBuilder.render() {
header {
nav {
/* ...... 其餘的導覽列內容 ...... */
// 增加此行
loginStatus { }
}
}
}
}
加完後,執行起來後就可以看到這個 component 出現在網頁上了,如下圖所示。
在上面登入狀態元件的程式碼部分,我們會讓登入和登出分別被導向到不同的路由設定去,分別是 /login
和 /logout
兩個路徑。首先,我們先來處理登入頁面 /login
的實作。
之前有提過批改系統網頁和資料管理系統由於位於不同的網域,其兩者之間的連線都必須要符合 CORS 守則。為了要讓登入時傳送的帳密資料能夠成功地被傳遞到資料管理系統,並且能夠讓資料管理系統回傳的 Set-Cookie
能夠正常運作,資料管理系統這邊需要先設定哪些 HTTP Method 被允許、哪些網域可以發送 HTTP request 過來、是否可以接受 JSON 格式的內容以及是否會需要利用 Cookie 進行驗證……等等的事情,如下程式碼所示:
install(Sessions) {
cookie<UserIdAuthorityPrincipal>(
SESSION_LOGIN_DATA_NAME,
storage = SessionStorageMemory()
) {
cookie.path = "/"
cookie.extensions["SameSite"] = "None"
cookie.extensions["Secure"] = "true"
}
}
install(CORS) {
method(HttpMethod.Get)
method(HttpMethod.Post)
method(HttpMethod.Put)
method(HttpMethod.Delete)
method(HttpMethod.Options)
anyHost()
allowCredentials = true
allowNonSimpleContentTypes = true
}
Cookie 的部分我們需要先設定 SameSite
為 None
,表示會需要設定或使用到的 Cookie 並非處在同個網域之中。Secure
參數則要設定 true
進去,讓伺服器能夠告知這個 Cookie 是經由安全的加密管道傳送的。但是目前就算你設定任何值進 Secure
的欄位中,其實都不會有什麼實質的用途,因為我們實際傳送 Cookie 的方式並不是真的經由安全的加密管道做傳輸的,關於這點我們在後面會再詳述。
至於 CORS 區塊,我們將預期接下來會使用到的 POST
、PUT
和 DELETE
三個方法都放上去,並且多放了一個 OPTIONS
方法。這個 OPTIONS
方法是做什麼用的呢?主要是作為 CORS preflight 的時候使用。當進行跨網域連線時,如果連線不是一般常見的連線需求(例如:傳遞 JSON 格式資料、使用 PUT
方法連線……等等),就會預先發送一個 OPTIONS
方法的 HTTP request 去確認伺服器是否允許等等即將要發送的實際 HTTP request。
最後則是將 allowCredentials
(允許認證機制)以及 allowNonSimpleContentTypes
(允許內容為非簡單型態,例如 JSON 格式內容就不是簡單型態)設定為 true
即可。
回到批改系統網頁專案,我們一樣先設計一個函式,讓它可以產生出與資料管理系統中的 /users/login
API 進行溝通的 Fetcher
物件。由於目前 Fetcher
只支援 GET
方法,所以我們先擴充它的功能,讓它可以代入想要使用的 HTTP Method 去做抓取資料的動作,程式碼如下所示:
class Fetcher<T>(val path: String) {
/* ...... create 相關函式的程式碼部分 ...... */
fun createHeaders(method: String): Headers {
val headers = Headers()
if (method == "POST") {
headers.append("Content-Type", "application/json")
}
return headers
}
suspend fun fetch(method: String = "GET", data: Any? = null): T =
window.fetch(path, RequestInit(
method,
mode = RequestMode.CORS,
credentials = RequestCredentials.INCLUDE,
headers = createHeaders(method),
body = if (data != null) JSON.stringify(data) else null
)).await()
.json().await()
.unsafeCast<T>()
}
首先,先讓 fetch()
可以傳入兩個值,分別是 method
和 data
,分別代表的是要傳遞使用的 HTTP Method 為何,以及要帶入的資料為何。由於要帶入的資料有很多不同的格式,所以這裡使用 Any?
來代替,讓它可以去存入各種不同的物件資料。
接著在呼叫 window.fetch()
的時候,代入第二個引數部分 RequestInit()
物件,其內部可以填入 HTTP Method、HTTP request 的模式、認證機制的使用方式、HTTP request 的 header 部分以及 HTTP request 的 body 內容部分。為了要能夠進行跨網域請求,mode
的引數部分需要代入 RequestMode.CORS
這個值。而為了要能夠進行跨網域的 Cookie 設定,則必須要要將 credentials
設定成 RequestCredentials.INCLUDE
。HTTP request 的 header 部分則與之前在 Postman 測試的時候一樣,由於要帶 JSON 格式資料過去給伺服器,故需要增加 Content-Type: application/json
這個值在 HTTP request 上,而 body 內容的部分則根據是不是有帶值來決定是否填入內容進去,填入的時候要利用 JSON.stringify()
函式去將物件轉成 JSON 格式的字串。
在這裡可以看到為了處理跨網域連線我們加了很多東西進去,我在這個部分只大概提了一下每一行程式碼加入的原因,有興趣了解詳細流程的話可以看看 MDN 文件上關於 CORS 的章節。
fetch()
函式擴充完後,我們就可以製作出與 /users/login
API 連線的 Fetcher
了!如下程式碼所示:
class Fetcher<T>(val path: String) {
companion object {
/* ...... 其餘的 create 函式內容 ...... */
fun createUserLoginFetcher() = Fetcher<FetchResult>("$DATA_URL/users/login")
}
/* ...... fetch() 函式內容 ...... */
}
data class UserLoginDTO (
val username: String,
val password: String
)
data class FetchResult(
val OK: Boolean?
)
為了因應要給 /users/login
的內容,以及其回傳回來的結果,這裡新增了 UserLoginDTO
和 FetchResult
兩個類別來處理兩者之間傳遞用的資料。
能夠跟資料管理系統溝通登入資料後,我們就來建立登入的頁面吧!建立一個新的 component LoginForm
,如下程式碼所示:
external interface LoginFormState: RState {
var isChecked: Boolean
var isSuccess: Boolean
var username: String
var password: String
}
class LoginForm: RComponent<RProps, LoginFormState>() {
override fun LoginFormState.init() {
isChecked = false
isSuccess = false
username = ""
password = ""
}
/* ...... render() 的部分 ...... */
}
與之前的程式碼結構大致相同,利用 LoginFormState
紀錄登入的狀態,並讓 LoginForm
component 去使用。裡面記錄了 isChecked
代表已經確認跟伺服器確認過了、isSuccess
代表登入是否成功、username
代表輸入的帳號為何、password
代表輸入的密碼為何。
而至於 LoginForm
的 RBuilder.render()
的部分,則整體視覺結構如下所示:
override fun RBuilder.render() {
styledDiv {
css {
width = LinearDimension("80%")
margin = "30px auto"
}
if (state.isChecked && state.isSuccess) {
/* ...... 導向回首頁 ...... */
}
else {
form {
div {
attrs.classes = setOf("form-group")
label {
attrs.htmlFor = "usernameInput"
+"帳號"
}
input {
attrs.type = InputType.text
attrs.id = "usernameInput"
attrs.classes = setOf("form-control")
}
}
div {
attrs.classes = setOf("form-group")
label {
attrs.htmlFor = "passwordInput"
+"密碼"
}
input {
attrs.type = InputType.password
attrs.id = "passwordInput"
attrs.classes = setOf("form-control")
}
}
button {
attrs.type = ButtonType.submit
attrs.classes = setOf("btn", "btn-primary")
+"登入"
}
}
}
}
}
整體結構由一個 <div>
標籤所包住,裡面在尚未登入或是尚未登入成功前,會輸出一個以 <form>
標籤為根目錄標籤的 Virtual DOM 結構。<form>
標籤代表的是表單的意思,在裡面會放置表單內容的結構。在這裡的 <form>
標籤內,我們放了三組東西在裡頭,分別是兩個 <div class="form-group">
來表示表單內兩組不同的輸入欄位,以及在表單填寫完後,用來讓使用者可以遞交表單用的按鈕 <button>
標籤。每一組 <div class="form-group">
中,主要是由一個 <label>
標籤表示輸入的內容涵義為何,以及一個 <input>
讓使用者可以輸入東西的文字輸入框。輸入框部分利用 type
屬性來展現不一樣的輸入效果,「帳號」的部分使用的是一般常見的文字輸入類型 InputType.text
,而「密碼」的部分使用的是會遮蔽輸入內容的 InputType.password
輸入類型。
表單本身在 Bootstrap 中也有一些預設的樣式可以設定,這裡我就直接打在程式碼上了,如果想知道詳情的話,可以看看 Bootstrap 的文件。
視覺部分處理好後,接著要來處理輸入元件的邏輯部分。首先先讓「帳號」與「密碼」的輸入標籤會改變 state 裡面的輸入內容,我們可以在這兩個輸入框的 onChangeFunction
事件上掛上我們要在內容改變時觸發的動作為何,如下程式碼所示:
override fun RBuilder.render() {
styledDiv {
/* ...... 中間的內容 ...... */
input {
attrs.type = InputType.text
attrs.id = "usernameInput"
attrs.classes = setOf("form-control")
// 利用 onChangeFunction 來修改 state 的值
attrs.onChangeFunction = {
val target = it.target as HTMLInputElement
setState {
username = target.value
}
}
}
/* ...... 中間的內容 ...... */
input {
attrs.type = InputType.password
attrs.id = "passwordInput"
attrs.classes = setOf("form-control")
// 利用 onChangeFunction 來修改 state 的值
attrs.onChangeFunction = {
val target = it.target as HTMLInputElement
setState {
password = target.value
}
}
}
}
/* ...... 中間的內容 ...... */
}
}
在兩個輸入框分別於 onChangeFunction
掛上會修改相對應內容的匿名函式,利用代入的事件發生相關參數裡的 target
去抓出事件發生所在的標籤物件,轉成正確的型態後,利用其標籤內所含的值去更新 state 的內容。這樣輸入框內容一有變更,網頁就會自動修正 state 中的值了。同理,我們在按下按鈕後要進行登入的動作也可以利用 <form>
標籤的 onSubmitFunction
來實作,如下程式碼所示:
form {
attrs.onSubmitFunction = {
it.preventDefault()
val mainScope = MainScope()
mainScope.launch {
val loginFetchResult = Fetcher.createUserLoginFetcher().fetch(
"POST",
UserLoginDTO(state.username, state.password)
)
setState {
isChecked = true
isSuccess = loginFetchResult?.OK != null && loginFetchResult.OK
}
}
}
/* ...... form 的內容 ...... */
}
首先利用事件發生相關參數中的 preventDefault()
將事件原本預設要發生的事情停止住,這裡就是將原本表單送出後會跳頁的過程給停止住。接著將使用者輸入的帳號密碼,透過 Fetcher
物件傳遞給資料管理系統,最後將回傳回來的結果更新到 state 上。拿到結果了以後,我們就可以在這個網頁如果登入行為已經完成,就導向到首頁去,如下程式碼所示:
override fun RBuilder.render() {
styledDiv {
/* ...... 樣式內容 ...... */
if (state.isChecked && state.isSuccess) {
// 利用 redirect 去導向回首頁
redirect(to = "/")
}
else {
/* ...... 表單內容 ...... */
}
}
}
利用 React Router 中的 redirect()
函式在使用者登入後,會導向回 /
首頁的地方,這樣這個登入頁面的 component 就完成了!
在 App
component 的地方,將路由到 /login
路徑上的內容輸出 LoginForm
這個 component,如下程式碼所示:
// LoginForm.kt
fun RBuilder.loginForm(handler: RElementBuilder<RProps>.() -> Unit): ReactElement =
child(LoginForm::class, handler)
// App.kt
route("/login") { mainArticle { loginForm { } } }
執行網頁後,就可以點擊右上方的「登入」按鈕,進到登入頁面了!
今天我們將登入狀態的確認元件以及登入頁面給完成了,但是如果你有使用看看的話,就會發現在輸入正確的帳號密碼後,雖然確實有導回到首頁,但不管怎麼重整網頁卻都是未登入狀態,到底是怎麼回事呢?
如果你有用 Chrome 的開發者工具,應該就可以看到在設定 Cookie 中的 Secure
欄位,會有一段訊息表示雖然有 Secure
參數,但這個兩者之間溝通用的連線並非是一段真正安全的連線,所以 Set-Cookie
的動作就會被擋下來。這就是在上面我們增加 Cookie 中的 Secure
參數時我所說的沒有用的意思,我們還是必須要建立一個安全的連線才行,那到底該怎麼做呢?敬請期待明天的內容吧!
昨天我們成功地從資料管理系統拉取了資料放在網頁上顯示,但由於目前的網頁實在還是太醜,再繼續將其他資料抓下來之前,就讓我們先來將網頁美化一下吧!
為了要美化網頁,最基本可以使用的方式就是利用 CSS 語言來進行美化的工作。這裡舉個簡單的例子,例如如果我們希望讓 <section>
標籤內的背景顏色呈現為黑色的話,可以像下面這樣的寫法:
section {
background: black;
}
CSS 語法以 [CSS 選擇器] { [要調整的樣式屬性名1]: [要調整的樣式屬性的值1]; [要調整的樣式屬性名2]: [要調整的樣式屬性的值2]; ...... }
的格式來對某個特定標籤進行樣式的調整。大括弧前面的是 CSS 選擇器
,用來選擇要套用樣式的標籤有哪些,而大括弧內則是樣式表的描述。像上面的例子就是要將所有的 <section>
標籤的背景 background
屬性設定為黑色 black
。
如果你不想讓所有 <section>
標籤的背景都改成黑色的話,可以在想要變更其樣式的標籤上,填入一個 class
的屬性值,像是 <section class="bgblack">
程式碼這樣。填完以後你就可以透過這個 class
屬性的值,去對要變更樣式的標籤進行選擇。使用 CSS 語法做這件事的程式碼如下所示:
.bgblack {
background: black;
}
在選擇器的部分,於名稱前面加上 .
,即代表要選擇套用此樣式表的標籤為其 class
屬性值為 .
後面的字串。故上面的例子就是讓 class
屬性值為 bgblack
的標籤,套用背景為黑色的樣式表。
CSS 語言中有很多可以選擇標籤的方法,以及可以修改顯示樣式的屬性,這裡就不詳細去談了,可以參考 MDN 的文件去了解該怎麼使用它們。
在這個專案裡面,我們並不會直接使用 CSS 語言來對標籤進行美化,而是使用 styled-components 和 Bootstrap 兩個套件去處理樣式美化的工作。styled-components 是一套可以與 React 良好結合的美化套件,其可以直接對 React 裡面 Virtual DOM 的節點去進行樣式的設定;而 Bootstrap 則是一套已經寫好很多常用 UI 的樣式表與邏輯的集合,我們只要在想要套用 Bootstrap 樣式的標籤,改變其 class
值為 Bootstrap 已設定好的值,就可以輕鬆的將 Bootstrap 已經製作好的樣式表套用上去,非常方便。
安裝 styled-components 的方式與以往相同,在 build.gradle.kts
裡面的 dependencies
內填入下面這行即可安裝:
implementation("org.jetbrains:kotlin-styled:1.0.0-pre.110-kotlin-1.4.0")
而 Bootstrap 的安裝則是使用原生的套用方式。於網頁 index.html
的內容裡面,使用 <link>
標籤來嵌入 CSS 樣式表,並利用 <script>
標籤來嵌入 Bootstrap 的 UI 邏輯 JavaScript 程式碼,整體套用到 src/main/resources/index.html
內後的內容如下所示:
<!DOCTYPE html>
<html lang="zh-tw">
<head>
<meta charset="UTF-8">
<title>Knight Online Judge</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" integrity="sha384-JcKb8q3iqJ61gNV9KGb8thSsNjpSL0n8PARn9HuZOnIxN0hoP+VmmDGMN5t9UJ0Z" crossorigin="anonymous">
</head>
<body>
<div id="root"></div>
<script src="[專案名稱].js"></script>
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.1/dist/umd/popper.min.js" integrity="sha384-9/reFTGAW83EW2RDu2S0VKaIzap3H66lZH81PoYlFhbGU+6BZp6G7niu735Sk7lN" crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js" integrity="sha384-B4gt1jrGC7Jh4AgTPSdUtOBvfO8shuf57BaghqFfPlYxofvL8/KUEfYiJOMMV+rV" crossorigin="anonymous"></script>
</body>
</html>
這裡是直接使用 Bootstrap 官方放在網路上的 CSS 與 JS 程式碼檔案,如果你希望在離線的情況下也可以使用,則可以將檔案從官網下載下來再嵌入進去即可。
安裝完上述兩個套件後,就讓我們開始來裝飾網頁吧!
首先先來將 Header 的部分進行美化。 Header 的部分主要有一個導覽列,我們利用 Bootstrap 預設的導覽列 Navbar 樣式來進行美化,程式碼如下:
class Header: RComponent<RProps, RState>() {
override fun RBuilder.render() {
header {
nav {
attrs.classes = setOf("navbar", "navbar-expand-xl", "navbar-dark", "bg-dark")
routeLink("/", className = "navbar-brand") {
+"Knight Online Judge"
}
ul {
attrs.classes = setOf("navbar-nav")
li {
attrs.classes = setOf("nav-item")
routeLink("/", className = "nav-link") { +"首頁" }
}
li {
attrs.classes = setOf("nav-item")
routeLink("/problems", className = "nav-link") { +"問題列表" }
}
li {
attrs.classes = setOf("nav-item")
routeLink("/submissions", className = "nav-link") { +"遞交程式碼列表" }
}
li {
attrs.classes = setOf("nav-item")
routeLink("/users", className = "nav-link") { +"使用者列表" }
}
}
}
}
}
}
為了要讓標籤能夠套用 Bootstrap 預設的 CSS 樣式表效果,我們要在標籤上填入指定的 class
名稱。在 Kotlin 語言的 React 套件中,我們可以利用標籤區塊內的 attrs.classes
去指定其 class
名稱有哪些。每一個標籤可以套用多個 class
名稱去套用多個不同的樣式表,像是在上面的程式碼中,最外層的 nav
區塊就套用了 navbar
、navbar-expand-xl
、navbar-dark
和 bg-dark
這四個 class
名稱,分別對此 nav
區塊套用了四種不同的樣式效果。另外在上面的程式碼中,routeLink()
這個輔助函式要套用 class
名稱的方式與預設的方式不太相同,它套用的方式是在函式內代入 class
名稱當引數即可,如果有多個 class
名稱要放入的話,中間需利用空白隔開。
底下稍微介紹一下每一個套用的 class
名稱主要的功能是什麼:
-xl
部分有四種大小可以填入,分別為 -sm
、-md
、-lg
和 -xl
。-dark
和 -light
可以選擇,主要會指定裡面所顯示的文字顏色為何。其餘還有很多不同的樣式可以使用,詳情可以查閱 Bootstrap 的文件。
在套用了 Bootstrap 所提供的樣式表後,我們可以直接執行看看效果如何,應該就會看到如下圖的結果了:
在 Header 部分美化完後,接著就讓我們先來美化可以比較簡單處理的 Footer 吧!Footer 的部分就比較沒什麼規範,這裡就讓它寫個 Copyright 常見的文字即可,程式碼如下所示:
class Footer: RComponent<RProps, RState>() {
override fun RBuilder.render() {
styledFooter {
css {
fontSize = LinearDimension("small")
}
hr { }
div {
attrs.classes = setOf("text-center")
+"© 2020 Copyright: Maplewing"
}
}
}
}
我們讓 footer
區塊改成使用 styled-components 專用的區塊定義方式,在 footer
前面加上 styled
,藉以讓裡面可以利用 css
區塊去定義 footer
區塊的 CSS 樣式。在 css
區塊裡面,我們設定了 fontSize
讓文字稍微變小了一點。接著利用 hr
區塊所代表的 <hr>
標籤定義了一條水平分隔線,然後用一個 div
區塊配合 class
名稱為 text-center
去讓文字能夠置中,並填入 © 2020 Copyright: Maplewing
這幾個文字。定義完後應該就可以看到如下圖所顯示的結果了:
最後要來處理的是內容部分的美化,首先我們先回頭看一下之前定義的 MainArticle
Component 在使用上有什麼與別人不同的地方:
mainArticle { content = "這裡是首頁" }
我們定義的擴充函式好像在區塊內只能代入 attrs
要填入的值而已,而不能像是其他 Component 一樣,在區塊內繼續填入 Virtual DOM 的結構。主要原因是因為我們的擴充函式目前定義的參數函式型態不足夠做這件事情,所以我們將定義改成如下程式碼所示:
fun RBuilder.mainArticle(handler: RElementBuilder<RProps>.() -> Unit): ReactElement =
child(MainArticle::class, handler)
參數函式的型態從代入 MainArticleProps.() -> Unit
變成代入 RElementBuilder<RProps>.() -> Unit
。利用將 Receiver Type 換成 RElementBuilder<RProps>
藉以符合 child()
函式能夠代入 Virtual DOM 結構的需求,進而讓你在使用的時候就可以繼續撰寫內部的 Virtual DOM 結構長怎樣。而之所以將 MainArticleProps
換回 RProps
,主要原因是因為既然已經可以繼續放 Virtual DOM 結構,那我也就不用再利用 props 去傳遞內容為何了。
改寫完擴充函式後,接著讓 MainArticle
能夠決定代入的 Virtual DOM 結構要放在哪裡吧!程式碼如下所示:
class MainArticle: RComponent<RProps, RState>() {
override fun RBuilder.render() {
article {
section {
children()
}
}
}
}
利用 children()
函式就可以將代入的 Virtual DOM 結構放在你想要放的位置上,這樣就可以讓使用的人從 mainArticle { content = "這裡是首頁" }
改成如下所示的用法:
mainArticle { +"這裡是首頁" }
而 App
Component 裡面整體的程式碼就會改成如下所示:
class App: RComponent<RProps, RState>() {
override fun RBuilder.render() {
hashRouter {
div {
attrs.id = "container"
websiteHeader { }
switch {
route("/", exact = true) { mainArticle { +"這裡是首頁" } }
route("/problems", exact = true) { problemsArticle { } }
route<IdProps>("/problems/:id") {
val id = it.match.params.id
mainArticle {
+"這裡是第 $id 題題目詳細資料"
}
}
route("/submissions", exact = true) { mainArticle { +"這裡是總遞交程式碼列表" } }
route<IdProps>("/submissions/:id") {
val id = it.match.params.id
mainArticle {
+"這裡是第 $id 個程式碼詳細資料"
}
}
route("/users", exact = true) { mainArticle { +"這裡是總使用者列表" } }
route<IdProps>("/users/:id") {
val id = it.match.params.id
mainArticle {
+"這裡是第 $id 編號使用者詳細資料"
}
}
}
websiteFooter { }
}
}
}
}
如此一來 MainArticle
就可以代入更多東西在其內部,藉以讓我們可以更方便地去使用這個 Component。至於其餘的 websiteHeader()
或是 websiteFooter()
要不要換成這樣可以由你自己去做決定,因為這兩個區塊比較沒有需要代入不同值去做顯示的需求,所以維持原狀就可以了。那之所以這邊我會想要將 MainArticle
做這樣的改動,是因為我希望能夠將 MainArticle
內的 article
和 section
所使用的大小與位置利用 styled-components 和 Bootstrap 中的 Grid 樣式去做調整,並讓 ProblemsArticle
能夠直接使用這個調整好的 Component 去做其內容上的顯示,如下程式碼所示:
// MainArticle.kt
class MainArticle: RComponent<RProps, RState>() {
override fun RBuilder.render() {
styledArticle {
css {
width = LinearDimension("80%")
margin = "30px auto"
}
attrs.classes = setOf("row")
section {
attrs.classes = setOf("col")
children()
}
}
}
}
// ProblemsArticle.kt
class ProblemsArticle: RComponent<RProps, ProblemsArticleState>() {
/* ...... 資料抓取的區塊 ...... */
override fun RBuilder.render() {
mainArticle {
h1 {
+"題目列表"
}
for (data in state.problemsData) {
div {
+"${data.id} - ${data.title}"
}
}
}
}
}
我們利用 Bootstrap 的 Grid 樣式去配置其標籤所佔的大小與位置,利用 row
來分隔出橫排的部分,接著每個橫排可以利用 col
來分隔出欄位的部分。但由於在這裡我們僅用了一個區塊,所以其實這樣切下來與沒切並不會有太多的差異,但未來如果有要加入其他區塊的話,只要遵守這個規則就可以讓版面配置地更好看。詳細可以怎麼利用 Grid 樣式去切割區塊可以查閱 Bootstrap 官網關於 Grid 樣式的文件。
在 MainArticle
的 article
部分,我們換成了 styledArticle
來讓裡面可以填寫一些 CSS 的樣式。其中 width
是用來調整標籤元素的寬度,而 margin
則是用來調整標籤與標籤之間的距離。width
調整成 80%
代表其寬度要為其所在的親代元素的寬度的 80%;而 margin
代入的值 30px auto
則表示這個標籤與上下兩個標籤之間的距離要相隔 30 像素高,而左右則是 auto
自動調整成置中的形式。
最後在 ProblemsArticle
的部分,我們就直接使用 mainArticle
作為基礎,繼續追加題目列表的內容即可,最後會長成下圖所示的樣子:
題目目前顯示的樣子還是有點不太好看,我們可以利用 HTML 當中的 <table>
標籤來用表格去表示這群題目資料,如下所示:
table {
attrs.classes = setOf("table", "table-bordered", "table-striped")
thead {
attrs.classes = setOf("thead-dark")
tr {
th { +"編號" }
th { +"標題" }
}
}
tbody {
for (item in state.problemsData) {
tr {
td { +item.id }
td {
routeLink("/problems/${item.id}") {
+item.title
}
}
}
}
}
}
表格的使用方式為,先使用根標籤 <table>
,並在其內部區分兩個部分,分別是表格標題所在的 <thead>
與表格內容所在的 <tbody>
。接著利用 <tr>
標籤來分隔成一個一個的橫排,然後利用 <th>
標籤或是 <td>
標籤來分隔所在橫排中的欄位。<th>
標籤指的是標題欄位,而 <td>
標籤指的是一般欄位。
我們在 <thead>
標籤中定義一個橫排,裡面有兩欄,兩欄分別是 編號
與 標題
這兩個代表下面欄位資料的表格標題文字。接著在 <tbody>
的部分,則利用 for
迴圈去將資料一筆一筆地生成一排一排的橫排,然後填入其資料所帶的編號與標題即可。那為了要讓標題能夠有超連結連結至相對應的題目詳細內容,這裡就用 routeLink()
去生成超連結標籤,讓使用者可以點擊題目標題進入詳細題目資料的頁面。
定義完表格的結構後,利用 Bootstrap 所預設的 Table
樣式表去美化整個表格。作法與之前相同,將 Bootstrap 所使用的 class
名稱一個一個代入給相對應的標籤即可。底下稍微解釋一下這些 class
名稱代表的涵意為何:
其餘還有很多不同的表格樣式可以使用,詳情可以查閱 Bootstrap 的文件。
將原本利用 <div>
標籤呈現的資料替換成 <table>
後,執行起來就可以看到如下圖的結果:
今天我們利用了 styled-components 和 Bootstrap 去幫我們在 React 中對其內部的節點套用了樣式上去,讓網頁可以看起來更漂亮。由於如何設計版面與套用樣式本身也是一個很深的學問,這裡僅對有使用到的部分進行說明,如果有興趣的話可以再找一些相關的教學,讓你可以把網頁弄得更漂亮。
昨日我們完成了前端網頁路徑路由的處理,今天就來讓我們對這些路徑能夠從對應的資料管理系統 API 去抓取資料,並將資料顯示在網頁上。
為了要能夠讓我們發送 HTTP Request 到資料管理系統去獲取資料,我們要使用 Fetch API 來進行抓取資料的動作,其函式為 window.fetch([路徑])
。由於這個函式是一個非同步函式(也就是可以與其他的流程同步運行),在 Kotlin 語言裡面,我們可以使用 kotlin-coroutine
去進行非同步函式的處理。為了要使用 kotlin-coroutine
的功能,首先先在 build.gradle.kts
的 dependencies
區塊中,增加下方的這行去安裝該套件:
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.9")
接著我們可以先來定義抓取資料用的類別 Fetcher
,程式碼如下所示:
import kotlinx.browser.window
import kotlinx.coroutines.await
class Fetcher<T>(val path: String) {
suspend fun fetch(): T =
window.fetch(path).await()
.json().await()
.unsafeCast<T>()
}
在這類別裡面,我們會代入一個 path
變數來作為抓取資料所要發送的 HTTP request 的目的地路徑。而裡面有個函式 fetch()
,整體的內容就是與該路徑抓取資料後,轉成 JSON 格式字串,再將該格式字串轉成 T
型態傳回來。在這個函式裡面,有兩個字詞是之前沒有看過的,分別是 suspend
和 await()
,底下就讓我來稍微解釋一下它們代表什麼意思。
由於 window.fetch(path)
抓取資料的動作會花很久的時間,故執行該函式時,它的設計上不會讓主執行緒卡在這個函式的位置,而是會另外利用一個執行緒去進行抓取資料的動作,而讓主執行緒能夠繼續往後去執行。而為了要讓程式知道後續的動作是需要先等待資料抓取完後才能繼續進行的,這裡會使用 await()
函式去中斷後續的動作,直到前面要進行的工作做完才會往後執行,所以 window.fetch(path).await()
和 json().await()
分別就是要等待抓取資料動作結束與將資料轉成 JSON 格式的動作結束為止。而如果在函式內部有用到像是 await()
這種中斷執行的函式的話,其函式定義必須加上 suspend
來表示這是一個會在中途中斷執行的函式的意思。
有了 Fetcher
類別後,我們就可以先利用題目資料來測試看看這個 Fetcher
是否能正常運作。先確認一下其資料在資料管理系統回傳的格式長怎樣,可以在開啟資料管理系統後,利用瀏覽器輸入 http://0.0.0.0:8081/problems
確認,結果如下所示:
{
"data" : [ {
"id" : "9",
"title" : "A + B + C Problem"
}, {
"id" : "10",
"title" : "A + B + C Problem"
}, {
"id" : "11",
"title" : "A + B Problem"
}, {
"id" : "12",
"title" : "A + B + C Problem"
} ]
}
依照這個格式,我們可以建立出能夠裝載這份資料的資料類別 ProblemsData
,如下程式碼所示:
data class ProblemsData(
val data: Array<ProblemData>
)
data class ProblemData(
val id: String,
val title: String
)
定義完資料後,就可以建立抓取這份資料的 Fetcher
物件了。這裡在 Fetcher
內部定義一個 companion object
,讓 Fetcher
類別本身有一些函式在不需要宣告出物件的情況下,就可以直接被呼叫去建立一個相對應的 Fetcher
物件,如下程式碼所示:
const val DATA_URL = "http://0.0.0.0:8081"
class Fetcher<T>(val path: String) {
companion object {
fun createProblemsFetcher() = Fetcher<ProblemsData>("$DATA_URL/problems")
}
suspend fun fetch(): T = /* ...... fetch() 程式碼部分 ...... */
這樣定義完以後,我們就可以直接使用 Fetcher.createProblemsFetcher()
去創建出可以抓取題目列表資料的 Fetcher
物件。在目前的架構中,先嘗試看看是否可以抓到題目列表吧!建立一個新的 ProblemsArticle
Component 去處理顯示題目列表資料的頁面,如下所示:
class ProblemsArticle: RComponent<RProps, RState>() {
override fun RBuilder.render() {
article {
section {
/* ...... 顯示題目列表資料的位置 ...... */
}
}
}
}
fun RBuilder.problemsArticle(handler: RProps.() -> Unit): ReactElement =
child(ProblemsArticle::class) {
attrs(handler)
}
整個類別與 MainArticle
基本上沒有太大的差異,差別只在於 顯示題目列表資料的位置
的地方該怎麼處理拉取題目列表資料與顯示題目列表資料的兩件事情了。由於抓取資料的動作是一個非同步的動作,意思就是當你說要執行這個動作後,預期上會需要等待一段時間後才能得到資料。在 React 中,對於資料變更的部分可以利用 state 來進行實作,state 有一個專門進行修改的函式叫做 setState()
,其可以在將 state
中的值變更後,讓 React 自動去更新相對應需要這個 state 資料的地方,藉以讓改變的 Virtual DOM 能夠再刷新到畫面上。故這裡我們要讓 ProblemsArticle
類別使用 state 去做資料改變的狀態處理,讓我們來為 ProblemsArticle
類別建立一個 ProblemsArticleState
的 state 吧!
external interface ProblemsArticleState: RState {
var problemsData: List<ProblemData>
}
ProblemsArticleState
繼承了 RState
,並且在裡面放置了我們所需要的 problemsData
資料以進行資料變更與顯示資料的操作。接著就讓 ProblemsArticle
會代入一個 ProblemsArticleState
類別參數,並在初始化的時候開始執行抓取資料的動作,程式碼如下所示:
class ProblemsArticle: RComponent<RProps, ProblemsArticleState>() {
override fun ProblemsArticleState.init() {
problemsData = listOf()
val mainScope = MainScope()
mainScope.launch {
val remoteProblemData = Fetcher.createProblemsFetcher().fetch()
setState {
problemsData = remoteProblemData.data.toList()
}
}
}
override fun RBuilder.render() { /* ...... render() 的程式碼部分 ...... */ }
}
我們在這裡覆寫了 ProblemsArticleState.init()
,讓它裡面會先初始化 state 的 problemsData
資料為一個空列表。接著宣告一個 MainScope
這個協同處理用的區塊,利用這個區塊去執行 fetch()
這個可以被中斷使用的函式。如果我們不使用一個區塊去幫我們執行 fetch()
的話,理論上會讓目前所執行的這個執行緒在進入 fetch()
後被裡面的 await()
給中斷,這樣的話程式後面該做的事情就沒有辦法被執行到。為了不發生這種事情,我們必須要跟程式表示這邊是一個協同處理的區塊,內有會被中斷執行的函式,故程式在執行的時候,就會知道在執行時被中斷後,還可以繼續執行這個協同處理區塊後面的事情。那編譯器為了不讓你犯這種錯誤,基本上是沒辦法讓你在不是協同處理區塊的地方呼叫標註有 suspend
的函式。
在協同處理區塊內,我們抓取了題目列表的資料回來,然後利用 setState()
這個函式去重新設定 ProblemsArticleState.problemsData
的值為抓取回來的資料。而由於呼叫了 setState()
,故程式就會重新檢查因為 state 變更而需要產生變化的地方有哪些,並且重新比對 Virtual DOM 和實際使用的 DOM 的差異在哪,藉以更新顯示的內容。
那在這裡之所以不使用 props 來實作,原因是 props 並不是要讓你直接對它的值進行修改而設計的,它是為了要因應上層的 state 被改動後,能夠重新反應 state 改動後的資料而設計的。故如果你是有自己需要進行的資料變更,請盡量使用 state 去做設計,並且記得在變更的時候要使用 setState()
函式,否則資料是不會產生更新的唷!
那 render()
的部分可以先簡單的顯示內容即可,如下程式碼所示:
override fun RBuilder.render() {
article {
section {
for (data in state.problemsData) {
div {
+"${data.id} - ${data.title}"
}
}
}
}
}
接著將 App.kt
內 route("/problems")
的地方換成使用 ProblmesArticle
。
route("/problems", exact = true) { problemsArticle { } }
完成後我們就可以來執行看看了,記得在執行的時候要一併打開資料管理系統唷!
執行完了以後,點選「問題列表」,會發現什麼都沒顯示,並且如果打開瀏覽器提供的主控台,會發現類似如下的錯誤訊息出現:
由於我們網頁專案所在的根目錄網址為 http://0.0.0.0:8080
,而資料管理系統所在的根目錄網址為 http://0.0.0.0:8081
,兩者雖然在同一個 IP 上,但由於 port 不同,故兩者實際上屬於不同的網域。而資料管理系統的伺服器在初始的預設設定會防止這種跨網域的 HTTP request,避免可能是來自惡意客戶端的攻擊,故如果要讓資料管理系統的伺服器能夠正常地給予網頁專案所要求的資料,必須要讓資料管理系統使用 CORS (Cross-Origin Resources Sharing;跨來源資源共用)
這個功能才行。在資料管理系統的專案內,我們可以利用 Ktor 的 install(CORS)
去設定有哪些請求是可以被允許的,程式碼如下所示:
install(CORS) {
method(HttpMethod.Get)
anyHost()
}
在上述的程式碼中,我們利用 method()
函式去表示如果送過來的請求方法為 GET
的話,允許其做 CORS
的請求。而 anyHost()
函式則代表不管請求來自於何方,通通允許其做 CORS
的請求。如果你覺得允許所有來源的請求很危險的話,也可以利用 host()
函式代入允許的網域去局部允許部分網域的請求即可。
這樣設定完後,我們的網頁專案就可以抓到題目列表的資料了,那麼抓取資料顯示的部分就完工了!
今天我們開始可以與資料管理系統進行溝通,獲取我們需要的資料來進行顯示。但是目前網頁的樣子還是有點過於陽春,在繼續將其他的資料抓取下來之前,我們明天就先來看看該怎麼將之前設計的網頁佈局用比較漂亮的方式呈現出來吧!