オレオレブログのフロント側はこんなん(全部CacheしてDBを触らないぜ、の巻)

だいたいフロント側の実装はできた。これから管理画面(多分reactで)を実装する。1年以上前にs3(react), dynamo, lambda(c#)の構成でreact書いたけど、完全に忘れてるので時間かかりそう。また「せっかくだからやったことのない技術で!」メソッドだけど、Vue+TypeScriptとか言い出したらいつ完成するかわからないのでなんとなくやったことある技術にします。SPAじゃなくてもいいんだけどね……。

ところで https://www.dobusarai.net/blog-test/ だけど、一度MongoDBから取得したら全て変数に保持しているので、今現在はまったくDBを舐めていないはず。俺が全部舐めたので。

kousei

文字打つのもだるいので画像で貼るが、ファイル構成はこんな感じ。いろいろ考えたが、前のエントリにも書いたようにgo modのpackage、インターネットに存在することが前提というのがダルすぎるので、どこかで見たフラット構成(全部main)にしている。

主にmain, data, actionの3ファイルでほとんどの処理を行えてしまっている。たったこんだけでここまで出来ちゃうかーって。コメント整備やLogger未実装だが抜粋。

main.go

package main

import (
    "context"
    "os"
    "os/signal"
    "time"

    "github.com/gorilla/sessions"
    "github.com/labstack/echo-contrib/session"
    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
)

func init() {
    initializeData()
}

func main() {
    defer closeConnection()
    e := echo.New()
    e.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
        TokenLookup:    "form:csrftoken",
        CookiePath:     settings.RootPath,
        CookieHTTPOnly: true,
        CookieMaxAge:   0,
    }))
    e.Use(session.Middleware(sessions.NewCookieStore([]byte("secret"))))
    e.Static(settings.RootPath+"files", "files")
    e.File("/favicon.ico", "files/images/favicon.ico")
    e.Renderer = getTemplateRenderer()
    e.GET(settings.RootPath, indexAction)
    e.GET(settings.RootPath+":entry_code", entryAction)
    e.GET(settings.RootPath+"page/:num", pageAction)
    e.GET(settings.RootPath+"category/:categoryName", categoryAction)
    e.GET(settings.RootPath+"error/:code", errorAction)
    e.GET(settings.RootPath+settings.BackendURI, backendLoginAction)
    e.POST(settings.RootPath+settings.BackendURI, authenticationAction)
    e.GET(settings.RootPath+settings.BackendURI+"manager/", managerAction)
    e.HTTPErrorHandler = errorHandler
    // start server
    go func() {
        if err := e.Start(settings.HttpdPort); err != nil {
            /*
                TODO: Log全般の実装
            */
            e.Logger.Info("shutting down the server")
        }
    }()
    // graceful shutdown
    quit := make(chan os.Signal)
    signal.Notify(quit, os.Interrupt)
    <-quit
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    if err := e.Shutdown(ctx); err != nil {
        e.Logger.Fatal(err)
    }
}

data.go

package main

import (
    "context"
    "strconv"

    "go.mongodb.org/mongo-driver/bson"
    "go.mongodb.org/mongo-driver/mongo"
    "go.mongodb.org/mongo-driver/mongo/options"
    "gopkg.in/ini.v1"
)

// MongoEntries for get data from mongodb
type MongoEntries struct {
    EntryID     int32
    EntryCode   string
    PublishDate string
    Title       string
    Content     string
    Category    []string
    IsPublished int32
    AuthorID    int32
    CreatedAt   string
    UpdatedAt   string
}

// EntryItem for view
type EntryItem struct {
    EntryID     int // mysql時代の名残り(ぶっちゃけいらない?)
    URI         string
    PublishDate string
    Title       string
    Content     string
    Categories  []CategoryItem
}

// CategoryItem for view
type CategoryItem struct {
    CategoryName string
    CategoryURI  string
}

// TitleList for category search
type TitleList struct {
    URI         string
    PublishDate string
    Title       string
    Categories  []CategoryItem
}

// Settings struct
type Settings struct {
    HttpdPort     string
    BlogURL       string
    RootPath      string
    BackendURI    string
    PagePerView   int
    SessionName   string
    LoggedinKey   string
    LoggedinValue string
    DBUser        string
    DBPassword    string
    DBName        string
    DBHost        string
    DBPort        string
}

// Paginator struct
type Paginator struct {
    IsExists bool
    URI      string
}

// CacheEntries struct
type CacheEntries struct {
    EntryItems        []EntryItem
    NextPaginator     Paginator
    PreviousPaginator Paginator
}

// 定数
const (
    IsPublished      = 1
    MoreLinkString   = "<!--more-->"
    SettingsFilePath = "./settings.ini"
)

// fields
var (
    // settings
    settings Settings
    // db context
    ctx context.Context
    // db object
    client *mongo.Client
    // pagenate URL prefix
    paginatorPrefixURI string
    categoryPrefixURI  string
    // cache category
    cacheCategoriesAll []CategoryItem
    // cache entry (string = entryCode)
    cacheEntry map[string]EntryItem
    // cache entries for page (int = page)
    cacheEntriesForPage map[int]CacheEntries
    // cache titleList for category page (string = categoryName)
    cacheTitleList map[string][]TitleList
)

func initializeData() {
    // settings
    iniFile, err := ini.Load(SettingsFilePath)
    settings = Settings{
        HttpdPort:     iniFile.Section("app").Key("HttpdPort").String(),
        BlogURL:       iniFile.Section("site").Key("BlogURL").String(),
        RootPath:      iniFile.Section("site").Key("RootPath").String(),
        BackendURI:    iniFile.Section("site").Key("BackendURI").String(),
        PagePerView:   iniFile.Section("site").Key("PagePerView").MustInt(),
        SessionName:   iniFile.Section("site").Key("SessionName").String(),
        LoggedinKey:   iniFile.Section("site").Key("LoggedinKey").String(),
        LoggedinValue: iniFile.Section("site").Key("LoggedinValue").String(),
        DBUser:        iniFile.Section("db").Key("DBUser").String(),
        DBPassword:    iniFile.Section("db").Key("DBPassword").String(),
        DBName:        iniFile.Section("db").Key("DBName").String(),
        DBHost:        iniFile.Section("db").Key("DBHost").String(),
        DBPort:        iniFile.Section("db").Key("DBPort").String(),
    }
    // link urls
    paginatorPrefixURI = settings.RootPath + "page/"
    categoryPrefixURI = settings.RootPath + "category/"
    // init DB
    ctx = context.Background()
    credential := options.Credential{
        AuthSource: settings.DBName,
        Username:   settings.DBUser,
        Password:   settings.DBPassword,
    }
    uri := "mongodb://" + settings.DBHost + ":" + settings.DBPort
    temp, err := mongo.Connect(ctx, options.Client().ApplyURI(uri).SetAuth(credential))
    if err != nil {
        panic("db error")
    }
    client = temp
    // init category slice
    getCategoriesAll()
    // cache map init
    cacheEntry = make(map[string]EntryItem)
    cacheEntriesForPage = make(map[int]CacheEntries)
    cacheTitleList = make(map[string][]TitleList)
}

func closeConnection() {
    client.Disconnect(ctx)
}

// category collection作らずにエントリから全てのカテゴリを抽出する(重複は無視)
func getCategoriesAll() {
    cacheCategoriesAll = nil
    entries := client.Database(settings.DBName).Collection("entries")
    cur, err := entries.Find(ctx, bson.D{})
    if err != nil {
        return
    }
    defer cur.Close(ctx)
    for cur.Next(ctx) {
        var result MongoEntries
        err := cur.Decode(&result)
        if err != nil {
            //log.Fatal(err)
            continue
        }
        for _, name := range result.Category {
            // goにはin_array, List<T>.Containsみたいなものは無いみたいなので自前チェック
            isExists := false
            for _, v := range cacheCategoriesAll {
                if v.CategoryName == name {
                    isExists = true
                    break
                }
            }
            if !isExists {
                cacheCategoriesAll = append(cacheCategoriesAll, CategoryItem{
                    CategoryName: name,
                    CategoryURI:  categoryPrefixURI + name,
                })
            }
        }
    }
}

// get entry item with paginator flag(next, previous)
func getEntryList(page int) ([]EntryItem, Paginator, Paginator) {
    // cache exists check & return
    if val, ok := cacheEntriesForPage[page]; ok {
        return val.EntryItems, val.NextPaginator, val.PreviousPaginator
    }
    // get entry list
    nextPaginator := Paginator{}
    if page > 0 {
        // pageから-1にして0の場合はindexにする
        if (page - 1) == 0 {
            nextPaginator = Paginator{IsExists: true, URI: settings.RootPath}
        } else {
            nextPaginator = Paginator{IsExists: true, URI: paginatorPrefixURI + strconv.Itoa(page-1)}
        }
    }
    previousPaginator := Paginator{}
    offset := page * settings.PagePerView
    var entryItems []EntryItem
    entries := client.Database(settings.DBName).Collection("entries")
    // paginateのために1件多く取得する
    findOption := options.Find().SetSort(bson.D{{Key: "publishDate", Value: -1}}).SetSkip(int64(offset)).SetLimit(int64(settings.PagePerView + 1))
    cur, err := entries.Find(ctx, bson.D{{Key: "isPublished", Value: 1}}, findOption)
    if err != nil {
        //log.Fatal(err)
        return entryItems, nextPaginator, previousPaginator
    }
    // findはスライス等で返ってこない - *Cursol型で返ってくるので下記のようにループ回して取得
    // PagePerViewの値を超えて存在した場合、Paginater->Previousは有効になる(paginaterのために1件多く取得している)
    defer cur.Close(ctx)
    index := 0
    for cur.Next(ctx) {
        if index >= settings.PagePerView {
            previousPaginator = Paginator{IsExists: true, URI: paginatorPrefixURI + strconv.Itoa(page+1)}
            break
        }
        var result MongoEntries
        err := cur.Decode(&result)
        if err != nil {
            //log.Fatal(err)
            return entryItems, nextPaginator, previousPaginator
        }
        var categories []CategoryItem
        for _, v := range result.Category {
            categories = append(categories, CategoryItem{CategoryName: v, CategoryURI: categoryPrefixURI + v})
        }
        entryItems = append(entryItems, EntryItem{
            EntryID:     int(result.EntryID),
            URI:         settings.RootPath + result.EntryCode,
            PublishDate: result.PublishDate,
            Title:       result.Title,
            Content:     result.Content,
            Categories:  categories,
        })
        index++
    }
    // save cache
    cacheEntriesForPage[page] = CacheEntries{EntryItems: entryItems, NextPaginator: nextPaginator, PreviousPaginator: previousPaginator}
    return entryItems, nextPaginator, previousPaginator
}

func getEntry(entryCode string) EntryItem {
    // cache exists check & return
    if val, ok := cacheEntry[entryCode]; ok {
        return val
    }
    // get entry
    var entryItem EntryItem
    entries := client.Database(settings.DBName).Collection("entries")
    var result MongoEntries
    err := entries.FindOne(ctx, bson.D{{Key: "entryCode", Value: entryCode}, {Key: "isPublished", Value: 1}}).Decode(&result)
    if err != nil {
        return entryItem
    }
    var categories []CategoryItem
    for _, v := range result.Category {
        categories = append(categories, CategoryItem{CategoryName: v, CategoryURI: categoryPrefixURI + v})
    }
    entryItem = EntryItem{
        EntryID:     int(result.EntryID),
        URI:         settings.RootPath + result.EntryCode,
        PublishDate: result.PublishDate,
        Title:       result.Title,
        Content:     result.Content,
        Categories:  categories,
    }
    // save cache
    cacheEntry[entryCode] = entryItem
    return entryItem
}

func getTitleList(categoryName string) []TitleList {
    // cache exists check & return
    if val, ok := cacheTitleList[categoryName]; ok {
        return val
    }
    // get title list
    var titleList []TitleList
    entries := client.Database(settings.DBName).Collection("entries")
    findOption := options.Find().SetSort(bson.D{{Key: "publishDate", Value: -1}})
    cur, err := entries.Find(ctx, bson.D{{Key: "category", Value: categoryName}, {Key: "isPublished", Value: 1}}, findOption)
    if err != nil {
        //log.Fatal(err)
        return titleList
    }
    defer cur.Close(ctx)
    for cur.Next(ctx) {
        var result MongoEntries
        err := cur.Decode(&result)
        if err != nil {
            //log.Fatal(err)
            return titleList
        }
        var categories []CategoryItem
        for _, v := range result.Category {
            categories = append(categories, CategoryItem{CategoryName: v, CategoryURI: categoryPrefixURI + v})
        }
        titleList = append(titleList, TitleList{
            URI:         settings.RootPath + result.EntryCode,
            PublishDate: result.PublishDate,
            Title:       result.Title,
            Categories:  categories,
        })
    }
    // save cache
    cacheTitleList[categoryName] = titleList
    return titleList
}

action.go

package main

import (
    "log"
    "net/http"
    "strconv"

    "github.com/labstack/echo/v4"
)

// error handler
func errorHandler(err error, c echo.Context) {
    code := http.StatusInternalServerError
    errorMessage := "server error"
    if he, ok := err.(*echo.HTTPError); ok {
        code = he.Code
        errorMessage = he.Message.(string)
    }
    log.Print(errorMessage) // TODO:log周り全体的にちゃんと書く
    c.Redirect(http.StatusFound, settings.RootPath+"error/"+strconv.Itoa(code))
}

// error action
func errorAction(c echo.Context) error {
    code, err := strconv.Atoi(c.Param("code"))
    if err != nil {
        code = http.StatusInternalServerError
    }
    errorMessage := "internal server error"
    if code == http.StatusNotFound {
        errorMessage = "not found"
    } else if code == http.StatusBadRequest {
        errorMessage = "Bad Request"
    }
    return c.Render(code, "error.html", map[string]interface{}{
        "title":         strconv.Itoa(code),
        "error_code":    strconv.Itoa(code),
        "error_message": errorMessage,
        "root_path":     settings.RootPath,
    })
}

// index action
func indexAction(c echo.Context) error {
    entries, next, previous := getEntryList(0)
    if entries == nil {
        return c.Redirect(http.StatusFound, settings.RootPath+"error/404")
    }
    return c.Render(http.StatusOK, "multiple.html", map[string]interface{}{
        "title":      "",
        "root_path":  settings.RootPath,
        "entries":    entries,
        "next":       next,
        "previous":   previous,
        "categories": cacheCategoriesAll,
    })
}

// entry action
func entryAction(c echo.Context) error {
    entryItem := getEntry(c.Param("entry_code"))
    if entryItem.EntryID < 1 {
        return c.Redirect(http.StatusFound, settings.RootPath+"error/404")
    }
    return c.Render(http.StatusOK, "single.html", map[string]interface{}{
        "title":      entryItem.Title,
        "root_path":  settings.RootPath,
        "entry":      entryItem,
        "categories": cacheCategoriesAll,
    })
}

// page action
func pageAction(c echo.Context) error {
    num, err := strconv.Atoi(c.Param("num"))
    if err != nil {
        return c.Redirect(http.StatusFound, settings.RootPath+"error/400")
    }
    entries, next, previous := getEntryList(num)
    if entries == nil {
        return c.Redirect(http.StatusFound, settings.RootPath+"error/404")
    }
    return c.Render(http.StatusOK, "multiple.html", map[string]interface{}{
        "title":      "",
        "root_path":  settings.RootPath,
        "entries":    entries,
        "next":       next,
        "previous":   previous,
        "categories": cacheCategoriesAll,
    })
}

// category action
func categoryAction(c echo.Context) error {
    categoryName := c.Param("categoryName")
    titleList := getTitleList(categoryName)
    if titleList == nil {
        return c.Redirect(http.StatusFound, settings.RootPath+"error/404")
    }
    return c.Render(http.StatusOK, "category_page.html", map[string]interface{}{
        "title":        "category : " + categoryName,
        "root_path":    settings.RootPath,
        "categoryName": categoryName,
        "titleList":    titleList,
        "categories":   cacheCategoriesAll,
    })
}

// backend login action
func backendLoginAction(c echo.Context) error {
    var errorMessage string
    errQuery := c.QueryParam("err")
    if errQuery != "" {
        errorMessage = "Invalid Username or Password."
    }
    return c.Render(http.StatusOK, "login.html", map[string]interface{}{
        "token":         c.Get("csrf").(string),
        "error_message": errorMessage,
    })
}

// authentication action
// CSRFは明示的にvalidate()みたいのをしなくても不正な値で勝手にerrorHandler呼ばれる。ちょっとキモい。
func authenticationAction(c echo.Context) error {
    user := c.FormValue("user")
    password := c.FormValue("password")
    if user == "user" && password == "80211" {
        err := saveSession(c)
        if err != nil {
            return c.Redirect(http.StatusFound, settings.RootPath+"error/500")
        }
        return c.Redirect(http.StatusFound, settings.RootPath+settings.BackendURI+"manager/")
    }
    return c.Redirect(http.StatusFound, settings.RootPath+settings.BackendURI+"?err=invalid")
}

// manager action
func managerAction(c echo.Context) error {
    if !isLoggedin(c) {
        return c.Redirect(http.StatusFound, settings.RootPath+settings.BackendURI)
    }
    return c.String(http.StatusOK, "backend")
}

これの他のgoファイルはsessionだったりtemplateメソッドだったり。backendのログインはSPA側でやるか、ログインだけはベタっとtemplateでやるか考え中。sessionとかcookieとかは調べた。