在寫網路相關應用的時候, 應該常常會碰到需要去解析JSON格式的資料, 而Go在這邊也已經內建了一個蠻方便的套件 - “encoding/json” 可以讓我們輕易地來處理這類型的資料

先來看看下面這段範例:

package main

import (
	"fmt"
	"encoding/json"
)

type Sample struct {
   Name string
   Age int 
}

const sampleData = `{
   "name":"julian",
   "age": -1
}`

func main() {
	var sample Sample
	err := json.Unmarshal([]byte(sampleData), &sample)
	
	if err != nil {
	   panic(err)
	}
	fmt.Println(sample)
}

[執行]

從這個範例可以看到, 我們可以用很簡單的程式碼, 把下面這段JSON內容給對應到Sample這個結構裡面

{
   "name":"julian",
   "age": -1
}

但這邊其實有一個問題, 如果你把這個JSON資料, 改成下面這樣子:

{
   "name":"julian",
   "age": "-1"
}

這在現實世界應該蠻常看到的, 只是多加個雙引號而已, 大家應該也會預期這邊應該也會沒問題的解析出一樣的結果吧? 但你如果實際改了資料執行看看, 你得到的結果應該會是:

panic: json: cannot unmarshal string into Go struct field Sample.Age of type int

goroutine 1 [running]:
main.main()
	/tmp/sandbox247755092/prog.go:23 +0x162

這其實是 “encoding/json” 把雙引號的內容都當作字串來看, 所以當我要把它塞到一個 int 欄位時, 就會出事了

解決方法有好幾種, 下面就一一來看看:

用Field tag來解決

如果把Sample的定義改成下面這樣:

type Sample struct {
   Name string
   Age int `json:",string"`
}

[執行範例]

喔耶, 沒問題了耶!!!可以正常的解出資料了耶!! 慢著, 先別高興太早!! 再試試把雙引號拿掉看看(參考這個範例)

呃, 是怎樣啦!! 換成這個錯誤了!!

panic: json: invalid use of ,string struct tag, trying to unmarshal unquoted value into int

goroutine 1 [running]:
main.main()
	/tmp/sandbox912379425/prog.go:23 +0x162

在現實案例中, 的確是有可能碰到有時送來的資料有雙引號, 有時候沒有, 這方法是沒法一次滿足的

利用Unmarshaler界面來解決

“encoding/json” 是可以讓開發者自行指定怎去解析JSON內容的, 只需要定義一個自定義的型別並實做Unmarshaler界面就可以了, 為了解決這個問題, 我們可以定義一個新的MyInt的型別, 並幫它實做UnmarshalJSON的方法, 參考下面範例:

type MyInt int

func (m *MyInt) UnmarshalJSON(data []byte) error {
	str := string(data)
	
	if unquoted, err := strconv.Unquote(str); err == nil {
	   str = unquoted
	}
	
	result, err := strconv.Atoi(str)
	if err != nil {
		return err
	}
	*m = MyInt(result)
	return nil
}

type Sample struct {
	Name string
	Age  MyInt
}

[執行範例]

在Sample這個結構中的Age欄位, 從原本的int改成MyInt, 這樣json.Unmarshal碰到Age這欄位的話, 就會用MyIntUnmarshalJSON方法去解析

這方法是麻煩了點, 而且可能針對不同型別要去個別做, 但卻是可以同時處理掉前述兩種型態的資料, 程式沒那好看就是了

使用json.Number

“encoding/json” 裡其實還提供一個資料型態json.Number, 這個應該是這個問題的比較正規的解法了, 把Sample的定義改成下面這樣:

type Sample struct {
	Name string
	Age  json.Number
}

[執行範例]

這方法也是可以無誤的解析兩種的型態, 然後當你要取用Age這欄位的int型態時, 你可以用sample.Age.Int64()去取得, 要多一層是麻煩點

那它是怎做到的呢? 來看一下它的原始碼:

// A Number represents a JSON number literal.
type Number string

// String returns the literal text of the number.
func (n Number) String() string { return string(n) }

// Float64 returns the number as a float64.
func (n Number) Float64() (float64, error) {
	return strconv.ParseFloat(string(n), 64)
}

// Int64 returns the number as an int64.
func (n Number) Int64() (int64, error) {
	return strconv.ParseInt(string(n), 10, 64)
}

會發現, 其實沒啥了不起的, Number本身就是一個string, 所以它是當字串在處理, 而當你需要數字表示(int或float)時, 再呼叫ParseInt或ParseFloat來處理

使用 Jsoniter 來取代 “encoding/json”

Jsoniter是一個號稱比原生的***“encoding/json”***效能還要來的更好的JSON處理套件, 除了Go的版本外, 它也有Java的版本

效能不在這邊的討論範圍, 但除效能外, 它也是最簡單解決這問題的方法, 先來看看完整的程式碼吧:

package main

import (
	"fmt"
	jsoniter "github.com/json-iterator/go"
	extra "github.com/json-iterator/go/extra"
)

type Sample struct {
	Name string
	Age  int
}

const sampleData = `{
   "name":"julian",
   "age": 45
}`

const sampleData2 = `{
   "name":"julian",
   "age": "45"
}`

func main() {
	extra.RegisterFuzzyDecoders()
	var sample Sample
	err := jsoniter.Unmarshal([]byte(sampleData), &sample)

	if err != nil {
		panic(err)
	}
	fmt.Println(sample)

	err = jsoniter.Unmarshal([]byte(sampleData2), &sample)

	if err != nil {
		panic(err)
	}
	fmt.Println(sample)

}

[執行範例]

為啥說是最簡單的方法呢? 首先, 它有跟 “encoding/json” 完全一樣的使用方法, 把套件換掉後, 程式幾乎一樣, 所以也不需要改啥程式, 但如果只有改這樣, 會發現問題都還是在, 並沒有解決掉, 這時候就要帶入它額外的功能 FuzzyDecoders 了, FuzzyDecoders是在它額外的套件裡面, 所以只需要加入import就可以用了

import extra "github.com/json-iterator/go/extra"

然後在開始使用前, 註冊 FuzzyDecoders 即可(extra.RegisterFuzzyDecoders()), 這樣, 不管有沒雙引號都不會有問題了!!

大概有三年沒寫Blog了, 最近覺得年紀大了, 變得越來越會忘東忘西的, 是有必要強迫自己寫一些東西了

每次荒廢了很久後重新執筆, 好像就會習慣換個系統, 並且做個紀錄, 像是之前 - 這篇 XD 這次…也不要例外好了, 雖然之前用 Jekyll + GitHub Page還算堪用, 但就是覺得它不是很快(是太慢了), 加上這麼久沒用了, 也忘了差不多了, 還是拋棄它吧

GitHub是不錯的免費空間, 所以還是沿用吧, 把 Jekyll 換掉應該就差不多了, 這樣的話找另外一套靜態網頁產生器就足夠了, 也不用特地架server, domain name也沿用, 那這次該換甚麼呢? 最近這幾年, 比較迷 Go, 所以沒多考慮, 打算就採用用Go寫的 Hugo, 當然希望以前的內容還是可以承繼下來, 之前在Jekyll上的功能能延續自然就更好了, 廢話說太多了, 廢話說太多了, 先來看一下這次做的改變

安裝 Hugo

Hugo是一套用Go寫的Open source靜態網頁產生器, 特點就是快, 非常快, 使用方法也很簡單, 如果你在Mac底下, 也像我一樣用 Homebrew, 那只要執行下面的指令:

brew install hugo

不過最近我在家的工作環境比較常是Windows底下用WSL(Windows subsystem for Linux)下開發東西,這邊就不得不誇誇微軟了,有了WSL後,我在Windows下工作,也跟我在Mac上一樣順手,WSL說穿了就是一個Linux環境, 我用的是Ubuntu, 不過我是想在Linux下也是用 我是想在Linux下也是用 Homebrew 來安裝, 不想用apt(習慣了), 這時候就可以用下面的指令來安裝 Homebrew

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install.sh)"

移植原本Jekyll上的內容

這是整個所有過程中最簡單的事了, 當你裝好Hugo後只要執行

hugo import jekyll jekyll_root_path target_path

這邊的jekyll_root_path是你原始jekyll內容的路徑, 它會自動幫你轉換所有的內容到新路徑, 又快又簡單

移植完內容後, 可以到新的目錄執行hugo server先來預覽結果…不過, 慢著~~看起來好像跟想像的不同!因為我們還沒套用主題跟做相關設定

套用原本的主題

雖然換了新系統, 不過我還是希望風格可以接近之前的, Jekyll 是可以套用主題的, 而我之前套用的主題是改自 hpstr 這個主題的(也只是換一下標頭途而已)

還好Hugo上也是有人幫忙移植了 Hugo版hpstr

Hugo的主題(themes)是被放在themes的目錄下, 所以新增一個新的theme很簡單, 只要把theme下載回來解開放在那目錄就好, 如果要試試套用的結果, 就執行

hugo server --theme hpstr

這邊hpstr是theme的名字, 不過到這邊為止, 都只是預覽而已, 真正產生網頁時都還不會套用theme

設定網站

剛剛有說過, 這時候如果去產生網頁, 並不會真的套用你所想要的theme, 必須先做好設定才可以

在剛剛從Jekyll移植過來的目錄裡面, 已經有產生了一個 config.yaml 的設定檔, 如果希望產生網頁時套用我們剛剛選用的hpstr, 那只要在 config.yaml 裡加上一行 theme: hpstr就好

這個config.yaml長的就像這樣:

baseURL: http://blog.jln.co
disablePathToLower: true
languageCode: en-us
title: Le murmure de Julian
theme: hpstr

這個設定檔格式是以yaml格式儲存的, 但它其實也支援了 TOML, JSON等其他的格式, 這是因為, 創作了Hugo的大神 spf13 同時也是著名 Go 套件 viper的作者, 相信很多寫Go的攻城獅們很多都使用過 viper 來讀取設定檔吧

這邊因為某些原因, 我後來決定不用yaml格式來存我的設定, 而是改用TOML, 因此我把config.yaml, 轉換成 config.toml , 以下是我最近一個版本的設定檔:

baseURL= "https://blog.jln.co"
disablePathToLower= true
languageCode="zh-tw"
title= "Le murmure de Julian"
theme="hpstr"
googleAnalytics = "UA-79243751-1"
PygmentsCodeFences = true
Paginate = 5
hasCJKLanguage = true
enableEmoji = true

[params]
        subtitle = "朱隸安貓囈語錄"
        images = ["/images/avatar.png"]
        [params.author]
                name = "Julian Shen"
                avatar = "/images/avatar.png"
                bio = "Softward developer"
                github = "julianshen"
                email = "[email protected]"
                linkedin = "julianshen"
                instagram = "julianshen"
        [params.image]
                feature = "/images/bkg2.jpg"

[outputFormats.RSS]
    mediatype = "application/rss"
    baseName = "feed"

[permalinks]
    post = "/:slug/"

這邊有些設定的意義, 容後再說(我沒忘的話), 但基本上有這些:

  • baseURL - 你網站的URL
  • languageCode - 語系
  • title - 標題
  • theme - 主題, 前面說過了
  • googleAnalytics - GA的Tracking ID
  • hasCJKLanguage - 這個設定關係到算字數,閱讀時間的

(好像都講得差不多了)

維持原本的URL格式

我原本URL格式是長這樣:

http://blog.jln.co/筆記Vue.js-Slot的應用/

但Hugo實際產生的格式是這樣

http://blog.jln.co/post/筆記Vue.js-Slot的應用/

原本的網站其實已經有被搜尋引擎爬過了, 所以我並不想改變URL格式, 因此我在設定檔內加入

[permalinks]
    post = "/:slug/"

這邊就是為了設定文章URL的格式, 當然不只有這邊可以設定, 也不是只有 slug 這個變數可以用, 詳細方法可以參考 這篇說明

RSS的位置

Hugo產生的網站也是會包含RSS的連結, 但它預設是放在index.xml, 但在我舊有用Jekyll產生的網站, 其實是放在feed.xml, 所以我在 IFTTT的設定是feed.xml, 這邊我也是不想變動, 所以加入了以下設定

[outputFormats.RSS]
    mediatype = "application/rss"
    baseName = "feed"

這樣你就會發現RSS不再是放在index.xml了, 而是放在feed.xml

Shortcodes

在Jekyll上有一個非常好用的東西, 比如說你在文章內加入 {% youtube FhoPTyMUgX0 %} 最後產生的網頁就會自動嵌入對應的Youtube影片, 如果是用 {% gist julianshen/229f4ac32b3893816bd7636b96fe6f7d %} , 那就會嵌入對應的gist程式碼

這如果在Hugo上沒有, 就頭痛了, 還好, 也是存在的, 它叫 shortcodes

雖然功能一樣, 但格式是不同的, 以剛剛兩個例子來說, 它就分別變成 (把%%改成{})

  • { {< youtube FhoPTyMUgX0 >} }
  • { {< gist julianshen 229f4ac32b3893816bd7636b96fe6f7d >} }

流程圖嵌入

之前我寫過一篇 “替Jekyll的markdown加上簡易流程圖功能”, 採用的是Jekyll-mermaid

Jekyll-mermaid是透過mermaid-js讓我們可以很簡單的在文章內加入流程圖

但在Hugo,並沒有這樣的plugin, 所以必須要用別的方式達成

首先, 先到 themes底下你的主題(這邊是hpstr)的layouts/partials找看看有沒head.html, 在裡面加入一行

<script async src="https://unpkg.com/[email protected]/dist/mermaid.min.js"></script>

這是為了在每個網頁都可以載入mermaid.js, 再來就是在內容目錄下的layouts/shortcodes裡面建立一個 mermain.html (注意喔, 不是在theme底下那個layouts喔), 內容如下:

<div class="mermaid">
  {{.Inner}}
</div>

這是建立一個新的shortcodes叫mermaid, 內容會轉化成一個div, 這div class是mermaid, mermaid.js會透過class找到這個div, 並將裡面內容轉成流程圖, 因此, 你就可以用這樣的shortcodes:

 { {<mermaid>}}
 graph TD;
    A-->B;
    A-->C;
    B-->D;
    C-->D;
 { {</mermaid>}}

畫出一個這樣的圖

graph TD; A-->B; A-->C; B-->D; C-->D;

把原本轉過來的文章都改成新的shortcodes就大功告成了

Open Graph

Open Graph很重要, 它決定了你的文章被分享到社群網路上的樣子, 長得太不起眼, 沒人會注意, 一篇文章的OG, 差不多會長像這樣:

<meta property="og:title" content="[Blog] 替Jekyll的markdown加上簡易流程圖功能">
<meta property="og:description" content="對一個developer的blog來說, 流程圖似乎是蠻需要的, 比較能夠清楚來解釋一些東西, 但每個東西都轉圖檔還蠻麻煩的, 下面介紹一個有用的J"><meta property="og:type" content="article">
<meta property="og:url" content="https://blog.jln.co/Blog-%E6%9B%BFJekyll%E7%9A%84markdown%E5%8A%A0%E4%B8%8A%E7%B0%A1%E6%98%93%E6%B5%81%E7%A8%8B%E5%9C%96%E5%8A%9F%E8%83%BD/">
<meta property="og:image" content="https://blog.jln.co/images/posts/2016-08-31-%5Bblog%5D-%E6%9B%BFjekyll%E7%9A%84markdown%E5%8A%A0%E4%B8%8A%E7%B0%A1%E6%98%93%E6%B5%81%E7%A8%8B%E5%9C%96%E5%8A%9F%E8%83%BD.md.jpg">

這邊, og:titleog:image 很重要的, 沒有圖, 就非常不起眼了, 標題不吸引人也是很不起眼, 所以我們要讓每篇文章都有對應的圖

如果我們去看 Hugo 的 Open graph template原始檔

{{ with $.Params.images }}{{ range first 6 . -}}
<meta property="og:image" content="{{ . | absURL }}" />
{{ end }}{{ else -}}
{{- $images := $.Resources.ByType "image" -}}
{{- $featured := $images.GetMatch "*feature*" -}}
{{- if not $featured }}{{ $featured = $images.GetMatch "{*cover*,*thumbnail*}" }}{{ end -}}
{{- with $featured -}}
<meta property="og:image" content="{{ $featured.Permalink }}"/>
{{ else -}}
{{- with $.Site.Params.images -}}
<meta property="og:image" content="{{ index . 0 | absURL }}"/>
{{ end }}{{ end }}{{ end }}

這邊就不解釋 Hugo的tempalte語法了, 直接講答案, 跟 og:image 相關的設定是:

  • 文章Front matter內設定的圖片(images)
  • 文章內名字含有"feature”, “cover”, “thumbnail” 的圖片
  • 網站設定(config.toml)裡params.images的第一張 (等於就是用這張當預設圖了)

由此可知, 只要在config.toml 裡面放上這設定

[params]
        subtitle = "朱隸安貓囈語錄"
        images = ["/images/avatar.png"]

那在文章沒半張圖時, /images/avatar.png就會是預設圖

如果我想自己在文章中指定圖片呢? 我們在新增文章後, 每篇文章都會有這樣的表頭, 叫做front matter, Hugo支援yaml, toml, json等各種front matter格式, 以下這個由 ---分隔表頭跟文章的, 用的就是yaml格式

 ---
 date: "2017-01-21T00:22:49Z"
 images:
 - /images/posts/2017-01-21-在heroku上用apt-get安裝套件.md.jpg
 tags:
 - server
 - heroku
 title: 在Heroku上用apt-get安裝套件
 ---

這邊就用了images屬性來設定了這篇文章的 og:image

優化社群分享

上面提到了設定Open graph相關內容的基本, 不過, 還有一個問題

Facebook會針對你提供的圖片大小來決定分享出去的版面設計, 可以參考這邊, 最大版面用的圖是1200x630, 不然分享出去的就小小一塊沒人看到了

另外一個問題是, 如果你沒在front matter設定圖片, 那OG就會使用網站預設圖, 或是文章中含有或是文章中含有"feature”, “cover”, “thumbnail”, 而這邊front matter上放的圖片還是自己放的, 如果不小心忘了, 絕大多數是忘了, 那可能每篇文章用的都是一樣的圖, 比較單調, 更慘的是, 萬一設的預設圖太小(像我一樣), 或是根本沒設, 那分享出去的版面就會很不顯眼

雖說會去掃文章中的圖片, 找出含有"feature”, “cover”, “thumbnail” 的圖片, 跟 Jekyll Auto image 有點類似(不過Auto image不會限制檔名), 當然你也可以把內建的open graph template拿出來改, 放到自己主題layouts下

如果你要改用自己的, 該怎做呢? 首先就是把內建的opengraph.html和twittercard.html拷貝一份到主題的layout/partials目錄下改, 並且也是去改layouts/partials/head.html, 你會在head.html發現這樣的內容(以hpstr為例):

<!-- Open Graph and Twitter cards -->
{{ template "_internal/opengraph.html" . }}
{{ template "_internal/twitter_cards.html" . }}

把這兩個路徑改成自己的就好了

還不滿足!!因為還是有些問題:

  • 文章內的圖片可能本來就不大, 小於1200x630
  • 文章內的圖片是外連的, 有可能之後消失不見
  • 文章內根本沒有圖片

所以我希望:

  • 被用在 og:image的圖片要被放大裁切到1200x630的比例
  • 這個圖片要放本地不能外連
  • 沒有圖的狀況…..產生一個給它用….用標題文字來當圖, 大小也要1200x630

在這需求之下, 我自己就寫了一個工具叫 ogp, 原始碼如下:

這工具做的是

graph LR A["分離Front matter跟文章內容"] --> I{是否含images屬性} I -->|有| H I -->|無| B["掃描文章內含的圖片"] B --> C{是否有圖} C -->|有| D[將圖片縮放並裁減] C -->|無| E[產生文字圖片] D --> F[在Front matter插入images屬性] E --> F F --> G[寫回原文] G --> H[結束]

這邊套用了幾個套件來處理,

  • Hugo本身的 Page parser, 用來處理Front matter跟Content分離, 由於Hugo本身就是用Go寫的, 所以很輕易地可以用 import "github.com/gohugoio/hugo/parser/pageparser"來使用
  • “gopkg.in/yaml.v2”, 用來輸出yaml的
  • “github.com/yuin/goldmark”, 這是Hugo用的Mark down parser, 我這邊用來找出所有的圖片, 如果是外站的就下載回來
  • “github.com/h2non/bimg”, 這是一個基於libvips的圖片處理套件, 安裝上會需要libvips, 這邊就不詳述, 以後如果有時間再另外寫一篇好了
  • “github.com/Iwark/text2img”, 這有點有趣, 我本來想自己寫一個把標題轉成圖片的來當作og:image的材料的, 沒想到真有人已經做了, 這邊我是自己fork回來做了些小修正

自動化處理文章的og:img

有了上面的ogp後, 就可以"手動"來產生文章的og:image了, 當然, 這完全不方便, 我每次寫完一篇新文章後, 我就得自己手動跑一次 ogp, 身為一個懶惰的攻城獅(做了前面這麼多還懶惰?), 當然要想想怎麼來自動化囉!!

這邊想到的方式是利用 git-hooks, 甚麼是git-hooks呢?簡單的說, 就是用來在你做git操作時, 可以讓你觸發某些動作, 例如我這邊要用到的pre-commit和post-commit, pre-commit是在你下commit命令後但還沒真正做commit動作前觸發, post-commit則是在動作發生之後

要使用git-hooks的話, 要把你要執行的寫成腳本(script)放到 .git/hooks/ 目錄下, 例如, pre-commit的腳本的檔名就是 .git/hooks/pre-commit, 記得要把這檔案用 chmod +x 把權限改成可執行, 下面就是我用的pre-commit

#!/usr/bin/perl 
use File::Basename;
use File::Copy;
use Cwd;
use utf8;

open(FH,"git diff --cached --name-status |") or die $!;

while (my $line = <FH>) {
   $line =~ /^(\w)\s+(.+)/;
   if ($1 ne "D"){
      my $f = $2;
      $f =~ tr/"//d;
      ($name,$path,$suffix) = fileparse($f,  ,qr"\..[^.]*$");
      if($suffix =~ /\.md|\.MD/) {
          my $dir = getcwd;
          print "$f\n";
          system("docker run -v $dir:/blog julianshen/ogpp '$name$suffix' > '/tmp/$name$suffix'");
          copy("/tmp/$name$suffix", "$dir/$f") or die "error moving file from /tmp/$name$suffix to $dir/$f:$!";
      }
   }
}
close(FH)

(更新: 後來因為有檔名處理問題, 改寫了這個perl的版本)

這邊所做的動作是找到這次有變更的文章(副檔名.md), 然後針對每個檔去跑ogp產生 og:image (我這邊把ogp包成docker image方便使用)

前面我說到, 會利用到 pre-commitpost-commit, 那 post-commit 又是用到哪個地方呢? 在 pre-commit這邊, 我用了 ogp 有產生了新檔案(圖檔), 但對於git來說, 這個檔案並不在stage中(git add後), 所以當做完commit後, 這個檔案並不會被放進去, 因此我們得找一個方式把它一起放進去

如果你有發現到在pre-commit這腳本中, 在跑完 ogp後有加了一個 touch .commit的動作, 這目的就是為了來解決前面所說的問題, 因為有產生新的檔案, 所以這邊建立一個 .commit的檔案來標記一下有新檔案產生, 這檔案本身沒太大意義, 而是為了在後面 post-commit使用

那, 我的 post-commit的腳本就會長成這樣:

#!/bin/bash
echo "Post commit"
if [ -e .commit ]
    then
    echo "Add rest files"
    rm .commit
    git add .
    git commit --amend -C HEAD --no-verify
fi
exit 0

很簡單, 只要有 .commit存在, 就會去多做一次git addgit commit把新的檔案放進去

要注意, 這兩個hooks都只在本地端作用, 如果要讓它其他電腦也會有作用, 要記得複製過去才會有用

產生網頁並佈署

寫完文章後, 由於原始文章是 mark down 語法寫的, 如果沒把它轉成網頁是沒辦法在瀏覽器上看的, 產生網頁很簡單, 執行

hugo --minify

這就可以了, 當然你如果要簡單的執行 hugo不加任何參數也行, 產生的靜態網頁, 就會放到 public 這個目錄下

所以把 public目錄裡面的所有內容, 都放到你github page的repository下就好了!

慢著!!!慢著!!! 都手動嗎???

懶惰的攻城獅會同意嗎??? 沒關係, 接下來我介紹一下怎把它自動化

使用GitHub Actions來自動化流程

GitHub Actions是GitHub的持續集成(Continue Integration, 簡稱CI)的服務, 可能有不少朋友已經知道CI是甚麼了, 這邊不多做介紹, 實際上使用過GitHub Actions後會發現, 它簡單而且強大

由於我這個blog的內容都是放在GitHub page, 而且原始檔也是放在GitHub, 所以使用GitHub Actions來自動化建置頁面跟佈署看來也很理所當然

GitHub Actions的工作流程(workflow)設定檔都放在.github/workflows底下, 所以只要新增一個就可以使用了, 這邊要特別注意一下, 如果你用GitHub tokens來存取這目錄, 需要特別有workflow權限, 通常如果你clone你的repository時是用http的話, 那可能就無法更動到這個檔(無法push), 建議是使用ssh

我發現, 寫到這邊, 篇幅已經非常的長了, 如果要仔細介紹一下這工具的話, 那可能還要很長的篇幅來說, 所以這邊就不仔細介紹了, 底下分享一下我用的設定:

# This is a basic workflow to help you get started with Actions

name: CI

# Controls when the action will run. Triggers the workflow on push or pull request
# events but only for the master branch
on:
  push:
    paths: ["content/**", ".github/workflows/main.yml", "config.toml"]

# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
  # This workflow contains a single job called "build"
  build:
    # The type of runner that the job will run on
    runs-on: ubuntu-latest

    # Steps represent a sequence of tasks that will be executed as part of the job
    steps:
    # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
    - uses: actions/[email protected]

    - name: Hugo setup
      uses: peaceiris/[email protected].4.12
      with:
        # The Hugo version to download (if necessary) and use. Example: 0.58.2
        hugo-version: latest
        # Download (if necessary) and use Hugo extended version. Example: true
        extended: false
    
    - name: Build pages
      run: hugo --minify

    - name: Deploy
      uses: peaceiris/[email protected]
      with:
        personal_token: ${{ secrets.PERSONAL_TOKEN }}
        external_repository: julianshen/julianshen.github.com
        publish_branch: master
        publish_dir: ./public
        cname: blog.jln.co

如果你用過另一個CI工具 drone 的話, 你會發現, 對比起來, GitHub Actions的彈性更大, 更方便, 觸發建置的條件不一定要是某個git事件(一般是push, pr), 檔案的更動也可以觸發,檔案的更動也可以觸發, 以這段為例:

on:
  push:
    paths: ["content/**", ".github/workflows/main.yml", "config.toml"]

這就是指 content目錄下, 以及workflow或Hugo設定有改變後, 就會觸發

至於Hugo的建置跟發布, 就在請大家自己看設定檔吧, 其實很簡單的

總結

好就沒寫blog, 也好久沒寫長文了, 我發現我廢話一樣多, 不過這邊也花了很長時間弄, 所以也多了一點

還有一些沒寫上, 例如說我現在是用vscode打這篇文章, 搭配hugoify這個plugin, 並且同時用瀏覽器預覽, 雖然整套工具很geek, 不過弄好後還蠻好玩的就是了

nodejs 8開始, nodejs可以不再需要一定跟V8綁在一起了, 之後底層的Engine可以換成微軟的查克拉(node-chakracore), 甚至是Mozilla的SpiderMonkey(spidernode)

node-chakracore的官方文件用的version managent是nvs, 而我習慣用的nvm則是尚未支援查克拉版本的安裝, 如果用nvm想嚐鮮的話, 要用下面的方式:

NVM_NODEJS_ORG_MIRROR=https://nodejs.org/download/chakracore-nightly/ nvm i node

這樣就會幫你裝最新版本的nodejs nightly (with Chakra Core), 以我剛剛執行的結果, 它就幫我裝了v9.0.0-nightly20170617021fbca6bc, 當然如果想用8而不是9一樣可以用:

NVM_NODEJS_ORG_MIRROR=https://nodejs.org/download/chakracore-nightly/ nvm list-remote

這樣就可以看有哪些版本可以安裝

裝完之後可以用nvm aliasv9.0.0-nightly20170617021fbca6bc取一個代號, 如nvm alias v9.0.0-nightly20170617021fbca6bc chakra9, 這樣之後就可以用nvm use chakra9來切換到這個版本, 這樣就搞定了! 歡迎來到木葉忍者村!!

另外, 補充一點, 怎知道用的版本是查克拉版的呢?

執行: node -e "console.log('Hello from Node.js ' + process.jsEngine)"

如果是查克拉版的會顯示: Hello from Node.js chakracore

如果是v8的則是: Hello from Node.js undefined

Vue的component裡有一個還蠻好用的東西叫做Slot(文件在此), 尤其適用在開發複雜或巢狀的元件

那Slot是用在什麼樣的地方, 舉個例子, 假設我們有個元件叫做panel:

<panel>
  <div>Inside panel</div>
  <div>Panel content</div>
</panel>

Panel的定義可能會是這樣:

Vue.component('panel', {
  template: '<div><slot></slot></div>'
})

執行範例

簡單的說, 這邊template裡的slot會被拿來放前面例子<panel></panel>裡面的<div>

當然, 這也可以讓我們這個component裡面再放其他的component, 像是這樣的例子:

<b-menu>
 <b-menu-item>menu 1</b-menu-item>
 <b-menu-item>menu 2</b-menu-item>
 <b-menu-item>menu 3</b-menu-item>
 <b-menu-item>menu 4</b-menu-item>
</b-menu>

實作上可以寫成這樣

Vue.component('b-menu', {
  template: '<div><ul><slot></slot></ul></div>'
})

Vue.component('b-menu-item', {
  template: '<li><slot></slot></li>'
})

執行範例

其實這範例看起來好像也沒啥必要寫成component(不過就ul/li), 當然, 實際上的應用可以再更複雜, 再來看一個更複雜的範例:

<b-menu>
	<b-menu-item title="menu 1">Content 1</b-menu-item>
	<b-menu-item title="menu 2">Content 2</b-menu-item>
	<b-menu-item title="menu 3">Content 3</b-menu-item>
	<b-menu-item title="menu 4">Content 4</b-menu-item>
</b-menu>

跟上面不一樣的是, 這次想render成的結果是像這樣:

<div>
  <ul>
    <li><a href="#a1">menu 1</a></li>
    <li><a href="#a2">menu 2</a></li>
    <li><a href="#a3">menu 3</a></li>
    <li><a href="#a4">menu 4</a></li>
  </ul>
  <div id="a1">Content1</div>
  <div id="a2">Content2</div>
  <div id="a3">Content3</div>
  <div id="a4">Content4</div>
</div>

這明顯被切成兩區了, title的部分顯示在<ul>內, 而content卻在另一區, 那這該怎麼做呢?

取得Slot內的子元件(Child component)

上面的例子可以寫成這樣:

Vue.component('b-menu', {
  data () {
  	return {
      items: []
    }
  },
  template: '<div><ul><li v-for="(item,i) in items"><a :href="`#a${i+1}`">{{item.title}}</a></li></ul><div><slot></slot></div></div>',
  mounted () {
     this.items= this.$slots.default.filter(item => item.componentInstance || false).map(item => item.componentInstance)
     for (let [index, item] of this.items.entries()) {
       this.$set(item, 'id', 'a' + (index + 1))
     }
  }
})

Vue.component('b-menu-item', {
  template: '<div :id="id"><slot></slot></div>',
  props: ['title'],
  data () {
    return {
      id: ''
    }
  }
})

要取得slot裡面的child nodes可以用this.$slots.default, 但這個包含所有的child nodes, 如果我們要的只是child components, 那可以檢查這個node是否包含componentInstance

因此, 透過filter和map, 我們可以以this.$slots.default.filter(item => item.componentInstance || false).map(item => item.componentInstance)來取得child components, 在這個例子就包含所有的b-menu-item

這段程式的作法就是取得所有child components放入items這個資料欄位中, 而在template中有<li v-for="(item,i) in items">利用items內的值來渲染<li>的部分

這邊有一點需要注意的是, 這段必須要跑在 mounted()不能在created(), 因為在created()裡面雖然可以用this.$slots.default來取得child nodes, 但這時候child nodes的componentInstace全部都是undefined, 因為這時候child components其實都還沒準備好

處理動態內容

b-menu-item當然也可以用v-for來動態渲染, 像是:

<b-menu>
   <b-menu-item v-for="mItem in menuItems" :title="mItem.title">{{mItem.content}}</b-menu-item>
</b-menu>

這邊的menuItems如果是一個靜態的陣列下面例子, 不會有問題

let vue = new Vue({
  el: '#app',
  data () {
     return {
       menuItems: [
          {
             title: 'menu 1',
             content: 'content 1'
          },
          {
             title: 'menu 2',
             content: 'content 2'
          },
          {
             title: 'menu 3',
             content: 'content 3'
          }
       ]
     }
  }
})

但如果它的內容是由一個async function所產生, 像是

let vue = new Vue({
  el: '#app',
  data () {
     return {
       menuItems: []
     }
  },
  mounted () {
	let vm = this
	doGetSomething(data => {
		vm.menuItems = data
	})
  }
})

你可能會發現畫面完全沒變化, 那是因為我們在b-menumouted()的時候去掃所有的child components, 而menuItems可能在mouted很之後才會被更新, 所以不會被重掃一次, items並不會被更新, 所以畫面也不會有變化, 因此必須要在menuItems資料被更新後再掃一次slot的child components

可以把b-menu改成這樣

Vue.component('b-menu', {
  data () {
  	return {
      items: []
    }
  },
  template: '<div><ul><li v-for="(item,i) in items"><a :href="`#a${i+1}`">{{item.title}}</a></li></ul><div><slot></slot></div></div>',
  methods: {
	updateItems () {
	  	this.items= this.$slots.default.filter(item => item.componentInstance || false).map(item => item.componentInstance)
		for (let [index, item] of this.items.entries()) {
		this.$set(item, 'id', 'a' + (index + 1))
		}
	}
  },
  mounted () {
     updateItems()
  }
})

這樣亦即是, 我們在更新完資料後必須要再呼叫一次updateItems()

為了直接呼叫到b-menu的updateItems, 可以先替他加一個ref="menu", 方便後面存取

<b-menu ref="menu">
   <b-menu-item v-for="mItem in menuItems" :title="mItem.title">{{mItem.content}}</b-menu-item>
</b-menu>

前面更新menuItems的程式可以改寫成這樣:

let vue = new Vue({
  el: '#app',
  data () {
     return {
       menuItems: []
     }
  },
  mounted () {
	let vm = this
	doGetSomething(data => {
		vm.menuItems = data
		vm.$refs.menu.updateItems()
	})
  }
})

這邊透過vm.$refs.menu.updateItems()來更新items

但….還是沒動靜呀…怎麼回事? 因為這時候menuItems才剛被更新, 它先去更新b-munu-item, 如果讓items更新後, 畫面要跟著更新, 就必須要在下一個DOM的更新週期, 也就是使用$nextTick, 如下:

let vue = new Vue({
  el: '#app',
  data () {
     return {
       menuItems: []
     }
  },
  mounted () {
	let vm = this
	doGetSomething(data => {
		vm.menuItems = data
		vm.$nextTick(() => {
			vm.$refs.menu.updateItems()
		})
	})
  }
})

這樣就沒問題了!

但對於一個元件來說, 這樣的設計並不是很好, 變成這個元件必須相依於使用它的程式, 還有沒更好的寫法?

在子原件更新時呼叫父元件呢?

b-menu-item這樣改寫:

Vue.component('b-menu-item', {
  template: '<div :id="id"><slot></slot></div>',
  props: ['title'],
  data () {
    return {
      id: ''
    }
  },
  mounted () {
     this.$parent.updateItems()
  }
})

這樣也是可行的, 當新的b-menu-item被加入slot中時, 就會呼叫一次updateItems

但這是有缺點的:

  1. 每個child component會呼叫一次, 但實際上不需要被呼叫這麼多次, 有點浪費
  2. 這個子原件的設計變成會依賴父元件, 不易與用在其他元件內

所以還是需要一個更好的方式

MutationObserver

這時候就要借用HTML5的MutationObserver, 這個在Vuejs內部也是大量地被使用

使用MutationObserver, 我們可以把b-menu改成這樣:

Vue.component('b-menu', {
  data () {
  	return {
      items: [],
	  domObserver: null
    }
  },
  template: '<div><ul><li v-for="(item,i) in items"><a :href="`#a${i+1}`">{{item.title}}</a></li></ul><div ref="content"><slot></slot></div></div>',
  methods: {
	updateItems () {
	  	this.items= this.$slots.default.filter(item => item.componentInstance || false).map(item => item.componentInstance)
		for (let [index, item] of this.items.entries()) {
		this.$set(item, 'id', 'a' + (index + 1))
		}
	}
  },
  mounted () {
     updateItems()
	 let vm = this
	 vm.domObserver = new MutationObserver((mr, el) => {
		 let shouldUpdate = false
		 for (let m of mr) {
			 if (m.addedNodes.length > 0 || m.removedNodes.length > 0) {
				 shouldUpdate = true
				 break
			 }
		 }

		 if (shouldUpdate) {
			 vm.updateItems()
		 }
	 })

	 vm.domObserver.observer(vm.$refs.content, {childList: true, subtree: true})
  }
})

藉由監控slot的父節點(parent node)的變化來確定是否要去更新items, 這樣一來就不用依賴其他人也可以做到自動更新了

axios是蠻好用的javascript http client, 不僅可以在browser上跑, 也可以在node.js上用, 而且Promise形態的API寫起來就比較好看, 如果搭配async/await的寫法, 看起來就更加漂亮了

function loadUser(uid) {
  axios.get('/user?ID=12345')
    .then(response => {
      console.log(response)
    })
    .catch(error => {
      console.log(error)
    })
}

或是(async/await)

async function loadUser(uid) {
  try {
    data = await axios.get('/user?ID=12345')
	console.log(data)
  } catch(e) {
    console.log(e)
  }
}

但如果開發時期或是要做Unit testing需要用假資料來取代server api直接回傳呢? 目前我看到兩套方案, 一個是axios作者做的moxios另外一個是axios-mock-adapter, moxios看起來好像比較適合在Unit testing時, 而我是想在開發過程中使用, 所以我選的是axios-mock-adapter

使用axios-mock-adapter還蠻簡單的:

import axios from 'axios'
import MockAdapter from 'axios-mock-adapter'

let mock = new MockAdapter(axios)

mock.onGet('/users').reply(200, {
  users: [
    { id: 1, name: 'John Smith' },
    { id: 2, name: 'John Doe' }
  ]
})

axios.get('/users')
  .then(response => {
    console.log(response.data)
  })

創建uri跟假資料的對應很簡單, 基本上也就是’on'‘Method’, 比如說onGet, onPost, 另外還有一個onAny可以處理所有的HTTP methods

做過mock後, axios呼叫這個uri所拿回來的資料通通就都會是假資料了, 這樣也不用為了塞假資料開發測試而去改動自己的程式