自建具备全文搜索能力的git server
需求
- 需求很简单,建立一个自己的git server,并对所有提交到该server的代码具备全文检索能力,并提供跨平台的查询方法,包括手机,windows,mac,linux系统都可以方便访问。如何建立git server技术上很容易的,成本较高,本文主要介绍的是如何能通过变通的方法实现对代码库的全文检索,并提供对应的代码。该方法只是个人实现的一种方式,肯定还有很多其他的方法来实现,欢迎大家给点建议,更方便优雅的实现。
分析
- 首先我们需要互联网能访问我们的主机,方法有两个,具体如何不用详细说,有一大堆的教程可以查阅
- 通过购买云端服务器,比如说阿里云,腾讯云等
- 购买自己的服务器,并通过DDNS动态域名绑定自己的主机,这种方式非常复杂,成本较高
- 部署git server服务到服务器,这个很简单,其实就是在服务器创建一个目录和用户,并通过SSH进行访问即可,网上也有一大堆的教程,比如 https://www.runoob.com/git/git-server.html
- 需要全文检索,第一选择肯定是将代码库的文件上传elastic search,所以需要部署es,如何部署网上已有大量教程
- 当git server有新的代码提交的时候,我们需要能及时知道并上传es,git 提供了一个钩子方法,git提供了这个方法,就是在gitserver 对应代码库的hooks里面有很多的钩子脚本,可以通过shell编程的方法,当代码提交的时候执行脚本。
- push到git server的时候,好像代码都被加密了存储,我不知道如何获取到上传的代码,所以我想了一个变通的方法,当git server 有提交的时候,通知我的一个web api服务,这个web api程序会通过 git clone 或者git pull 获取最新的代码文件
- 从上一步获取到的文件需要更新到es,为此,我写了一个小程序用于监听所本地git代码库git pull 或者clone 下来之后的文件变化,监听修改和新增,和删除
- 以上步骤之后可以完成及时的将代码提交更新到es的效果了,但是我们还需要提供一个全文检索的通道,最简单的登录kibana进行查询,这种方式灵活度最高,但是不方便,所以我们要建立一个查询工具,我们可以采用建立一个web网页来进行这个工作。但是web网页在手机端的效果并不好,还是要搞一个app。为了跨平台的访问,我选择了flutter来构建这个app,这样就可以无缝的运行在所有操作系统,包括windows和linux。
- 以上之后基本上功能实现了,但是作为程序员,我们要将代码提交给git server也自动化起来,所以我又写了一个小程序,gitadd, 该命令行工具只有一个参数,就是要提交的目录。 该命令会自动在gitserver创建对应的代码仓库并拷贝.gitignore 文件到这个目录,并初始化和提交该目录到git server
模块图
以上的模块图表明了本次需要实现的4个模块,下面会具体介绍每一个模块具体的内容并提供对应的参考代码
- gitclient: 提供一个跨操作系统的代码搜索工具
- gitadd: 由于自建git server要在git服务器端建立对应的代码库,所以挺麻烦的,这个工具可以一个命令完成一个代码仓库的建立,初始化和首次提交
- gitmonitor:监控本地的代码库,当有文件更新,新增或者删除的时候将该代码文件同步更新到es
- githook: 一个很简单的web api,当git server有提交的时候会通过钩子脚本回调到该api,而该服务会将更新本地代码库,更新本地代码库后会触发gitmonitor来上传到es
实现
githook
githook的功能已经介绍过,只需要实现一个很简单的回调api,这里就要先介绍一下git server如何调用到这个api的
在git server对应的仓库文件中,有一个hooks的目录,这个目录结构如下:
可以看到当我们用git init --bare 初始化这个仓库的时候,会自动生成这些sample代码,如果要某个回调脚本生效,只需要将后面的.sample去掉即可,我们的案例中我是修改了post-update,里面都是shell代码,因为我只需要通知githook去更新,所以我在里面写的代码非常简单,就一句话就可以,直接通过curl发送到githook,并发送是那个仓库的更新
curl -X POST -d $PWD "http://192.168.31.11:20081/checkversion"
githook 我采用了go语言编写的一个web api,主要是实现非常简单,有些配置就直接hardcode了,我是部署到windows上的服务,go提供了windows服务化的包,但是直接用一个winscv这个工具部署也很简单。
package main
import (
"fmt"
"githook/loger"
"github.com/CodyGuo/win"
"github.com/julienschmidt/httprouter"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
)
func checkVersion(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
data := make([]byte, 1024)
n, _ := r.Body.Read(data)
if n>0 {
repo := string(data[0:n])
folder,name := extractName(repo)
if folder!=""{
path := filepath.Join(basePath,folder)
if exist,_ := loger.PathExists(path);exist{
go update(folder)
} else {
go clone(name)
}
}
}
w.Write([]byte("200"))
}
func main() {
router := httprouter.New()
router.POST("/checkversion", checkVersion)
ip, err := getOutBoundIP()
if err != nil {
loger.Info("get outbound ip err :%v", err)
os.Exit(1)
}
var address = ip + ":20081"
loger.Info("auto update server listen at %s", address)
loger.Fatal(http.ListenAndServe(address, router))
}
func extractName(path string) (folder string,name string) {
lastIndex := strings.LastIndex(path,"/")
if lastIndex!=-1{
name = path[lastIndex+1:]
folder = strings.Replace(name,".git","",-1)
} else{
folder=""
name=""
}
return
}
const basePath = "d:/repository_search"
const remotePath = "ssh://user@xxx.xxx.xxx/volume4/git_reposit/"
func update(name string) {
local := filepath.Join(basePath,name)
cmdLine := fmt.Sprintf("git -C %s pull",local)
execRun(cmdLine)
}
func clone(name string) {
remote :=remotePath+name
cmdLine := fmt.Sprintf("git -C %s clone %s",basePath,remote)
execRun(cmdLine)
}
func runCmd(cmdLine string) {
cmd := exec.Command("cmd.exe", "/c", cmdLine)
err := cmd.Run()
if err!=nil{
loger.Info("%s, error:%s", cmdLine, err.Error())
} else {
loger.Info("%s, run success",cmdLine)
}
}
func execRun(cmd string) {
lpCmdLine := win.StringToBytePtr(cmd)
ret := win.WinExec(lpCmdLine, win.SW_HIDE)
if ret <= 31 {
loger.Info("%s, error:%d", cmd, ret)
} else {
loger.Info("%s, run success",cmd)
}
}
func getOutBoundIP() (ip string, err error) {
conn, err := net.Dial("udp", "8.8.8.8:53")
if err != nil {
return
}
localAddr := conn.LocalAddr().(*net.UDPAddr)
ip = strings.Split(localAddr.String(), ":")[0]
return
}
gitmonitor
该服务由于要监控的是我的windows系统下的一个本地文件夹的增删改,之前有一个比较完善的工具类来实现,所以干脆就直接用了c# .net6来构造这个服务。
首先在这里我们要看看es上面建立的index的结构,这里只有4个字段,其中filename为wildcard为了进行正则查询性能更高一点
{
"mappings": {
"properties": {
"filename": {
"type": "wildcard"
},
"path": {
"type": "keyword",
"index": true
},
"content": {
"type": "text",
"index": true
},
"time": {
"type": "text",
"index": false
}
}
}
}
这个程序中,可能存在一点点坑的地方是filemonitor这个类,当监测到文件修改的时候,我们不能立刻就上传文件,这样如果瞬间更新很多的文件,会导致监听函数处理不过来,从而导致错误,所以我们异步上传文件,监听到文件更新后,只是简单的将文件放到队列,然后延迟上传。
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Threading;
using System.Linq;
using System.Threading.Tasks;
using System.Collections;
public class FileMonitor
{
private FileSystemWatcher _watcher = null;
private ConcurrentQueue<FileModel> _msgQueue = new ConcurrentQueue<FileModel>();
private ConcurrentQueue<string> _reloadQueue = new ConcurrentQueue<string>();
private CancellationTokenSource _cts = null;
private HashSet<string> _changeSet = new HashSet<string>();
private HashSet<string> _AddSet = new HashSet<string>();
private void HandleChangeEvent()
{
while (!_cts.IsCancellationRequested)
{
if (_changeSet.Count > 0)
{
Thread.Sleep(5000);//before handle ,wait 5 seconds. so low.haha
string filePath = _changeSet.First();
if (File.Exists(filePath))
{
if (_AddSet.Contains(filePath))
{
_changeSet.Remove(filePath);//create . so remove this event
_AddSet.Remove(filePath);
}
else
{
AddToQueue(filePath, OperationType.Update);
_changeSet.Remove(filePath);
_AddSet.Remove(filePath);
}
}
else
{
_changeSet.Remove(filePath);
_AddSet.Remove(filePath);
}
}
else
{
Thread.Sleep(10 * 1000);//very low piority
}
}
}
private List<string> GetFileListInFolder(string filePath)
{
List<string> fileList = new List<string>();
var files = Directory.GetFiles(filePath, "*", SearchOption.AllDirectories);
if(files!=null)
fileList.AddRange(files);
return fileList;
}
public void Start()
{
_cts = new CancellationTokenSource();
StartFileWatcher();
Task.Factory.StartNew(HandleMessageQueue);
Task.Factory.StartNew(HandleChangeEvent);
Log.Debug("file monitor start");
}
public void Stop()
{
StopFileWatcher();
_cts.Cancel();
}
private void HandleMessageQueue()
{
while (!_cts.IsCancellationRequested)
{
while (_msgQueue.TryDequeue(out FileModel file))
{
if (file.Op == OperationType.Add)
{
Update(file.FilePath);
}
else if (file.Op == OperationType.Delete)
{
Delete(file.FilePath);
}
else if (file.Op == OperationType.Update)
{
Update(file.FilePath);
}
}
Thread.Sleep(1000);
}
}
private void Delete(string path)
{
string relatePath = Utility.ToRelatePath(path);
if (Utility.IsFile(path))
EsClient.Delete(relatePath);
else
EsClient.DeleteFolder(relatePath);
}
private void Update(string path)
{
if (FileFilter.IsFolderAndFileOk(path))
{
string relatePath = Utility.ToRelatePath(path);
if (EsClient.IsFileExist(relatePath))
EsClient.Update(relatePath, File.ReadAllText(path));
else
EsClient.Add(relatePath, File.ReadAllText(path), new FileInfo(path).Name);
}
}
private void StopFileWatcher()
{
if (_watcher != null)
{
_watcher.EnableRaisingEvents = false;
_watcher.Created -= Watcher_Created;
_watcher.Deleted -= watcher_Deleted;
_watcher.Changed -= watcher_Changed;
_watcher.Dispose();
}
}
private void StartFileWatcher()
{
_watcher = new FileSystemWatcher();
_watcher.BeginInit();
_watcher.IncludeSubdirectories = true;
_watcher.EnableRaisingEvents = true;
_watcher.Path = Configer.Instance.RootPath;
_watcher.Created += Watcher_Created;
_watcher.Deleted += watcher_Deleted;
_watcher.Changed += watcher_Changed;
_watcher.EndInit();
}
private void watcher_Changed(object sender, FileSystemEventArgs e)
{
_changeSet.Add(e.FullPath);
}
private void watcher_Deleted(object sender, FileSystemEventArgs e)
{
AddToQueue(e.FullPath, OperationType.Delete);
}
private void Watcher_Created(object sender, FileSystemEventArgs e)
{
if (File.Exists(e.FullPath)) //skip folder , and temp file create by minio
{
AddToQueue(e.FullPath, OperationType.Add);
_AddSet.Add(e.FullPath);
}
}
private void AddToQueue(string filePath, OperationType op)
{
Log.Debug($"watcher event [{op.ToString()}] : {filePath}");
FileModel fi = new FileModel
{
FilePath = filePath,
Op = op
};
_msgQueue.Enqueue(fi);
}
}
public enum OperationType
{
Add,
Delete,
Update
}
public class FileModel
{
public string FilePath { get; set; }
public OperationType Op { get; set; }
}
gitclient
这个程序的目的就是查询客户端,上文已经解析过,采用dart语言编写,跨操作系统,目前也非常简单,主要提一下目前是可以查询文件名或者直接全文搜索代码的,后面我要加上一个可以查询代码库的小功能,只有一个输入框,可以按照不同代码类型来查询,由于我平时写很多语言代码,所以什么代码类型都有。
f-go-loger //查询文件名包括loger的go文件
checkversion debug //查询代码文件包括这两个关键字的所有代码文件
go-checkversion debug //查询go语言类型的 包括checkversion 和 debug两个关键字的所有代码文件
只有两个界面,主要就是实用
查询页
点击后出现详细页,具有代码高亮功能
代码很简单,只有三个文件,功能就是直接通过es api 发送查询请求,然后显示,这里要强调一下的是编译为手机app的时候,需要主要在配置里面添加可访问网络的权限要求
贴一下简单的es访问方法
import 'package:elastic_client/elastic_client.dart';
import 'package:wjwcode/model/aritcle.dart';
class EsClient {
Future<List<Article>> searchArticle(String keyword) async {
final transport = HttpTransport(
url: 'https://xxx.xxx.xxx:9200/',
authorization: "Basic xxxxxxxxxxxxxxxxxxxxxxx");
final client = Client(transport);
var keywords = keyword.split("-");
final rs1;
if (keywords.length == 3) {
var patten = _buildPatten(keywords[2],keywords[1]);
rs1 = await client.search(
index: 'codes2',
offset: 0,
limit: 100,
query: Query.regexp("filename", patten),
source: true);
} else if (keywords.length == 2) {
var patten = ".*.${keywords[0]}".trim();
rs1 = await client.search(
index: 'codes2',
offset: 0,
limit: 100,
query: Query.bool(must: [
Query.match("content", keywords[1].trim()),
Query.regexp("filename", patten)
]),
source: true);
} else {
//search content
rs1 = await client.search(
index: 'codes2',
offset: 0,
limit: 100,
query: Query.match("content", keyword.trim()),
source: true);
}
EsResult result = EsResult.fromJson(rs1.toMap());
List<Article> articles = [];
result.articleInfos.forEach((element) {
articles.add(element.doc);
});
return articles;
}
String _buildPatten(String keyword,String type) {
var sb = new StringBuffer();
sb.write(".*");
for (int i = 0; i < keyword.length; i++) {
sb.write("[${keyword[i].toUpperCase()}${keyword[i].toLowerCase()}]");
}
sb.write(".*.${type}");
return sb.toString();
}
}
gitadd
这是一个封装了远端代码仓库建立,本地代码提交的一个命令行工具,基本使用方法就是:
gitadd /xxx/xxx/xxx
要将这个命令加到path环境变量,采用go语言编写,支持编译为windows或者mac,我本来就有两套开发环境。
package main
import (
"fmt"
loger "gitadd/log"
"golang.org/x/crypto/ssh"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
)
//var path = flag.String("p", "", "the path")
var path = ""
var name = ""
func main() {
//flag.Parse()
if len(os.Args) < 2 {
fmt.Println("please input path")
os.Exit(1)
}
path = os.Args[1]
if ok, _ := PathExists(path); !ok {
fmt.Println("file path not exist,exit")
os.Exit(1)
}
_, name = filepath.Split(path)
if err := runSSh(buildSSHCmd()); err == nil {
if err := runCmd(buildCmd()); err != nil {
loger.Info("run cmd error : %s", err.Error())
} else {
loger.Info("git add %s success", path)
}
}
}
func buildCmd() string {
//gitIgnore := filepath.Join(GetStartupDirectory(), ".gitignore")
gitIgnore := filepath.Join(GetExecutePath(), ".gitignore")
cmds := []string{}
if runtime.GOOS == "windows" {
cmds = append(cmds, fmt.Sprintf(" copy %s %s", gitIgnore, path))
} else {
cmds = append(cmds, fmt.Sprintf(" cp %s %s", gitIgnore, path))
}
cmds = append(cmds, fmt.Sprintf(" git -C %s init", path))
cmds = append(cmds, fmt.Sprintf(" git -C %s add .", path))
cmds = append(cmds, fmt.Sprintf(" git -C %s commit -m 'update'", path))
cmds = append(cmds, fmt.Sprintf(" git -C %s remote add origin ssh://xxx.xxxx.xxxx:22/volume4/git_reposit/%s", path, name))
cmds = append(cmds, fmt.Sprintf(" git -C %s push --set-upstream origin master", path))
if runtime.GOOS == "windows" {
return strings.Join(cmds, "&")
} else {
return strings.Join(cmds, ";")
}
}
func buildSSHCmd() string {
cmds := []string{}
cmds = append(cmds, "cd /volume4/git_reposit/")
cmds = append(cmds, fmt.Sprintf(" mkdir %s", name))
cmds = append(cmds, fmt.Sprintf(" cd %s", name))
cmds = append(cmds, " git init --bare")
cmds = append(cmds, fmt.Sprintf(" cp ~/post-update /volume4/git_reposit/%s/hooks/", name))
return strings.Join(cmds, ";")
}
func runCmd(cmd string) error {
var c *exec.Cmd
if runtime.GOOS == "windows" {
c = exec.Command("cmd", "/C", cmd)
} else {
c = exec.Command("bash", "-c", cmd)
}
_, err := c.CombinedOutput()
if err != nil {
loger.Info(err.Error())
}
return err
}
func runSSh(cmd string) error {
sshHost := "xxx.xxxx.xxx"
sshUser := "xxx"
sshPassword := "xxxx"
sshType := "password"
sshPort := 22
config := &ssh.ClientConfig{
Timeout: 5 * time.Second,
User: sshUser,
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
}
if sshType == "password" {
config.Auth = []ssh.AuthMethod{ssh.Password(sshPassword)}
}
addr := fmt.Sprintf("%s:%d", sshHost, sshPort)
sshClient, err := ssh.Dial("tcp", addr, config)
if err != nil {
loger.Info("create ssh client failed")
return err
}
defer sshClient.Close()
session, err := sshClient.NewSession()
if err != nil {
loger.Info("create ssh session failed")
}
defer session.Close()
if _, err := session.CombinedOutput(cmd); err != nil {
loger.Info(err.Error())
return err
}
return nil
}
附上所有的源代码,由于其实每一个小程序的代码都非常简单,并且包括分析在内我就用了两天时间,所以大部分地方都是hardcode了一些基本配置,也只是作为一个demo提供给大家的一个思路参考。
代码链接:https://download.csdn.net/download/xiongwjw/86846232