Spis treści

Go - wypisuję nazwy użytych na blogu tagów

Czas na kolejne ćwiczenie w ramach nauki języka Go i jego biblioteki standardowej. Napiszę program, który wypisze nazwy tagów, jakich dotychczas użyłam na moim blogu wraz z ilością otagowanych w ten sposób wpisów.

Problem - jak wypisać tagi używane na blogu

Podczas pisania każdego kolejnego posta zastanawiam się, jak go otagować. Czy tag, który chcę nadać, już istnieje? Czy pisałam wcześniej o tej dziedzinie? Potrzebuję szbko sprawdzić, jakie tagi nadałam wcześniej moim wpisom.

Nie wiem, czy hugo posiada jakąś opcję wypisywania tagów - jeśli ma, to dobrze ukrytą, bo nie służą do tego:

  • hugo list [typ] - to polecenie wyświetla wpisy, których typ to jedna z wartości [all, draft, future, expired],
  • hugo gen - generuje css, strony podręcznika man czy skrypty autocompletion

Nie widziałam żadnego hugo print tags

Jestem pewna, że istnieje jakiś plugin, który to robi. Ale sama też umiem, więc do dzieła!

Wiersz poleceń - rozwiązanie w 15 minut

Spróbowałam najpierw skonstruować odpowiedni pipeline z wiersza poleceń:

1
2
3
4
5
6
7
rg --no-filename -r'$1'  'tags\s?=\s?\[([^]]*)\]' ../content/ |\
string split ","|\
tr '"' ' '|\
string trim |\
sort |\
uniq -c |\
sort -r
  • używam polecenia ripgrep (rg):
    • bez wypisywania nazw plików (--no-filename)
    • podaję wyrażenie 'tags\s?=\s?\[([^]]*)\]' , do którego musi się dopasować wiersz w pliku (posiada grupę łapiącą zawartość tablicy z tagami)
    • z opcją -r '$1' która zamieni tekst dopasowany przez wyrażenie na zawartość pierwszej grupy (dzięki temu “wyjmę” pooddzielane przecinkami tagi ze środka tags=[…])
    • szukam we wszystkich plikach wewnątrz content/
  • używam fishowego polecenia string split "," które podzieli wiersz zawierający przecinki na listę wierszy
  • używam tr '"' ' ' - zamieniam podwójne cudzysłowia (hm, może mogą być inne znaki?…) wokół taga na spacje
  • używam fishowego polecenia string trim, które usunie spacje przed i po tagu
  • sortuję, żeby
  • zrobić uniq -c - co da mi napis w formacie ilość_tagów tag dla każdego unikalnego taga
  • sortuję odwrotnie (żeby tagi z największą ilością były u góry)

Czy osiągnęłam to, co chciałam?

/posts/go-lista-tagow/wiersz-polecen.jpg
Wykonanie polecenia wyświetlającego histogram tagów

Prawie. Jak widać na załączonym obrazku - pipeline przetwarza wszystkie wiersze w plikach, a więc rg zgarnie nie tylko ten jeden znajdujący się we frontmatter, ale także wszystkie innne, zawierające podane wyrażenie. Chciałabym kończyć przetwarzanie pliku po znalezieniu pierwszego wiersza. Hm, nie mam pomysłu, jak to zrobić w wierszu poleceń.

To chyba dobra okazja, żeby napisać program w go 😄

Program w Go - rozwiązanie w dwa wieczory

/posts/go-lista-tagow/program-go.jpg

Przy okazji pisania tego narządka nauczyłam się kilku interesujących rzeczy na temat języka i biblioteki standardowej.

Czego się nauczyłam

Nazwa binarki

Nie bardzo rozumiałam, skąd bierze się nazwa budowanej binarki - czy to nazwa pliku, w którym jest funckja main, czy może nazwa bierze się z nazwy modułu, która jest zapisana w go.mod? W zależności od tego, jak budowałam program w go, uzyskiwałam różne rezultaty:

  • go build . budował binarkę tools
  • go build main.go budował binarkę main

Odpowiedź znalazłam w dokumentacji na stronie Compile packages and dependencies:

When compiling a single main package, build writes the resulting executable to an output file named after the first source file (‘go build ed.go rx.go’ writes ‘ed’ or ‘ed.exe’) or the source code directory (‘go build unix/sam’ writes ‘sam’ or ‘sam.exe’).

W go możliwy jest Null Pointer Exception

Operator new nie zaalokuje pamięci na pustą mapę będącą polem wewnątrz struktury. Zwróci tylko wskaźnik do zaalokowanego obiektu struktury, ale pola - tu: jedno pole, mapa - będą zainicjowane wartościami zerowymi odpowiednimi dla ich typów. Dla typów referencyjnych (mapy, tablicy) wartością zerową jest nil. Zamiast

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
package main

type Stat struct {
  data map[string]int
}

func main() {
  var s = new(Stat)
  s["abc"] = 5
}

co kończy się odpowiednikiem NPE:

/posts/go-lista-tagow/struct-init-bad.jpg

należy użyć literału “konstruującego” strukturę (composite litaral):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
package main

type Stat struct {
	data map[string]int
}

func main() {
	var s = Stat{data: map[string]int{}}
	s.data["abc"] = 5
}

/posts/go-lista-tagow/struct-init-good.jpg

Moje typy

Oto moje typy danych:

  • Tag reprezentuje pojedynczy tag wraz z ilością wpisów nim oznaczonych:
1
2
3
4
type Tag struct {
	name  string
	count int
}
  • Tags to mapa nazwy taga w strukturę Tag
1
2
3
4
// Keeps all tags
type Tags struct {
	nameToTag map[string]*Tag
}

Rekurencyjny spacer wśród katalogów - Walk

Spodziewałam się, że w Go istnieje jakiś sposób przechodzenia po strukturze katalogów w systemie plików - jakiś odpowiednik os.walk dla Pythona czy Files.walk dla Javy.

Istnieje: [filepath.Walk]. Nie wiem, czy używam tej funkcji w sposób idiomatyczny. W porównaniu z API Javowym, które generuje mi piękny strumień obiektów Path, tutaj muszę przekazać funkcję - callback, która będzie wołana dla każdego znalezionego pliku.

Funkcja ma typ filepath.WalkFunc i sygnaturę func(path string, info fs.FileInfo, err error) error. Chcę, żeby ta funkcja podczas przechodzenia przez wiersze napotkanych plików aktualizowała mój obiekt Tags. Tworzę więc implementację filepath.WalkFunc jako clojure zawierający Tags przy pomocy mojej funkcji CreateProcessor:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func NewTags() *Tags {
	return &Tags{nameToTag: map[string]*Tag{}}
}

func main() {
	tags := NewTags()
	err := filepath.Walk(GetPath(), CreateProcessor(tags))
	if err != nil {
		log.Printf("Walk exited wiht err %v", err)
	}
	tags.Print()
}

Czytanie kolejnych wierszy z pliku

Oto CreateProcessor zwracająca implementację filepath.WalkFunc, która

  • sprawdza, czy jest to plik z rozszerzeniem .md
  • otwiera go
  • używa NewScanner do czytania kolejnych wierszy
  • jeśli dopasuje wiersz jako tags:
    • wywołuje na nich metodę Extract przekazanego w parametrze objektu TagExtractor.
1
2
3
type TagExtractor interface {
	Extract(s string) bool
}

Po dopasowaniu w pliku wiersza nie analizuję już kolejnych wierszy - kończę pętlę skanowania i przechodzę do kolejnego pliku.

Metoda Extract zwraca wartość bool oznaczającą, czy było dopasowanie do wiersza tags=[...]. Jeśli było, jest to sygnał do przerwania pętli iterującej po wierszach w pliku.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func CreateProcessor(lineProc TagExtractor) filepath.WalkFunc {

	proc := func(path string, info fs.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if !info.IsDir() && strings.HasSuffix(path, ".md") {

			file, err := os.Open(path)
			if err != nil {
				log.Fatal(err)
				return err
			}
			defer file.Close()

			scanner := bufio.NewScanner(file)
			for scanner.Scan() {
				t := scanner.Text()
				if lineProc.Extract(t) {
					// only parse first tags= line
					break
				}
			}
		}
		return nil
	}
	return proc
}

Wyrażenia regularne

Bardzo spodobało mi się API modułu regexp - dokumentacja tutaj - jest uporządkowane, logiczne i, kiedy się je już pozna, bardzo intuicyjne i łatwe w użyciu. Bardzo możliwe, że tak tylko mi się wydaje, bo nie robię nic skomplikowanego, ale sposób nazywania fukcji w API według pewnego schematu jakoś dobrze rezonuje z potrzebą porządku i logiki w mojej głowie.

Używam tego modułu do przefiltrowania wierszy zaczynających się od tags= oraz wyjęciu z nich nazw tagów.

Ciekawe: regexp.MustCompile() obok regexp.Compile() to chyba idiom - spotkałam go już w module do templejtów - dzięki któremu można łatwiej używać API:

MustCompile is like Compile but panics if the expression cannot be parsed. It simplifies safe initialization of global variables holding compiled regular expressions.

Tak wygląda implementacja TagExtractor dla typu Tags:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// Extracts all tags from string s
func (t *Tags) Extract(s string) bool {
	tagsGroupRe := regexp.MustCompile(`tags\s*=`)
	tagsExtractRe := regexp.MustCompile(`"[^"]*"`)
	if !tagsGroupRe.MatchString(s) {
		return false
	}
	ms := tagsExtractRe.FindAllString(s, -1)
	if ms != nil && len(ms) > 0 {
		for _, m := range ms {
			t.Update(strings.Trim(m, "\""))
		}
	} else {
		return false
	}
	return true
}

Sortowanie tagów

Po raz pierwszy napisałam implementację interfejsu sort.Interface, dzięki czemu mogę wypisać swoje taki od najczęstszego do najrzadziej występującego, a tagi występuące równie często wypisuję alfabetycznie.

Nauczyłam się też używania dokumentacji z wiersza poleceń (polecenie go doc):

1
2
go doc sort.Interface

Jeśli dobrze rozumiem, to sort.Sorted() działa jedynie na strukturze danych, której kolejne elementy są indeksowane liczbą. Nie mogę więc zaimplementować sort.Interface dla Tags. Potrzebuję nowy typ danych:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
type TagA []Tag

func (ta TagA) Len() int {
	return len(ta)
}

func (ta TagA) Less(i, j int) bool {
	a := ta[i]
	b := ta[j]

	if a.count > b.count {
		return true
	}
	if a.count == b.count && strings.ToLower(a.name) < strings.ToLower(b.name) {
		return true
	}
	return false
}
func (ta TagA) Swap(i, j int) {
	ta[i], ta[j] = ta[j], ta[i]
}

oraz sposób na przekształcenie Tags w posortowany TagA:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10

// Sorts by count (highest first) then by lowercase name
func (ts Tags) Sorted() TagA {
	tags := make(TagA, 0)
	for _, tg := range ts.nameToTag {
		tags = append(tags, *tg)
	}
	sort.Sort(tags)
	return tags
}

Musi być kolorowo

Nie kombinowałam, tylko dokleiłam sekwencje sterujące, które wyświetlą mi w bashu kolorki. Niezbyt to przenośne.

Formatowanie tekstu z polem o dynamicznie wyliczanej szerokości

W znaczniku szerokości pola formatu należy umieścić nie liczbę, ale gwiazdkę. Wartość zostanie odczytana z kolejnego argumentu - poprzedzającego ten, dla którego pisany jest format.

Przykład

Oto funkcja wypisująca posortowane tagi.

Bardziej poprawnie byłoby, gdybym nie obliczała szerokości pola na podstawie stałej czterocyfrowej, tylko znalazła największą liczbę wśród liczności tagów. Przyznam szczerze, że zajęło mi chwilę, żeby odkryć, że szerokość mojego pola musi uwzględnić również te sekwencje sterujące kolorem…

1
2
3
4
5
6
7
func (ts Tags) Print() {
	sorted := ts.Sorted()
	for _, t := range sorted {
		indent := len(Red + "1234" + Reset)
		fmt.Printf("%*s:  %s\n", indent, Red+strconv.Itoa(t.count)+Reset, Green+t.name+Reset)
	}
}

Kod źródłowy

Kod go-tags znajduje się na repozytorium na githubie.