基于nestjs框架,使用dockerode运行代码,打造在线编译平台
dockerode命名由来:官方自称docker + node于是就命名docker + ode = dockerode (Node.js module for Docker’s Remote API)有点意思
需求:在线运行代码
思路:利用dockerode创建docker容器,运行代码,如果是py代码就基于python镜像,如果是java代码就基于openjdk镜像,以此类推。
例如——运行python代码关于dockerode的思路如下(假设docker是dockerode构造的实例):
1、检查python镜像是否存在
const image = docker.getImage(imageName);
await image.inspect();
2、不存在则基于python镜像build一个自己的镜像,存在则跳过
await checkImage() ? createImage() : ''
3、检查镜像所对因的容器是否存在
const containers = await this.docker.listContainers({
filters: {
name: [containerName]
}
})
containers.find(c => {
return c.Names.includes('/' + containerName)
})
4、容器不存在则创建、存在则跳过
await checkContainer() ? createContainer() : ''
5、检查容器是否为运行状态,没运行则运行
6、将代码copy进容器内部
container.exec({
Cmd: ['sh', '-c', `cat > /app/文件名`],
});
const back = await exec.start({
hijack: true,
stdin: true,
});
back.write(fs.readFileSync(tmpFile).toString());
7、容器内部执行代码
container.exec({
Cmd: ['python', `./文件名`],
})
8、获取代码执行结果(包括报错信息)
以上均为伪代码,主要为了提供思路
为了加快在线代码运行速度,所以镜像和容器一旦创建不会删除,所有要检查是不是存在。
整理好思路,开始制作功能
首页下载dockerode,创建一个docker实例,定义一个docker.providers.ts
用于提供实例
// docker.providers.ts
import Docker from 'dockerode';
import fs from 'fs'
export const dockerProviders = [
{
provide: 'DOCKER_INSTANCE',
useFactory: async () => {
try {
const docker = new Docker({
host: 'http://localhost',
port: 2375,
version: 'v1.47', // required when Docker >= v1.13 —— 命令行docker version 查看Docker Api Version,
// 如果开启https,需要填写证书
// ca: fs.readFileSync('src/common/certs/ca.pem'),
// cert: fs.readFileSync('src/common/certs/cert.pem'),
// key: fs.readFileSync('src/common/certs/key.pem'),
});
// 检查是否连接成功
// const info = await docker.info()
// console.log(info);
return docker;
} catch (error) {
console.error('Failed to create Docker instance:', error);
throw error;
}
},
},
];
使用nest代码生成器创建一个docker模块
运行nest g resource docker
会创建出一个docker module
引入docker实例
// docker.module.ts
import { Module } from '@nestjs/common';
import { DockerService } from './docker.service';
import { DockerController } from './docker.controller';
import { dockerProviders } from 'src/config/docker/docker.providers';
@Module({
controllers: [DockerController],
providers: [DockerService, ...dockerProviders],
exports: [DockerService]
})
export class DockerModule { }
接下来docker.service.ts就可以注入实例了
// docker.service.ts
constructor(
@Inject('DOCKER_INSTANCE')
private docker: Docker,
) { }
紧接着进入本文章主要内容
先定义一个实体类ExecCode
,包括源代码,输入,代码类型
// types/index.ts
export enum CodeType {
JAVA = 'java',
CPP = 'cpp',
PYTHON = 'python'
}
// exec-code.entity.ts
import { CodeType } from 'src/types';
export class ExecCode {
codeType: CodeType;
sourceCode: string;
input: string;
}
编写一个switch方法,通过代码类型,判断具体的执行逻辑
// docker.service.ts
execCode(execCode: ExecCode) {
switch (execCode.codeType) {
case CodeType.JAVA:
this.execJava(execCode);
break;
case CodeType.PYTHON:
this.execPython(execCode);
break;
case CodeType.CPP:
this.execGcc(execCode)
break
}
}
先看看execPython的具体逻辑
npm i tmp
(没有tmp库的安装)
现在docker环境中安装python镜像,否则构建会比较缓慢
docker pull python
推荐一个镜像加速站,非常好用!DockerHub镜像加速 | 运维开发绿皮书
async execPython(execCode: ExecCode, client: Socket) {
const dir = tmp.dirSync({});// 创建临时文件夹
const tmpFile = tmp.fileSync({ // 在临时文件夹中创建临时文件(对应要运行的代码文件,例如main.py)
dir: dir.name,
name: 'main.' + execCode.codeType,
});
fs.writeFileSync(tmpFile.name, execCode.sourceCode);// 将源代码写入main.py
// Dockerfile内容,用于构建镜像
const dockerfileContent = `
FROM python
COPY ${path.basename(tmpFile.name)} /app/
WORKDIR /app
`;
// 创建Dockerfile
const dockerfileTmp = tmp.fileSync({
dir: dir.name,
name: 'Dockerfile',
});
// 写入内容
fs.writeFileSync(dockerfileTmp.name, dockerfileContent);
// 检查镜像,没有就创建,CoderRunnerConstant是常量类,在文章下提供
if (
!(await this.checkImage(CoderRunnerConstant.PYTHON_RUNNER_IMAGE_NAME))
) {
await this.createImage(
CoderRunnerConstant.PYTHON_RUNNER_IMAGE_NAME,
dockerfileTmp,
tmpFile,
);
}
try {
// 检查容器是否存在,没有就创建
let container: Docker.Container | null = await this.checkContainer(CoderRunnerConstant.PYTHON_CONTAINER_NAME)
if (!container) {
container = await this.createContainer(CoderRunnerConstant.PYTHON_RUNNER_IMAGE_NAME, CoderRunnerConstant.PYTHON_CONTAINER_NAME)
}
// 经检查容器是否正在运行,没有则允许
await this.checkStatusAndStart(container)
// 将main.py复制进容器
await this.copyFileContent(container, tmpFile);
// 容器执行命令
const exec = await container.exec({
Cmd: ['python', `./${path.basename(tmpFile.name)}`],
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
});
// hijack开启伪控制台,可以输入内容
exec.start({ hijack: true, stdin: true }, function (err, stream) {
if (err) return console.error(err);
// 控制台写入输入
stream?.write(execCode.input);
stream?.end();
// 正常输出和错误输出分离
const stdout = new PassThrough();
const stderr = new PassThrough();
exec.modem.demuxStream(stream, stdout, stderr);
stdout?.on('data', (chunk) => {
console.log(chunk.toString())
});
stderr?.on('data', (chunk) => {
console.log(chunk.toString())
});
stream?.on('end', async function () {
console.log('执行完毕')
});
});
} catch (error) {
console.log(error.message);
console.log('运行出错')
} finally {
tmpFile.removeCallback();
dockerfileTmp.removeCallback();
dir.removeCallback();
}
}
检查镜像函数实现
async checkImage(imageName: string) {
try {
const image = this.docker.getImage(imageName);
await image.inspect();
return true;
} catch (error) {
console.log(`${imageName}不存在,正在构建`);
return false;
}
}
创建镜像函数实现
async createImage(
imageName: string,
dockerfileTmp: tmp.FileResult,
tmpFile: tmp.FileResult,
) {
try {
let stream = await this.docker.buildImage(
{
context: path.dirname(dockerfileTmp.name),
src: [path.basename(dockerfileTmp.name), path.basename(tmpFile.name)],
},
{ t: imageName },
);
stream.on('data', (chunk) => {
console.log(chunk.toString());
});
return new Promise((resolve, reject) => {
this.docker.modem.followProgress(stream, (err, res) => {
if (err) {
console.error('Error building image:', err);
reject(err);
}
resolve(res);
});
});
} catch (error) {
console.log('构建镜像出错:', error.message);
}
}
检查容器函数实现
async checkContainer(containerName: string) {
const containers = await this.docker.listContainers({
filters: {
name: [containerName]
}
})
const containerInfo = containers.find(c => {
return c.Names.includes('/' + containerName)
})
if (!containerInfo) return null
return this.docker.getContainer(containerInfo.Id)
}
创建容器函数实现
async createContainer(imaegName: string, containerName: string) {
return await this.docker.createContainer({
Image: imaegName,
Cmd: ['sh', '-c', 'while true; do sleep 1; done'],
Tty: false,
name: containerName
});
}
容器运行函数实现
async checkStatusAndStart(container: Docker.Container) {
const inspect = await container.inspect()
if (inspect.State.Status !== 'running') {
await container.start()
}
}
拷贝文件内容函数实现
async copyFileContent(container: Docker.Container, tmpFile: tmp.FileResult) {
try {
const exec = await container.exec({
Cmd: ['sh', '-c', `cat > /app/${path.basename(tmpFile.name)}`],
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
});
const back = await exec.start({
hijack: true,
stdin: true,
});
back.write(fs.readFileSync(tmpFile.name).toString());
back.end();
} catch (error) {
console.log('拷贝文件失败:' + error);
}
}
执行java和cpp的代码同理,不过最好也先在docker环境中拉取好对应的镜像openjdk:22和gcc
docker pull openjdk:22
docker pull gcc
async execJava(execCode: ExecCode) {
const dir = tmp.dirSync({});
const tmpFile = tmp.fileSync({
dir: dir.name,
name: 'main.' + execCode.codeType,
});
fs.writeFileSync(tmpFile.name, execCode.sourceCode);
const dockerfileContent = `
FROM openjdk:22
COPY ${path.basename(tmpFile.name)} /app/
WORKDIR /app
`;
const dockerfileTmp = tmp.fileSync({
dir: dir.name,
name: 'Dockerfile',
});
fs.writeFileSync(dockerfileTmp.name, dockerfileContent);
if (!(await this.checkImage(CoderRunnerConstant.JAVA_RUNNER_IMAGE_NAME))) {
await this.createImage(
CoderRunnerConstant.JAVA_RUNNER_IMAGE_NAME,
dockerfileTmp,
tmpFile,
);
}
try {
let container: Docker.Container | null = await this.checkContainer(CoderRunnerConstant.JAVA_CONTAINER_NAME)
if (!container) {
container = await this.createContainer(CoderRunnerConstant.JAVA_RUNNER_IMAGE_NAME, CoderRunnerConstant.JAVA_CONTAINER_NAME)
}
await this.checkStatusAndStart(container)
await this.copyFileContent(container, tmpFile);
const exec = await container.exec({
Cmd: ['java', `./${path.basename(tmpFile.name)}`],
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
});
exec.start({ hijack: true, stdin: true, Tty: false }, (err, stream) => {
if (err) return console.error(err);
stream?.pipe(process.stderr)
stream?.write(execCode.input);
stream?.end();
const stdout = new PassThrough();
const stderr = new PassThrough();
exec.modem.demuxStream(stream, stdout, stderr);
stdout?.on('data', (chunk) => {
console.log(chunk.toString())
});
stderr?.on('data', (chunk) => {
console.log(chunk.toString())
});
stream?.on('end', async function () {
console.log('执行完成')
});
});
} catch (error) {
console.log('执行容器出错:', error.message);
} finally {
tmpFile.removeCallback();
dockerfileTmp.removeCallback();
dir.removeCallback();
}
}
gcc有点区别,多了一个编译的步骤
async execGcc(execCode: ExecCode) {
const dir = tmp.dirSync({});
const tmpFile = tmp.fileSync({
dir: dir.name,
name: 'main.' + execCode.codeType,
});
fs.writeFileSync(tmpFile.name, execCode.sourceCode);
const dockerfileContent = `
FROM gcc
COPY ${path.basename(tmpFile.name)} /app/
WORKDIR /app
`;
const dockerfileTmp = tmp.fileSync({
dir: dir.name,
name: 'Dockerfile',
});
fs.writeFileSync(dockerfileTmp.name, dockerfileContent);
if (
!(await this.checkImage(CoderRunnerConstant.GCC_RUNNER_IMAGE_NAME))
) {
await this.createImage(
CoderRunnerConstant.GCC_RUNNER_IMAGE_NAME,
dockerfileTmp,
tmpFile,
);
}
try {
let container: Docker.Container | null = await this.checkContainer(CoderRunnerConstant.GCC_CONTAINER_NAME)
if (!container) {
container = await this.createContainer(CoderRunnerConstant.GCC_RUNNER_IMAGE_NAME, CoderRunnerConstant.GCC_CONTAINER_NAME)
}
await this.checkStatusAndStart(container)
await this.copyFileContent(container, tmpFile);
await this.gccCompile(container, tmpFile)
const exec = await container.exec({
Cmd: ['./main'],
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
});
exec.start({ hijack: true, stdin: true }, function (err, stream) {
if (err) return console.error(err);
stream?.write(execCode.input);
stream?.end();
const stdout = new PassThrough();
const stderr = new PassThrough();
exec.modem.demuxStream(stream, stdout, stderr);
stdout?.on('data', (chunk) => {
console.log(chunk.toString())
});
stderr?.on('data', (chunk) => {
console.log(chunk.toString())
});
stream?.on('end', async function () {
console.log('执行完成')
});
});
} catch (error) {
console.log(error.message);
} finally {
tmpFile.removeCallback();
dockerfileTmp.removeCallback();
dir.removeCallback();
}
}
编译gcc函数实现
async gccCompile(container: Container, tmpFile: tmp.FileResult) {
const makeExec = await container.exec({
Cmd: ['g++', `./${path.basename(tmpFile.name)}`, '-o', './main'],
AttachStdout: true,
AttachStderr: true
})
let output = ''
const back = await makeExec.start({
})
// 必须等待编译完成,否则会导致误差
return new Promise((resolve, reject) => {
back.on('end', (e: any) => {
if (output === '') {
resolve(e)
} else {
reject(new Error(output))
}
})
back.on('error', (err: any) => {
console.error('Error during command execution:', err);
reject(err);
});
back.on('data', (e: any) => {
output += e.toString()
})
})
}
基于docker容器执行代码就完成了,包括java、cpp、python、如果要添加其他语言,也只需要下载对应镜像,改造一下运行方法即可,理论上支持所有语言。
成果中的在线代码编译器基于websocket的方式实现了页面与服务端的互动,前端通过client.emit({})发送过来一个execCode对象,后端接受到执行execCode方法,根据语言类型以适合的容器运行,运行结束将输出和错误信息均返回给前端。
只需实现websocket互通,就可以达成和文章首网站中一样的成果。
总结:nestjs框架有点东西,dockerode效率嘎嘎快,python、gcc、python瞬间执行完毕(不知道基于process进程的执行方式效率如何),good👍