¿

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_processThe module
    • streamThe 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

  1. Client issueGET/sse HTTP / 1.1request
  • Listen for custom eventssse-connect: 获得一个身份标识id
  • Listen for custom eventssse-message: Push of progress messages, such as mirror pull, code execution start, code execution end
  • Listen for custom eventssse-result: Push of code execution results
  1. User-submitted codePOST 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