自建具备全文搜索能力的git server

自建具备全文搜索能力的git server

需求

  • 需求很简单,建立一个自己的git server,并对所有提交到该server的代码具备全文检索能力,并提供跨平台的查询方法,包括手机,windows,mac,linux系统都可以方便访问。如何建立git server技术上很容易的,成本较高,本文主要介绍的是如何能通过变通的方法实现对代码库的全文检索,并提供对应的代码。该方法只是个人实现的一种方式,肯定还有很多其他的方法来实现,欢迎大家给点建议,更方便优雅的实现。

分析

  • 首先我们需要互联网能访问我们的主机,方法有两个,具体如何不用详细说,有一大堆的教程可以查阅
    1. 通过购买云端服务器,比如说阿里云,腾讯云等
    2. 购买自己的服务器,并通过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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值