自建云笔记方案
需求
因为没有找到符合自己期望的云笔记平台,所以利用服务器自行搭建一个,我的需求是:
- 跨平台:在公司用的是Windows,家里用Mac
- 数据同步:多端同步,还要支持跨平台
- 数据备份:笔记数据掌握在自己手里,可以不依赖于云笔记平台
- markdown:支持markdown文本编写
- 图床:除了笔记,图片也要自行保存,不依赖于网络图床平台
实现方案
综上,经过技术选型,最终的实现方案为:
- Syncthing:一个Go语言实现的跨平台数据同步,可以在多台设备之间同步数据
- Typora:跨平台的markdown文本编写工具
- Minio:高性能的存储工具,适合存储海量图片、视频等文件,使用Go语言开发实现,所以有跨平台、内存占用小等特点。可以用来做图床完全没问题
- Golang:编写图片上传脚本以及冗余图片清理脚本
- Picgo(可选) :Typora内置支持Picgo进行图片上传,但没有冗余图片清理功能
- Git(可选) :替代Syncthing作为笔记存储工具
实现步骤
Syncthing
首先需要在服务器上安装Syncthing,由服务器作为主节点及中转站,这样处于内网下的其他设备才可以互通。
- syncthing可以在官网下载:
下载完后启动即可
- 也可以通过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上官网下即可:
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,那就更没有语言约束了
所以本篇文章只是提供了一个思路,如果大家有更好的方案,请留言告诉我,感谢!