¿
Recently, I tried to make a tool that can run Code online: code-runner, step on many holes, make a summary
The technologies/modules involved are as follows:
-
Server Sent Events (HEREINAFTER referred to as SSE) is a server-side push technology
-
Node. Js module:
child_process
The modulestream
The module
-
Docker a few simple commands
docker run
docker pull
docker kill
-
Koa2: For passing purposes, not the core
-
Pug: Render template
reference
Nguyen Yifeng: Server-Sent Events tutorial
Gracefully terminate the Docker container
1. SSE is used as server push
Strictly speaking, the HTTP protocol does not allow the server to actively push information. A workaround, however, is for the server to declare to the client that the next message to be sent is streaming. Instead of closing the connection, the client waits for a new data stream from the server.
1.1 Comparison between SSE and WebSocket
Compared with WebSocket, it has the following characteristics:
-
SSE uses HTTP protocol; WebSocket is a separate protocol
-
SSE is simple to use and lightweight; You definitely need to introduce socket. IO, WS, etc
-
SSE supports disconnection and reconnection by default. WebSocket needs to be implemented by itself
-
SSE is generally used to transmit text; WebSocket supports binary data transfer by default
If only the server push function is required, the development cost of USING SSE is the lowest. Compatibility is as follows
1.2 SSE is the easiest access
Assume that the interface address is/SSE, and the server code (Koa2 as an example) is:
const { PassThrough } = require('stream')
router.get('/sse', ctx => {
const stream = new PassThrough()
setInterval((a)= > { stream.write(`: \n\n`)},5000)
ctx.set({ 'Content-Type':'text/event-stream' })
ctx.body = stream
})
Copy the code
The client
const eventSource = new EventSource('/sse')
Copy the code
Counting the blank lines, it takes only 10 lines of code to complete the FRONT and back SSE connections, with the following effect
1.3 SSE event stream format
An event stream is simply a stream of text data. The text should be encoded in UTF-8 format. Each message is followed by an empty line delimited by a colon.
Comment lines can be used to prevent connection timeouts, and the server can periodically send a message to comment lines to keep the connection going
This is not straightforward enough. The code is as follows:
// Send a comment line
stream.write(`: \n\n`)
// Send the custom event test. The data is a string: this is a test message
stream.write(`event: test\ndata: this is a test message\n\n`)
{MSG: 'this is a test message'}
stream.write(`event: test1\ndata: The ${JSON.stringify({ msg: 'this is a test message' })}\n\n`)
Copy the code
The client listens for custom events:
const eventSource = new EventSource('/sse')
eventSource.addEventListener('test', event => {
console.log(event.data) // this is a test message
})
Copy the code
Yes, it’s that simple
2. Specific implementation of code-runner
2.1 Overall Process
- Client issue
GET/sse HTTP / 1.1
request
- Listen for custom events
sse-connect
: 获得一个身份标识id - Listen for custom events
sse-message
: Push of progress messages, such as mirror pull, code execution start, code execution end - Listen for custom events
sse-result
: Push of code execution results
- User-submitted code
POST HTTP / 1.1 / runner
- Pull mirror, for example:
docker pull node:latest
- Write the user submission code to a file, for example:
/code/main-1.js
- Start the container, for example:
docker run --rm --name runner-1 -v /code:/code node:latest node /code/main-1.js
- The result is written to the corresponding stream based on the identity id
- Closed container
2.2 SSE encapsulation
Packaging objectives:
-
The corresponding stream can be obtained by identifying the ID
-
Encapsulation of sending custom events
-
Continuous encapsulation for maintaining a connection
-
Maintain an instance table for pushing messages to the corresponding flow
const { PassThrough } = require('stream')
const instanceMap = new Map(a)let uid = 0
/** * Server Sent Events
module.exports = class SSE {
/** * initializes the transformation flow, identifies it, executes the initialization method */
constructor(options = {}) {
this.stream = new PassThrough()
this.uid = ++uid
this.intervalTime = options.intervalTime || 5000
this._init()
}
/** * get SSE instance */ based on uid
static getInstance(uid) {
return instanceMap.get(+uid)
}
/** * Sends custom events */ based on uid
static writeStream(uid, event, data) {
const instance = this.getInstance(uid)
if (instance) instance.writeStream(event, data)
}
/** * records the current instance in the initialization function and keeps the long connection */
_init() {
instanceMap.set(this.uid, this)
this._writeKeepAliveStream()
const timer = setInterval((a)= > { this._writeKeepAliveStream() }, this.intervalTime)
this.stream.on('close', () => {
clearInterval(timer)
instanceMap.delete(this.uid)
})
}
/** * Keep long connections */ by sending comment messages
_writeKeepAliveStream() {
this.stream.write(': \n\n')}/** * Sends custom events */
writeStream(event, data) {
const payload = typeof data === 'string' ? data : JSON.stringify(data)
this.stream.write(`event: ${event}\ndata: ${payload}\n\n`)}}Copy the code
After encapsulation, the/SSE interface code is simplified to:
router.get('/sse', ctx => {
ctx.set({
'Content-Type':'text/event-stream'.'Cache-Control':'no-cache'.'Connection': 'keep-alive'
})
const sse = new SSE()
sse.writeStream('sse-connect', sse.uid)
ctx.body = sse.stream
})
Copy the code
2.3 Limit the usage time of containers
Docker stop/Docker kill is the best way to stop the container from running, although there has always been an Issue: add timeout option to docker run command
docker stop: When docker stop is used to stop the container, Docker will allow the application in the container to stop running for 10 seconds by default. If the waiting time reaches the set timeout time, or the default 10 seconds, docker will continue to send SIGKILL system signal to kill the process
Docker kill: By default, the Docker kill command does not allow applications in the container to cause cause-like shutdown. It directly issues the SIGKILL system signal to forcibly terminate the program in the container
Therefore, docker kill is more in line with requirements, and the code for stopping the container is as follows:
/** * stop Docker * @description * exec method timeout is invalid when executing "Docker run" command, Therefore, use the "docker kill" command to limit the container usage time * through the docker kill, ChildProcess exitCode: 137 * @param {string} containerName specifies the containerName * @param {number} timeout specifies the usage period * @return {number} timer */
function stopDocker(containerName, timeout) {
return setTimeout(async() = > {try {
await exec(`docker kill ${containerName}`)}catch (e) { }
}, timeout)
}
Copy the code
Note: The timeout option of child_process.exec here does not stop the Docker container
2.4 Stream the way to get output
The child_process.exec method returns a buffer/string. If we want to stream, we need to use the child_process.spawn method
Part of the code for container startup is as follows:
/** * Start Docker container and use stream mode to get output * @param {object} dockerOptions start Docker configuration */
function startDockerBySpawn(dockerOptions) {
// Get the container name, image name, run the command to mount the volume
const { containerName, imageName, execCommand, volume } = dockerOptions
// Parameter difference
const commandStr = `docker run --rm --memory=50m --name ${containerName} -v ${volume} ${imageName} ${execCommand}`
const [command, ...args] = commandStr.split(' ')
// Start the container
const childProcess = spawn(command, args)
/ /...
}
Copy the code
Once the container is started, two streams can be obtained:
-
childProcess.stdout
-
childProcess.stderr
We need to combine the data from the two streams, convert it to SSE data format, and finally write it to targetStream targetStream as follows:
const t = new SSETransform()
const transferStation = new PassThrough()
childProcess.stdout.pipe(transferStation)
childProcess.stderr.pipe(transferStation)
transferStation.pipe(t).pipe(targetStream, { end: false })
Copy the code
The custom conversion flow is as follows:
const { Transform } = require('stream')
/** * Custom conversion stream * @description * convert child_process.stdout/stderr writable stream to EventStream format */
module.exports = class SSETransform extends Transform {
constructor(eventName) {
super(a)this.eventName = eventName || 'sse-result'
}
_transform(chunk, encoding, callback) {
callback(null.`event: The ${this.eventName}\ndata: The ${JSON.stringify({ result: chunk.toString('utf8')})}\n\n`)}}Copy the code
The data obtained from a code execution process is shown in the figure below
At this point, you have completed the development of a tool for running code online