自建云笔记方案

710 阅读6分钟

自建云笔记方案

需求

因为没有找到符合自己期望的云笔记平台,所以利用服务器自行搭建一个,我的需求是:

  • 跨平台:在公司用的是Windows,家里用Mac
  • 数据同步:多端同步,还要支持跨平台
  • 数据备份:笔记数据掌握在自己手里,可以不依赖于云笔记平台
  • markdown:支持markdown文本编写
  • 图床:除了笔记,图片也要自行保存,不依赖于网络图床平台

实现方案

综上,经过技术选型,最终的实现方案为:

  • Syncthing:一个Go语言实现的跨平台数据同步,可以在多台设备之间同步数据
  • Typora:跨平台的markdown文本编写工具
  • Minio:高性能的存储工具,适合存储海量图片、视频等文件,使用Go语言开发实现,所以有跨平台、内存占用小等特点。可以用来做图床完全没问题
  • Golang:编写图片上传脚本以及冗余图片清理脚本
  • Picgo(可选) :Typora内置支持Picgo进行图片上传,但没有冗余图片清理功能
  • Git(可选) :替代Syncthing作为笔记存储工具

实现步骤

Syncthing

首先需要在服务器上安装Syncthing,由服务器作为主节点及中转站,这样处于内网下的其他设备才可以互通。

  1. syncthing可以在官网下载:

syncthing.net/downloads/

下载完后启动即可

  1. 也可以通过docker进行部署:
docker run -itd \
  --name=syncthing \
  -e PUID=1000 \
  -e PGID=1000 \
  -p 8384:8384 \
  -p 22000:22000/tcp \
  -p 22000:22000/udp \
  -p 21027:21027/udp \
  -v /home/mycontainer/syncthing/data \
  -v /home/mycontainer/syncthing/config:/config \
  --restart=always \
  --net mynetwork \
  syncthing/syncthing:latest

启动成功后访问:http://localhost:8384

syncthing的使用不难,官网有很详细的说明

Typora

Typora上官网下即可:

typoraio.cn/

Minio

Minio同Syncthing,我这里使用Docker进行部署:

docker run --name myminio -p 8789:9000 -itd --restart=always -v /etc/localtime:/etc/localtime -v /home/mycontainers/myminio/data:/data -v /home/mycontainers/myminio/config:/root/.minio --net mynetwork -e "MINIO_ACCESS_KEY=minio"  -e "MINIO_SECRET_KEY=minio123" minio/minio:RELEASE.2021-06-17T00-10-46Z server /data

启动成功后访问:http://localhost:8789

新建一个桶(bucket)存储作为图床,桶名:note

再新建一个桶作为备份,防止清理冗余图片出现异常状态,有一个备份比较安心,桶名:note-backup

Golang

编写图片上传脚本(minioUploader)和冗余图片清理脚本(minioCleaner)

minioUploader
package main
​
import (
    "context"
    "fmt"
    "github.com/minio/minio-go/v7"
    "github.com/minio/minio-go/v7/pkg/credentials"
    "log"
    "net/http"
    "os"
    "path"
    "strconv"
    "time"
)
​
// 定义配置文件接收对象
type JsonConfig struct {
    Bucket string `json:bucket`
    Url string `json:url`
    Username string `json:username`
    Password string `json:password`
}
​
func main() {
    /**
    golang实现的minio文件上传程序,可以结合typora实现图片自动上传
    */var conf JsonConfig
    conf.Url = "localhost:8789"
    conf.Username = "minio"
    conf.Password = "minio123"
    conf.Bucket = "note"
​
    bucket := conf.Bucket
    url := conf.Url
    username := conf.Username
    password := conf.Password
​
    filePath := os.Args[1]
​
    minioClient := minioInitialize(url, username, password)
    milliSecond := strconv.FormatInt(time.Now().UnixMilli(), 10)
    milliSecond = milliSecond[len(milliSecond)-3:]
​
    // 定义文件名
    objectName := "image-" + time.Now().Format("20060102150405") + milliSecond + path.Ext(filePath)
​
    file, err := os.Open(filePath)
    if err != nil {
        fmt.Println(err)
        return
    }
    defer file.Close()
    contentType, err := getFileContentType(file)
    if err != nil {
        fmt.Println(err)
    }
​
    _, err = minioClient.FPutObject(context.Background(), bucket, objectName, filePath, minio.PutObjectOptions{
        ContentType: contentType,
    })
    if err != nil {
        fmt.Println(err)
        return
    }
​
    result := "http://" + conf.Url + "/" + bucket + "/" +objectName
    fmt.Println(result)
}
​
// 初始化minio
func minioInitialize(url string, username string, password string) *minio.Client {
    endpoint := url
    accessKeyID := username
    secretAccessKey := password
    useSSL := false
​
    minioClient, err := minio.New(endpoint, &minio.Options{
        Creds:  credentials.NewStaticV4(accessKeyID, secretAccessKey, ""),
        Secure: useSSL,
    })
    if err != nil {
        log.Fatalln(err)
    }
​
    return minioClient
}
​
// 获取文件ContentType
func getFileContentType(out *os.File) (string, error) {
    buffer := make([]byte, 512)
    _, err := out.Read(buffer)
    if err != nil {
        return "", err
    }
    return http.DetectContentType(buffer), nil
}
minioCleaner
package main
​
import (
    "context"
    "encoding/json"
    "fmt"
    "github.com/minio/minio-go/v7"
    "github.com/minio/minio-go/v7/pkg/credentials"
    "io/fs"
    "io/ioutil"
    "log"
    "os"
    "path/filepath"
    "regexp"
    "strings"
    "time"
)
​
// 定义配置文件接收对象
type JsonConfig struct {
    Dir string `json:dir`
    Force string `json:force`
    Bucket string `json:bucket`
    BucketBackup string `json:bucketBackup`
    Prefix string `json:prefix`
    Url string `json:url`
    Username string `json:username`
    Password string `json:password`
    Loop string `json:loop`
}
​
// 脚本工具集
func main() {
    /**
    minio图床的清理脚本,清理图床未被引用的图片
    */// 笔记根目录
    jsonPath := os.Args[1]
​
    var conf JsonConfig
    data, err := ioutil.ReadFile(jsonPath)
    if err != nil {
        fmt.Println(err)
        return
    }
    err = json.Unmarshal(data, &conf)
    if err != nil {
        fmt.Println(err)
        return
    }
​
    dir := conf.Dir
    force := conf.Force
    bucket := conf.Bucket
    prefix := conf.Prefix
    url := conf.Url
    username := conf.Username
    password := conf.Password
​
    // minio配置
    minioClient := minioInitialize(url, username, password)
​
    for true {
        // 1.
        mdPaths := getMdFilePaths(dir)
        fmt.Println("该路径下的md文件数组:", mdPaths)
​
        // 2.
        imgUrlsFromMdPaths := getImgUrlsFromMdPaths(mdPaths)
        fmt.Println("扫描md得到的图片数量为:", len(imgUrlsFromMdPaths))
        if len(imgUrlsFromMdPaths) == 0 && force != "clean" {
            fmt.Println("md内的图片数量为0,或许是因为路径参数有问题,请检查配置的路径参数(dir)或者将force参数设为:clean")
            return
        }
​
        // 3.
        imgUrlsFromMinio := getImgUrlsFromMinio(minioClient, bucket, prefix)
        fmt.Println("minio中的图片数量为:", len(imgUrlsFromMinio))
​
        // 4.
        unusedImgMinioUrls := getPreRemoveUrls(imgUrlsFromMinio, imgUrlsFromMdPaths)
        fmt.Println("待删除的图片数量为:", len(unusedImgMinioUrls))
        for _, url := range unusedImgMinioUrls {
            fmt.Println("待删除:" + url)
        }
​
        // 5.
        backupImgUrls(minioClient, unusedImgMinioUrls, conf)
​
        // 6.
        removeUnusedImgUrlsOnMinio(minioClient, bucket, unusedImgMinioUrls)
        fmt.Println("删除了未引用图片数量:", len(unusedImgMinioUrls))
​
        if conf.Loop != "yes" {
            fmt.Println("不循环执行,程序结束.")
            break
        }
        time.Sleep(time.Hour * 24)
    }
​
}
​
// 获取指定目录下的所有md文件路径
func getMdFilePaths(dir string) []string {
    var pathArr []string
    err := filepath.Walk(dir, func(path string, info fs.FileInfo, err error) error {
        if strings.HasSuffix(info.Name(), ".md") {
            pathArr = append(pathArr, path)
        }
        return nil
    })
    if err != nil {
        return nil
    }
    return pathArr
}
​
// 获取md文件的图片,格式是这样的:![]()
func getImgUrlsFromMdPaths(mdPaths []string) []string {
    var mdImgUrls []string
    for i := range mdPaths {
        content, err := os.ReadFile(mdPaths[i])
        if err != nil {
            fmt.Println(err)
        }
        reg := regexp.MustCompile(`![.*?](.*?)`)
        all := reg.FindAll(content, -1)
        for _, bytes := range all {
            mdImgUrls = append(mdImgUrls, string(bytes))
        }
    }
    return mdImgUrls
}
​
// 获取minio图床上的图片
func getImgUrlsFromMinio(client *minio.Client, bucket string, prefix string) []string {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
​
    objectCh := client.ListObjects(ctx, bucket, minio.ListObjectsOptions{
        Prefix:    prefix,
        Recursive: false,
    })
​
    var minioImgArr []string
    for object := range objectCh {
        if object.Err != nil {
            fmt.Println(object.Err)
            return nil
        }
        minioImgArr = append(minioImgArr, object.Key)
    }
​
    return minioImgArr
}
​
// 获取minio里未被引用(即待删除)的图片
func getPreRemoveUrls(imgUrlsFromMinio []string, imgUrlsFromMdPaths []string) []string {
    var unusedImgMinioUrls []string
    for _, minioImgUrl := range imgUrlsFromMinio {
        used := false
        for _, mdImgUrl := range imgUrlsFromMdPaths {
            if checkImgUrlUsed(mdImgUrl, minioImgUrl) {
                used = true
                break
            }
        }
        if !used {
            unusedImgMinioUrls = append(unusedImgMinioUrls, minioImgUrl)
        }
    }
    return unusedImgMinioUrls
}
​
// 5.拷贝文件到备份桶
func backupImgUrls(client *minio.Client, imgUrls []string, config JsonConfig) {
    for _, url := range imgUrls {
        srcOpts := minio.CopySrcOptions{
            Bucket: config.Bucket,
            Object: url,
        }
​
        dstOpts := minio.CopyDestOptions{
            Bucket: config.BucketBackup,
            Object: url,
        }
​
        uploadInfo, err := client.CopyObject(context.Background(), dstOpts, srcOpts)
        if err != nil {
            fmt.Println(err)
            return
        }
​
        fmt.Println("备份成功:", uploadInfo.Key)
    }
}
​
// 删除minio里未被引用的图片
func removeUnusedImgUrlsOnMinio(client *minio.Client, bucket string, unusedImgMinioUrls []string) {
    for _, url := range unusedImgMinioUrls {
        opts := minio.RemoveObjectOptions{
            GovernanceBypass: true,
        }
        err := client.RemoveObject(context.Background(), bucket, url, opts)
        if err != nil {
            fmt.Println(err)
            return
        }
    }
}
​
// 判断minio图片是否被引用
func checkImgUrlUsed(mdImgUrl string, minioImgUrl string) bool {
    return strings.Contains(mdImgUrl, minioImgUrl)
}
​
// 初始化minio
func minioInitialize(url string, username string, password string) *minio.Client {
    endpoint := url
    accessKeyID := username
    secretAccessKey := password
    useSSL := false
​
    minioClient, err := minio.New(endpoint, &minio.Options{
        Creds:  credentials.NewStaticV4(accessKeyID, secretAccessKey, ""),
        Secure: useSSL,
    })
    if err != nil {
        log.Fatalln(err)
    }
​
    return minioClient
}

minioCleaner依赖于一个配置文件config.json

使用时把注释去掉
{
    // 笔记根目录,扫描的就是该目录下的所有md文件
    "dir": "D:\...\Syncthing\Note", 
    // 强制清理:clean,默认:normal
    "force": "normal",
    // 笔记图床的桶名,记得提前创建好
    "bucket": "note",
    // 备份桶名,记得提前创建好
    "bucketBackup": "note-backup",
    // 上传的图片前缀
    "prefix": "image-",
    // minio用户信息
    "url": "localhost:8789",
    "username": "minio",
    "password": "minio123",
    // 循环执行:yes;不循环:no
    "loop": "no"
}

结语

以上就是我自建云笔记的全部内容,虽然看起来十分折腾,但是自由度是很高的:

  • Syncthing:笔记的存储可以放到Gitee/GitHub,还提供版本管理功能,Syncthing虽然也提供版本管理,但是始终没有Git好用。但是对于大一些的文件,比如多文件的数据同步,Synchting会比Git好
  • Typora:可以看到Typora只是作为文本编辑器在使用,所以可以任意更换,只要自己喜欢即可。但是最好是内置了自动上传图片功能,不然只能手动上传,然后拷贝图片链接到内容了
  • Minio:网上图床一大堆,随便找一个都能用,需要担心的就是平台是否靠谱,图片资源会不会哪一天就寄了
  • Golang:用Go写脚本是因为方便跨平台,minio的SDK支持Java、Golang、Python、JavaScript以及.Net语言进行开发,如果不用minio,那就更没有语言约束了

所以本篇文章只是提供了一个思路,如果大家有更好的方案,请留言告诉我,感谢!