Simple Node/Express app, the functional programmin

2020-05-20 06:46发布

问题:

There are many good articles about the theory of functional programming in JavaScript. Some even contain code examples showing the difference between imperative/object-oriented programming and declarative/functional programming. But I have found none that show, with simple JavaScript code examples, how to handle side-effects in a web app. No real world application can entirely avoid side-effects (database calls, logging to console, saving to a file, drawing to the screen etc.) and I have a hard time figuring out how it is done in practice.

There are blog articles and S/O answers (like this one: How to perform side-effects in pure functional programming?) that touch on the subject of handling side-effects in the real world but they are usually far from simple, don't include code example or include code example in other languages (Haskell, Scala, etc.). I haven't found one for Node/JavaScript.

So... given the following very simple example Node/Express app with MongoDB database, what code changes must be implemented so that this piece of code fully reflect current JavaScript functional programming best practices. Especially when it comes to the routes/functions handling database calls. I'm hoping your answers will help me, and others, better understand the practical application of the 'avoiding side-effects' concept of Functional Programming in real-world JavaScript.

/*app.js*/

const express = require('express')
const app = express()
var mongoose = require('mongoose');
mongoose.connect('mongodb://localhost/test');

var greetingSchema = mongoose.Schema({
    greeting: String
});

var Greeting = mongoose.model('Greeting', greetingSchema);

app.get('/', function (req, res) {
  Greeting.find({greeting: 'Hello World!'}, function (err, greeting){
    res.send(greeting);
  });  
});

app.post('/', function (req, res) {
  Greeting.create({greeting: 'Wasssssssssssuuuuppppp'}, function (err, greeting){
  res.send(greeting);
  });      
});

app.listen(3000, function () {
  console.log('Example app listening on port 3000!')
})

回答1:

You will not be able to avoid side effects entirely but you can make some effort to maximally abstract them away where possible.

For example the Express framework is inherently imperative. You run functions like res.send() entirely for their side effects (you don't even care about its return value most of the time).

What you could do (in addition to using const for all your declarations, using Immutable.js data structures, Ramda, writing all functions as const fun = arg => expression; instead of const fun = (arg) => { statement; statement; }; etc.) would be to make a little abstraction on how Express usually works.

For example you could create functions that take req as parameter and return an object that contains response status, headers and a stream to be piped as body. Those functions could be pure functions in a sense that their return value depend only on their argument (the request object) but you would still need some wrapper to actually send the response using the inherently imperative API of Express. It may not be trivial but it can be done.

As an example consider this function that takes body as an object to send as json:

const wrap = f => (req, res) => {
  const { status = 200, headers = {}, body = {} } = f(req);
  res.status(status).set(headers).json(body);
};

It could be used to create route handlers like this:

app.get('/sum/:x/:y', wrap(req => ({
  headers: { 'Foo': 'Bar' },
  body: { result: +req.params.x + +req.params.y },
})));

using a function that returns a single expression with no side effects.

Complete example:

const app = require('express')();

const wrap = f => (req, res) => {
  const { status = 200, headers = {}, body = {} } = f(req);
  res.status(status).set(headers).json(body);
};

app.get('/sum/:x/:y', wrap(req => ({
  headers: { 'Foo': 'Bar' },
  body: { result: +req.params.x + +req.params.y },
})));

app.listen(4444);

Testing the response:

$ curl localhost:4444/sum/2/4 -v
* Hostname was NOT found in DNS cache
*   Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 4444 (#0)
> GET /sum/2/4 HTTP/1.1
> User-Agent: curl/7.35.0
> Host: localhost:4444
> Accept: */*
> 
< HTTP/1.1 200 OK
< X-Powered-By: Express
< Foo: Bar
< Content-Type: application/json; charset=utf-8
< Content-Length: 12
< ETag: W/"c-Up02vIPchuYz06aaEYNjufz5tpQ"
< Date: Wed, 19 Jul 2017 15:14:37 GMT
< Connection: keep-alive
< 
* Connection #0 to host localhost left intact
{"result":6}

Of course this is just a basic idea. You could make the wrap() function accept promises for the return value of the functions for async oprations, but then it will arguably not be so side-effect free:

const wrap = f => async (req, res) => {
  const { status = 200, headers = {}, body = {} } = await f(req);
  res.status(status).set(headers).json(body);
};

and a handler:

const delay = (t, v) => new Promise(resolve => setTimeout(() => resolve(v), t));

app.get('/sum/:x/:y', wrap(req =>
  delay(1000, +req.params.x + +req.params.y).then(result => ({
    headers: { 'Foo': 'Bar' },
    body: { result },
  }))));

I used .then() instead of async/await in the handler itself to make it look more functional, but it can be written as:

app.get('/sum/:x/:y', wrap(async req => ({
  headers: { 'Foo': 'Bar' },
  body: { result: await delay(1000, +req.params.x + +req.params.y) },
})));

It could be made even more universal if the function that is an argument to wrap would be a generator that instead of yielding only promises to resolve (like the generator-based coroutines usually do) it would yield either promises to resolve or chucks to stream, with some wrapping to distinguish the two. This is just a basic idea but it can be extended much further.