coderunner

这几天学习go语言,发现The Go Playground可以在线编辑代码然后运行,其实很多语言都有提供这种在线代码编辑运行功能,比如Scalafiddle等等,这种免去了下载语言配置环境等,可以让用户尽情体验编码的乐趣。

这几天学习go语言,发现The Go Playground 可以在线编辑代码然后运行,其实很多语言都有提供这种在线代码编辑运行功能,比如Scalafiddle等等,这种免去了下载语言配置环境等,可以让用户尽情体验编码的乐趣。

之前还有个github1s很好玩,可以在线预览Github仓库的项目,这个是基于vscode的web版实现,而Github被微软收购,又即将推出Github Codespaces,以后在线编程将成为一种常态。

又因为docker是go语言写的,通过go可以很容易调用docker客户端执行相应的命令,所以一时兴起就也想自己搞个coderunner,学以致用才是王道,从github仓库找到了Elaina这个基于 Docker 的远程代码运行器的项目,所以就参照这个实现。

Docker Client

下载依赖

go get github.com/docker/docker/client

初始化客户端

cli, err := client.NewClientWithOpts()

或者连接远程的docker

cl, err := client.NewClient("tcp://192.168.64.1:2375", "", nil, nil)

创建容器并启动

	var networkMode container.NetworkMode
	createContainerResp, err := t.cli.ContainerCreate(t.ctx,
		&container.Config{
			Image: t.RUNNER.Image,
			Tty:   true,
			WorkingDir: RuntimePath,
		},
		&container.HostConfig{
			NetworkMode: networkMode,
			Mounts: []mount.Mount{
				{
					Type:   mount.TypeBind,
					Source: t.SourceVolumePath,
					Target: RuntimePath,
				},
			},
			Resources: container.Resources{
				NanoCPUs: t.RUNNER.MaxCPUs * 1000000000,    // 0.0001 * CPU of cpu
				Memory:   t.RUNNER.MaxMemory * 1024 * 1024, // Minimum memory limit allowed is 6MB.
			},
		}, nil, nil, t.UUID)
	if err != nil {
		return nil, err
	}
	t.ContainerID = createContainerResp.ID

	// Clean containers and folder after executed.
	//defer t.Clean()

	if err := t.cli.ContainerStart(t.ctx, t.ContainerID, types.ContainerStartOptions{}); err != nil {
		return nil, err
	}

执行容器里的命令并返回输出

idResponse, err := t.cli.ContainerExecCreate(t.ctx, t.ContainerID, types.ExecConfig{
		Env: t.RUNNER.Env,
		Cmd:[]string{"/bin/sh", "-c", cmd},
		Tty:true,
		AttachStderr:true,
		AttachStdout:true,
		AttachStdin:true,
		Detach:true,
	})
	if err != nil {
		return &Output{
			Error: true,
			Body:  err.Error(),
		}, nil
	}

	// 附加到上面创建的/bin/sh进程中
	hr, err := t.cli.ContainerExecAttach(t.ctx, idResponse.ID, types.ExecStartCheck{Detach: false, Tty: true})
	if err != nil {
		return &Output{
			Error: true,
			Body:  err.Error(),
		}, nil
	}

更多方法的使用可以查看Go client for the Docker Engine API

自此我们可以通过go操作docker容器

编程语言配置

不同编程语言,基于docker镜像,编译命令,执行命令等等都是不一样的,所以我们这里要对支持的编程语言进行相应初始配置信息

type runner struct {
	Name     string
	Ext      string
	Image    string
	BuildCmd string
	RunCmd   string
	DefaultFileName string
	Env 	 []string
	MaxCPUs  int64
	MaxMemory int64
	Example  string
}

var LangRunners = []runner{
	{
		Name:     "go",
		Ext:      ".go",
		Image:    "golang:1.15-alpine",
		BuildCmd: "rm -rf go.mod && go mod init code-runner && go build -v .",
		RunCmd:   "./code-runner",
		Env: []string{"GOPROXY=https://goproxy.io,direct"},
		DefaultFileName: "code.go",
		MaxCPUs: 2,
		MaxMemory: 100,
		Example: "package main\n\n" +
			"import \"fmt\"\n\n" +
			"func main() {\n\n" +
			"  fmt.Println(\"hello world.\")\n\n" +
			"}",
	},
	{
		Name:     "python",
		Ext:      ".py",
		Image:    "python:3.9.1-alpine",
		BuildCmd: "",
		RunCmd:   "python3 code.py",
		Env: []string{},
		DefaultFileName: "code.py",
		MaxCPUs: 2,
		MaxMemory: 100,
		Example: "print(\"hello world.\")",
	},
	{
		Name:     "java",
		Ext:      ".java",
		Image:    "openjdk:8u232-jdk",
		BuildCmd: "javac code.java",
		RunCmd:   "java code",
		Env: []string{},
		DefaultFileName: "code.java",
		MaxCPUs: 2,
		MaxMemory: 100,
		Example: "class code {\n  public static void main(String[] args) {\n    System.out.println(\"Hello, World!\"); \n  }\n}",
	},
	{
		Name:     "javascript",
		Ext:      ".js",
		Image:    "node:lts-alpine",
		BuildCmd: "npm config set registry https://registry.npm.taobao.org",
		RunCmd:   "node code.js",
		Env: []string{},
		DefaultFileName: "code.js",
		MaxCPUs: 2,
		MaxMemory: 50,
		Example: "console.log(\"hello world.\");",
	},
	{
		Name:     "c",
		Ext:      ".c",
		Image:    "gcc:latest",
		BuildCmd: "gcc -v code.c -o code",
		RunCmd:   "./code",
		Env: []string{},
		DefaultFileName: "code.c",
		MaxCPUs: 2,
		MaxMemory: 50,
		Example: "#include <stdio.h>\nint main()\n{\n  printf(\"Hello, World!\");\n  return 0;\n}",
	},
}

这里简单说一下几个字段,

Image: 基于哪个镜像构建的,理论上只要镜像里包含指定编程语言的指令即可,你也可以构建自己的镜像,

Env: 容器的环境变量,这个是go语言一般要设置goproxy,不然基本下载不了依赖

BuildCmd: 构建命令,比如java代码,要先javac编译成class才能执行;或是npm设置淘宝镜像等代码执行前的前置操作;

RunCmd: 执行命令,运行代码

DefaultFileName: 默认的文件名,因为java的语言,文件名是要跟文件内容里的类名一致的,后期我们会拉取github仓库或者其他地方的源代码,这时候文件名保存就不能是默认的文件名了,这时候有一个默认文件名的字段,可以方便替换

后期,如果你需要扩展语言,直接编写下对应编程语言的配置即可,后期可能会考虑以配置文件的方式存储,目前是硬编码,

多人任务模式

可以存在多人操作,为了避免多个人操作同一个容器,所以这边抽象一个任务的结构体,

type Task struct {
	ctx context.Context

	UUID   string
	RUNNER *runner

	cli         *client.Client
	ContainerID string

	SourceVolumePath string // Folder in Host: /home/<your_user>/coderunner/volume/<UUID>/
	fileName         string
}

每个任务都有一个唯一的UUID,这个会在程序运行的工作空间APP_CONTAINER_PATH创建以UUID命名的文件夹,并且不同任务都会创建不同的容器,保证不同任务执行互不影响。

因为每个任务的操作频率可能不止一次,所以这边并没有执行运行代码命令后就删除容器,因为容器的创建销毁是需要时间的,为了给用户良好的操作体验,所以这边每个任务只要有操作对应关联的docker容器便会保留一小时的有效时间,当然如果你切换其他编程语言的话,之前的编程语言的容器就会销毁。

拉取Github源码

一般源码都放在Github仓库上,而且github1s确实用着感觉看源代码很方便,所以这边就简单实现下,拉取Github仓库目录的源码,在左边形成文件列表,代码如下,

user := strings.Split(gitPath, "/")[1]
	rep := strings.Split(gitPath, "/")[2]
	branch := strings.Split(gitPath, "/")[4]
	dir := gitPath[strings.Index(gitPath, branch) + len(branch) + 1:]
	gitUrl := "https://github.com/" + user + "/" + rep + ".git"
	fmt.Println(user, rep, branch, dir, gitUrl)

	var workDir = path.Join(hostGitPath, rep)
	if !utils.Exists(workDir) {
		fmt.Println(workDir, "not existed")
		err = os.MkdirAll(workDir, 0755)
		if err != nil {
			return r.MakeErrJSON(500, err.Error())
		}
		runGitCommand(workDir,"git", "init")
		runGitCommand(workDir,"git", "remote", "add", "origin", gitUrl)
		runGitCommand(workDir,"git", "config", "core.sparsecheckout", "true")
	} else {
		fmt.Println(workDir, "existed")
	}
	runGitCommand(workDir,"/bin/sh", "-c", "echo \"" + dir + "/*\">> .git/info/sparse-checkout")
	runGitCommand(workDir,"git", "pull", "origin", branch)
	runGitCommand(workDir,"git", "checkout")
	targetDir := path.Join(workDir, dir)
	fmt.Println(targetDir)
	fileMap := map[string]interface{}{}
	buildFileMap(targetDir, fileMap, fileExt)
	return r.MakeSuccessJSON(gin.H{
		"fileMap": fileMap,
	})

就是使用git命令,通过配置.git/info/sparse-checkout来检出特定的目录,避免有的项目太大,下载半天,然后遍历目录,找到所有文件后缀跟当前编程语言,以文件名:文件内容这样的map返回给前端,生成文件列表,点击对应文件名,会在右边编辑窗口显示对应的文件内容,

终端实现

有的代码,可能要安装依赖,所以这边也实现简单的终端,可以让用户在docker容器里面执行命令,这个就是跟运行代码调用方法一致,只是运行代码的命令是编程语言配置好,固定的,这边是获取前端用户输入的执行命令。

上传文件

既然提供了终端,用户可以操作容器里命令,那么上传文件到容器里也是很有必要的,比如用户上传一个jar包,然后在容器里运行java -jar执行,毕竟web界面提供的窗口有限,只适用单文件的源码,

语言特性

遇到的问题是java语言,文件名是要求跟类名一致的,并且,单文件源码的话不能包含包名,所以这里针对java语言会特殊处理,

保存文件到工作空间的时候直接用源码的文件名,并且去除源码里的包名,

if len(fileName) == 0 || t.RUNNER.Name != "java" || path.Ext(fileName) != t.RUNNER.Ext  {
		fileName = "code" + t.RUNNER.Ext
	} else {
		if strings.HasPrefix(code, "package") {
			code = code[strings.Index(code, "\n"):]
		}
	}

编译命令跟运行命令也要进行相对应的调整,

if len(fileName) > 0 && t.RUNNER.Name == "java" && path.Ext(fileName) == t.RUNNER.Ext {
				cmd = strings.ReplaceAll(t.RUNNER.BuildCmd, t.RUNNER.DefaultFileName, fileName) + " && "+ strings.ReplaceAll(t.RUNNER.RunCmd, "code", strings.ReplaceAll(fileName, t.RUNNER.Ext, ""))
			} else {
				cmd = t.RUNNER.BuildCmd + " && " + t.RUNNER.RunCmd
			}

Docker部署

Dockerfile

FROM alpine:latest

RUN apk update && apk add ca-certificates && rm -rf /var/cache/apk/*
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
RUN echo 'Asia/Shanghai' >/etc/timezone

# install git - apt-get replace with apk
RUN apk update && \
    apk upgrade && \
    apk add --no-cache bash git openssh

RUN mkdir /etc/coderunner
WORKDIR /etc/coderunner

ADD coderunner /etc/coderunner

RUN chmod 655 /etc/coderunner/coderunner

ENTRYPOINT ["/etc/coderunner/coderunner"]
EXPOSE 8080

这里注意,要安装git软件,不然运行不了,

go build & docker build

set GOARCH=amd64
set GOOS=linux
go build -o coderunner main.go

docker build -t coderunner:v0.0.1 .
docker tag coderunner:v0.0.1 jianchengwang/coderunner
docker login
docker push jianchengwang/coderunner

这里根据自己的需求,打包成基于哪种架构的二进制文件,然后生成docker镜像即可,

docker-compose

version: '3'
services:
  coderunner:
    image: jianchengwang/coderunner:latest
    ports:
      - "8080:8080"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - /root/coderunner:/root/coderunner
    environment:
      APP_URL: http://localhost:8080
      APP_PASSWORD: 12345678
      APP_CONTAINER_PATH: /root/coderunner

这里要注意将APP_CONTAINER_PATH要跟docker目录进行映射,APP_URL就是配置允许跨域的域名地址了,

nginx proxy

你如果使用nginx进行代理转发的话,要配置下跨域相关,否则可能导致跨域问题,

 proxy_set_header    Host            $host;
 proxy_set_header    X-Real-IP       $remote_addr;
 proxy_set_header    X-Forwarded-For $proxy_add_x_forwarded_for;
 location / {
    proxy_pass http://172.17.0.6:8902;
    add_header Access-Control-Allow-Origin *;
 }

后记

附上一张效果图,

效果图1

当然如果你感兴趣的话,也可以在线访问,请戳

后续,可能会集成markdown,jsbin等,持续完善中...

相关链接

jianchengwang/coderunner

Elaina

Js-Encoder

github1s

codemirror

cdnjs codemirror