Пишем веб-сервер для markdown-заметок на Go

zaz600
Golang Notes
Published in
8 min readAug 9, 2015

--

В очередной раз перед уходом в отпуск думал о том, в каком виде оставить коллегам свои “тайные знания” и решил разместить документацию в форме веб-ресурса в локальной сети.

Устанавливать какой-нибудь тяжелый вики-движок не хотелось, так как это требует определенных манипуляций при установке, да и работать будет не совсем прозрачно, поэтому решил написать свой сервер на базе Go.

Требования самые минимальные. Заметки должны быть набраны в текстовых файлах в формате markdown, а сервер должен их отображать в виде html. Должна быть предусмотрена подсветка синтаксиса для блоков с кодом, ни онлайн-редактор для создания заметок, ни какая-либо админка не нужны. Результат работы сервера в браузере должен выглядеть примерно так:

Для того, чтобы наше приложение заработало как веб-сервер, воспользуемся стандартной библиотекой net/http.

Создаем файл mdserver.go

package mainimport (
"fmt"
"log"
"net/http"
)
func main() {
http.HandleFunc("/", postHandler)
log.Println("Listening...")
http.ListenAndServe(":3000", nil)
}
func postHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello")
}

Компилируем его, запускаем. Открываем в браузере адрес http://127.0.0.1:3000 и видим там текст “Hello”.

Для генерации html кода воспользуемся шаблонами из библиотеки html/template.

Создадим два шаблона: layout.html и post.html в папке templates.

В layout.html поместим основные элементы нашего сайта: верхнюю строку с ссылками, необходимые стили и javascript библиотеки.
Кстати, мы будем использовать bootstrap.

{{define "layout"}}
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/bootstrap/css/bootstrap.min.css">
<link rel="stylesheet" href="/static/bootstrap/css/bootstrap-theme.min.css">
<link rel="stylesheet" href="/static/stylesheets/main.css">
{{template "head"}}
<title>{{template "title" .}}</title>
</head>
<body>
<nav class="navbar-wrapper navbar-default navbar-fixed-top navbar-inverse" role="navigation">
<div class="container-fluid">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target="#bs-example-navbar-collapse-1">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<span class="navbar-brand" href="#">База знаний</span>
</div>
<div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
<ul class="nav navbar-nav">
<li><a href="/">Главная</a></li>
<li><a href="/reports/"><span class="glyphicon glyphicon-exclamation-sign"></span> Отчеты</a></li>
</ul></div>
<!-- /.navbar-collapse -->
</div>
<!-- /.container-fluid -->
</nav>
{{template "body" .}}
<script src="/static/bootstrap/js/jquery-1.11.1.min.js"></script>
<script src="/static/bootstrap/js/bootstrap.min.js"></script>
{{template "scripts"}}
</body>
</html>
{{end}}

Обратите внимание на точку в конце инструкции template

{{template "title" .}}

{{template "body" .}}

Если её не поставить, то во вложенный шаблон не будут передаваться переменные, из которых мы будем брать данные для шаблона (см. .Title и .Body в post.html) .

// public/static/main.cssbody { padding-bottom: 70px; padding-top: 70px; }
#container
{
background:#fff;
margin:0 auto;
text-align:center;
}

В шаблоне post.html мы подключим highlightjs для подсветки синтаксиса языков программирования.

{{define "head"}}
<link href="/static/highlight/styles/ir_black.css" rel="stylesheet" media="screen">
{{end}}
{{define "title"}}{{.Title}} {{end}}{{define "body"}}
<div class="container" id="container">
<div class="row" style="text-align:left;">
<div class="col-md-8 col-md-offset-2">
<h1>{{.Title}}</h1>
{{.Body}}
</div>
</div>
</div>
{{end}}
{{define "scripts"}}
<script src="/static/highlight/highlight.pack.js"></script>
<script>hljs.initHighlightingOnLoad();</script>
<script type="text/javascript">
</script>
{{end}}

Итак, шаблоны мы разместили в папке templates, а все необходимые javascript библиотеки и таблицы стилей поместим внутрь папки public/static/

Теперь внесем изменение в код, чтобы сервер мог отдавать статические файлы (javascript, css и т.п.), а также начал использовать шаблоны.

package mainimport (
"html/template"
"log"
"net/http"
"path"
)
var (
// компилируем шаблоны, если не удалось, то выходим
post_template = template.Must(template.ParseFiles(path.Join("templates", "layout.html"), path.Join("templates", "post.html")))
)
func main() {
// для отдачи сервером статичных файлов из папки public/static
fs := http.FileServer(http.Dir("./public/static"))
http.Handle("/static/", http.StripPrefix("/static/", fs))
http.HandleFunc("/", postHandler)
log.Println("Listening...")
http.ListenAndServe(":3000", nil)
}
func postHandler(w http.ResponseWriter, r *http.Request) {
// обработчик запросов
if err := post_template.ExecuteTemplate(w, "layout", nil); err != nil {
log.Println(err.Error())
http.Error(w, http.StatusText(500), 500)
}
}

Компилируем, запускаем. Открываем в браузере адрес http://127.0.0.1:3000

Создадим папку posts и поместим туда файл index.md

Инструкции
### Наша ERP
* [Ошибка XXX при обработке YYY](/p2)
* [Переотправка документа клиенту](/p3)
* [Запуск ERP](/p4)
* [Зачистка каталогов на серверах](/p5)
### Прочее* [Как снять отчет из хелпдеска](/p1)

Для преобразования markdown в html будем использовать github.com/russross/blackfriday

Первая строка файла с заметкой содержит заголовок, остальные строки — сама заметка.

Скачаем и подключим библиотеки.

go get github.com/russross/blackfridayimport (
"github.com/russross/blackfriday"
"html/template"
"io/ioutil"
"log"
"net/http"
"path"
"strings"
)

Внесем изменения в функцию postHandler

func postHandler(w http.ResponseWriter, r *http.Request) {
fileread, _ := ioutil.ReadFile("posts/index.md")
lines := strings.Split(string(fileread), "\n")
title := string(lines[0])
body := strings.Join(lines[1:len(lines)], "\n")
body = string(blackfriday.MarkdownCommon([]byte(body)))
post := Post{title, template.HTML(body)}

if err := post_template.ExecuteTemplate(w, "layout", post); err != nil {
log.Println(err.Error())
http.Error(w, http.StatusText(500), 500)
}
}

Считываем файл с заметкой, отделяем заголовок от текста, текст преобразуем в html и передаем его в шаблон через переменную post типа Post.

Объявим новый тип.

type Post struct {
Title string
Body template.HTML
}

В шаблоне содержимое post помещается там, где мы указали (см. post.html).

<h1>{{.Title}}</h1>
{{.Body}}

Если в шаблон передать html-код, то в целях безопасности он не будет интерпретирован и будет выглядеть так:

Поэтому мы преобразуем результат конвертации в template.HTML.

post := Post{title, template.HTML(body)}

Полный текст.

package mainimport (
"github.com/russross/blackfriday"
"html/template"
"io/ioutil"
"log"
"net/http"
"path"
"strings"
)
type Post struct {
Title string
Body template.HTML
}
var (
// компилируем шаблоны, если не удалось, то выходим
post_template = template.Must(template.ParseFiles(path.Join("templates", "layout.html"), path.Join("templates", "post.html")))
)
func main() {
// для отдачи сервером статичный файлов из папки public/static
fs := http.FileServer(http.Dir("./public/static"))
http.Handle("/static/", http.StripPrefix("/static/", fs))
http.HandleFunc("/", postHandler)
log.Println("Listening...")
http.ListenAndServe(":3000", nil)
}
func postHandler(w http.ResponseWriter, r *http.Request) {
// обработчик запросов
fileread, _ := ioutil.ReadFile("posts/index.md")
lines := strings.Split(string(fileread), "\n")
title := string(lines[0])
body := strings.Join(lines[1:len(lines)], "\n")
body = string(blackfriday.MarkdownCommon([]byte(body)))
post := Post{title, template.HTML(body)}
if err := post_template.ExecuteTemplate(w, "layout", post); err != nil {
log.Println(err.Error())
http.Error(w, http.StatusText(500), 500)
}
}

Компилируем, запускаем. Открываем в браузере адрес http://127.0.0.1:3000

Итак, главная страница у нас есть, но ссылки пока не работают корректно. При нажатии на них всегда открывается главная страница.

Чтобы ссылки заработали, сначала необходимо создать файлы с заметками. Создадим в папке posts два файла.

p1.md

Как снять отчет из хелпдеска
Чтобы снять отчет, необходимо
* Войти в админку
* Зайти в меню Отчеты
* Выбрать месяц и год
* Тип отчета: "ХХХ"
* Выполнить

p2.md

Ошибка XXX при обработке YYY
Если при обработке YYY возникает ошибка:
>%Произошла ошибка при формировании ZZZ%то это означает, что необходимо добавить в справочник недостающую запись.```sql
insert into table1
```

Кстати, файлы необходимо набирать в кодировке UTF-8

Напомню, что первая строка в файле с заметкой — это заголовок, который будет выглядеть так:

Теперь в папке posts есть три файла index.md, p1.md, p2.md и нам необходимо изменить программу таким образом, чтобы она отображала нужный нам файл в зависимости от ссылки, которую откроет пользователь в браузере.

Пишем функцию, которая будет загружать файл с заметкой, конвертировать его в html и возвращать в виде Post-объекта.

// Загружает markdown-файл и конвертирует его в HTML
// Возвращает объект типа Post
// Если путь не существует или является каталогом, то возвращаем ошибку
func load_post(md string) (Post, int, error) {
info, err := os.Stat(md)
if err != nil {
if os.IsNotExist(err) {
// файл не существует
return Post{}, http.StatusNotFound, err
}
}
if info.IsDir() {
// не файл, а папка
return Post{}, http.StatusNotFound, fmt.Errorf("dir")
}
fileread, _ := ioutil.ReadFile(md)
lines := strings.Split(string(fileread), "\n")
title := string(lines[0])
body := strings.Join(lines[1:len(lines)], "\n")
body = string(blackfriday.MarkdownCommon([]byte(body)))
post := Post{title, template.HTML(body)}
return post, 200, nil
}

Отразим изменения в postHandler

func postHandler(w http.ResponseWriter, r *http.Request) {
// обработчик запросов
post, status, err := load_post("posts/index.md")
if err != nil {
http.Error(w, http.StatusText(status), status)
return
}
if err := post_template.ExecuteTemplate(w, "layout", post); err != nil {
log.Println(err.Error())
http.Error(w, http.StatusText(500), 500)
}
}

И добавим библиотеки.

import (
"fmt"
"github.com/russross/blackfriday"
"html/template"
"io/ioutil"
"log"
"net/http"
"os"
"path"
"strings"
)

Можно скомпилировать и проверить работоспособность программы. Но пока ссылки так и не открываются.

Для обработки ссылок будем использовать библиотеку Pat
Установим её и добавим в импорт.

go get github.com/bmizerany/pat

Добавляем в main обработчик ссылок.

 mux := pat.New()
mux.Get("/:page", http.HandlerFunc(postHandler))
mux.Get("/:page/", http.HandlerFunc(postHandler))
mux.Get("/", http.HandlerFunc(postHandler))
http.Handle("/", mux)
log.Println("Listening...")
http.ListenAndServe(":3000", nil)

Добавим обработку ссылок в postHandler

func postHandler(w http.ResponseWriter, r *http.Request) {
params := r.URL.Query()
// Извлекаем параметр
// Например, в http://127.0.0.1:3000/p1 page = "p1"
// в http://127.0.0.1:3000/ page = ""
page := params.Get(":page")
// Путь к файлу (без расширения)
// Например, posts/p1
p := path.Join("posts", page)
var post_md string
if page != "" {
// если page не пусто, то считаем, что запрашивается файл
// получим posts/p1.md
post_md = p + ".md"
} else {
// если page пусто, то выдаем главную
post_md = p + "/index.md"
}
post, status, err := load_post(post_md)

Компилируем, запускаем. Открываем в браузере адрес http://127.0.0.1:3000 и видим, что ссылки, для которых мы создали файлы (p1 и p2), открываются.

Если открыть ссылку, для которой не создан файл, то увидим ошибку.

Страница с ошибкой выглядит ужасно и не выполнена в том же стиле, что и остальные страницы. Исправим это.

Создаем шаблон для страницы с ошибкой в папке templates.

templates/error.html

{{define "head"}}
{{end}}
{{define "title"}}Ошибка{{end}}
{{define "body"}}
<div class="container" id="container">
<div class="row" style="text-align:left;">
<div class="col-md-8 col-md-offset-2">
{{.Status}} {{.Error}}
</div>
</div>
</div>
{{end}}
{{define "scripts"}}
{{end}}

Компилируем шаблон

var (
// компилируем шаблоны, если не удалось, то выходим
post_template = template.Must(template.ParseFiles(path.Join("templates", "layout.html"), path.Join("templates", "post.html")))
error_template = template.Must(template.ParseFiles(path.Join("templates", "layout.html"), path.Join("templates", "error.html"))))

Пишем функцию, которая будет выводить ошибку в скомпилированный шаблон.

func errorHandler(w http.ResponseWriter, r *http.Request, status int) {
w.WriteHeader(status)
if err := error_template.ExecuteTemplate(w, "layout", map[string]interface{}{"Error": http.StatusText(status), "Status": status}); err != nil {
log.Println(err.Error())
http.Error(w, http.StatusText(500), 500)
return
}
}

Добавляем вызов errorHandler вместо вызова стандартной функции, выводящей ошибку.

post, status, err := load_post(post_md)
if err != nil {
errorHandler(w, r, status)
return
}
if err := post_template.ExecuteTemplate(w, "layout", post); err != nil {
log.Println(err.Error())
errorHandler(w, r, 500)
}

Компилируем и проверяем.

Теперь остается только создавать файлы с заметками и делать на них ссылку в index.md.

Финальный код целиком можно посмотреть на github.

Что еще можно улучшить?

Например, сделать кеширование заметок, если они не изменялись, вместо того, чтобы перечитывать их каждый раз.
Еще можно сделать так, чтобы ссылки на верхней панели подгружались из конфигурационного файла так же как и адрес/порт, на котором работает приложение.

Ссылки

  1. Markdown
  2. How to code a markdown blogging system in Go
  3. Blackfriday is a Markdown processor implemented in Go
  4. Hugo :: A fast and modern static website engine
  5. pat — A Sinatra style pattern muxer for Go’s net/http library
  6. Bootstrap
  7. highlight.js — Syntax highlighting for the Web
  8. The Go templates post
  9. Репозиторий на github

--

--