だいたいフロント側の実装はできた。これから管理画面(多分reactで)を実装する。1年以上前にs3(react), dynamo, lambda(c#)の構成でreact書いたけど、完全に忘れてるので時間かかりそう。また「せっかくだからやったことのない技術で!」メソッドだけど、Vue+TypeScriptとか言い出したらいつ完成するかわからないのでなんとなくやったことある技術にします。SPAじゃなくてもいいんだけどね……。
ところで https://www.dobusarai.net/blog-test/ だけど、一度MongoDBから取得したら全て変数に保持しているので、今現在はまったくDBを舐めていないはず。俺が全部舐めたので。
文字打つのもだるいので画像で貼るが、ファイル構成はこんな感じ。いろいろ考えたが、前のエントリにも書いたように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とかは調べた。