使用生成器和Promise进行Node.js功能编程

时间:2019-01-16 09:50:18

标签: node.js functional-programming lodash ramda.js

摘要

node.js中的函数式编程是否足够通用?是否可以用来解决一个现实问题,即处理少量的db记录而不使用toArray将所有记录加载到内存中(从而导​​致内存不足)。您可以阅读this criticism for background。我们想通过异步生成器来演示此类node.js库的Mux and DeMux和fork / tee / join功能。

上下文

我在质疑使用任何功能编程工具(例如ramdalodashimlazy)甚至自定义功能在node.js中进行功能编程的有效性和普遍性。

放弃

来自MongoDB游标的数百万条记录可以使用await cursor.next()进行迭代

您可能想read more about async generators and for-await-of

对于虚假数据,可以使用(在节点10上)

function sleep(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
}
async function* getDocs(n) {
  for(let i=0;i<n;++i) {
     await sleep(1);
     yield {i: i, t: Date.now()};
  }
}
let docs=getDocs(1000000);

想要

我们需要

  • 第一份文件
  • 最后一个文件
  • 文件数
  • 拆分为n批文件/批量,并针对该批量文件发出socket.io事件

请确保第一批和最后一批文档都包含在批次中,并且不会被消耗。

约束

数百万条记录不应加载到ram中,应该对其进行迭代,最多只能保留其中的一批。

可以使用常规的nodejs代码完成此要求,但也可以使用here中的applyspec之类的要求。

R.applySpec({
  first: R.head(),
  last: R.last(),
  _: 
    R.pipe(
      R.splitEvery(n),
      R.map( (i)=> {return "emit "+JSON.stringify(i);})
    ) 
})(input)

5 个答案:

答案 0 :(得分:2)

我不确定是否公平地说,在处理大量数据时,函数式编程在性能方面会比命令式编程提供任何优势。

我认为您需要在工具包中添加另一个工具,该工具可能是RxJS

  

RxJS是一个库,用于通过使用可观察的序列来组成异步和基于事件的程序。

如果您一般不熟悉RxJS或反应式编程,那么我的示例肯定看起来很奇怪,但我认为熟悉这些概念将是一项不错的投资

在您的情况下,可观察的序列是您的MongoDB实例,该实例随时间发出记录。

我要假冒你的数据库:

var db = range(1, 5);

range函数是一个RxJS东西,它将发出所提供范围内的值。

db.subscribe(n => {
  console.log(`record ${n}`);
});

//=> record 1
//=> record 2
//=> record 3
//=> record 4
//=> record 5

现在我只对第一个和最后一个记录感兴趣。

我可以创建一个仅发出第一条记录的可观察对象,并创建另一条仅发出最后一条记录的对象:

var db = range(1, 5);
var firstRecord = db.pipe(first());
var lastRecord = db.pipe(last());

merge(firstRecord, lastRecord).subscribe(n => {
  console.log(`record ${n}`);
});
//=> record 1
//=> record 5

但是,我还需要分批处理所有记录:(在本示例中,我将创建每组10条记录的批处理)

var db = range(1, 100);
var batches = db.pipe(bufferCount(10))
var firstRecord = db.pipe(first());
var lastRecord = db.pipe(last());

merge(firstRecord, batches, lastRecord).subscribe(n => {
  console.log(`record ${n}`);
});

//=> record 1
//=> record 1,2,3,4,5,6,7,8,9,10
//=> record 11,12,13,14,15,16,17,18,19,20
//=> record 21,22,23,24,25,26,27,28,29,30
//=> record 31,32,33,34,35,36,37,38,39,40
//=> record 41,42,43,44,45,46,47,48,49,50
//=> record 51,52,53,54,55,56,57,58,59,60
//=> record 61,62,63,64,65,66,67,68,69,70
//=> record 71,72,73,74,75,76,77,78,79,80
//=> record 81,82,83,84,85,86,87,88,89,90
//=> record 91,92,93,94,95,96,97,98,99,100
//=> record 100

如您在输出中所见,它发出了:

  1. 第一条记录
  2. 十批,每组10条记录
  3. 最后一条记录

我不会尝试为您解决运动,而且我对RxJS不太熟悉,无法对此做太多扩展。

我只是想向您展示另一种方式,让您知道可以将其与功能编程结合使用。

希望有帮助

答案 1 :(得分:2)

我想我可能已经为您找到了答案,它叫做scramjet。它轻巧(node_modules中没有成千上万的依赖),易于使用,并且确实使您的代码易于理解和阅读。

让我们从您的案例开始吧:

DataStream
    .from(getDocs(10000))
    .use(stream => {
        let counter = 0;

        const items = new DataStream();
        const out = new DataStream();

        stream
            .peek(1, async ([first]) => out.whenWrote(first))
            .batch(100)
            .reduce(async (acc, result) => {
                await items.whenWrote(result);

                return result[result.length - 1];
            }, null)
            .then((last) => out.whenWrote(last))
            .then(() => items.end());

        items
            .setOptions({ maxParallel: 1 })
            .do(arr => counter += arr.length)
            .each(batch => writeDataToSocketIo(batch))
            .run()
            .then(() => (out.end(counter)))
        ;

        return out;
    })
    .toArray()
    .then(([first, last, count]) => ({ first, count, last }))
    .then(console.log)
;

所以我不太同意javascript FRP是一种反模式,我认为我没有唯一的答案,但是在开发第一个提交时,我发现ES6箭头语法和async / await用链式时尚使代码易于理解。

这是OpenAQ中,特别是this line in their fetch process中的超燃冲压发动机代码的另一个示例:

return DataStream.fromArray(Object.values(sources))
  // flatten the sources
  .flatten()
  // set parallel limits
  .setOptions({maxParallel: maxParallelAdapters})
  // filter sources - if env is set then choose only matching source,
  //   otherwise filter out inactive sources.
  // * inactive sources will be run if called by name in env.
  .use(chooseSourcesBasedOnEnv, env, runningSources)
  // mark sources as started
  .do(markSourceAs('started', runningSources))
  // get measurements object from given source
  // all error handling should happen inside this call
  .use(fetchCorrectedMeasurementsFromSourceStream, env)
  // perform streamed save to DB and S3 on each source.
  .use(streamMeasurementsToDBAndStorage, env)
  // mark sources as finished
  .do(markSourceAs('finished', runningSources))
  // convert to measurement report format for storage
  .use(prepareCompleteResultsMessage, fetchReport, env)
  // aggregate to Array
  .toArray()
  // save fetch log to DB and send a webhook if necessary.
  .then(
    reportAndRecordFetch(fetchReport, sources, env, apiURL, webhookKey)
  );

它描述了每种数据源发生的一切。因此,这是我的建议供质疑。 :)

答案 2 :(得分:1)

为演示如何使用Vanilla JS建模,我们可以引入折叠在异步生成器上的想法,该生成器可以生成可以组合在一起的东西。

(of, concat, empty)

此处参数分为三个部分:

  • (step, fin)期望一个函数产生“可组合”的事物,该函数将组合两个“可组合”的事物和一个“可组合”的事物的空/初始实例。
  • Promise期望一个函数在每个步骤中都会采用“可组合”的事物,并产生Promise的“可组合”事物用于下一步,而该函数将采用生成器用尽并产生async asyncGen最终结果后的最后“可组合”事物
  • const Accum = (first, last, batch) => ({ first, last, batch, }) Accum.empty = Accum(null, null, []) // an initial instance of `Accum` Accum.of = x => Accum(x, x, [x]) // an `Accum` instance of a single value Accum.concat = (a, b) => // how to combine two `Accum` instances together Accum(a.first == null ? b.first : a.first, b.last, a.batch.concat(b.batch)) 是要处理的异步生成器

在FP中,“可组合”事物的概念称为Monoid,它定义了一些定律,详细阐明了将它们两个组合在一起的预期行为。

然后我们可以创建一个Monoid,在逐步通过生成器时,它将用于传递第一个,最后一个和一批值。

onFlush

要捕获刷新累积批处理的想法,我们可以创建另一个函数,该函数采用一个Promise函数,该函数将在返回的n中执行某些操作,并刷新其值,大小为{{ 1}}何时刷新批处理。

Accum.flush = onFlush => n => acc =>
  acc.batch.length < n ? Promise.resolve(acc)
                       : onFlush(acc.batch.slice(0, n))
                           .then(_ => Accum(acc.first, acc.last, acc.batch.slice(n)))

我们现在还可以定义如何折叠Accum实例。

Accum.foldAsyncGen = foldAsyncGen(Accum.of, Accum.concat, Accum.empty)

定义了以上实用程序后,我们现在可以使用它们来为您的特定问题建模。

const emit = batch => // This is an analog of where you would emit your batches
  new Promise((resolve) => resolve(console.log(batch)))

const flushEmit = Accum.flush(emit)

// flush and emit every 10 items, and also the remaining batch when finished
const fold = Accum.foldAsyncGen(flushEmit(10), flushEmit(0))

最后运行您的示例。

fold(getDocs(100))
  .then(({ first, last })=> console.log('done', first, last))

答案 3 :(得分:1)

这是使用RxJsscramjet的两种解决方案。

这是一个RxJs solution

诀窍是使用share(),以使first()last()不会从迭代器中被使用,forkJoin用于将它们组合在一起以发出完成事件,这些值。

function ObservableFromAsyncGen(asyncGen) {
  return Rx.Observable.create(async function (observer) {
    for await (let i of asyncGen) {
      observer.next(i);
    }
    observer.complete();
  });  
}
async function main() {
  let o=ObservableFromAsyncGen(getDocs(100));
  let s = o.pipe(share());
  let f=s.pipe(first());
  let e=s.pipe(last());
  let b=s.pipe(bufferCount(13));
  let c=s.pipe(count());
  b.subscribe(log("bactch: "));
  Rx.forkJoin(c, f, e, b).subscribe(function(a){console.log(
    "emit done with count", a[0], "first", a[1], "last", a[2]);})
}

这是一个scramjet,但这并不纯(功能有副作用)

async function main() {
  let docs = getDocs(100);
  let first, last, counter;
  let s0=Sj.DataStream
    .from(docs)
    .setOptions({ maxParallel: 1 })
    .peek(1, (item)=>first=item[0])
    .tee((s)=>{
        s.reduce((acc, item)=>acc+1, 0)
        .then((item)=>counter=item);
    })
    .tee((s)=>{
        s.reduce((acc, item)=>item)
        .then((item)=>last=item);
    })
    .batch(13)
    .map((batch)=>console.log("emit batch"+JSON.stringify(batch));
  await s0.run();
  console.log("emit done "+JSON.stringify({first: first, last:last, counter:counter}));
}

我将与@michał-kapracki合作开发它的纯版本。

答案 4 :(得分:0)

针对这种确切的问题,我制作了这个库:ramda-generators

希望这是您要寻找的:在功能性JavaScript中对流进行惰性评估

唯一的问题是我不知道如何在不重新运行生成器的情况下从流中获取最后一个元素和元素数量

可以在不解析内存中整个数据库的情况下计算结果的可能实现是

Try it on repl.it

const RG = require("ramda-generators");
const R  = require("ramda");

const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));

const getDocs = amount => RG.generateAsync(async (i) => {
    await sleep(1);
    return { i, t: Date.now() };
}, amount);

const amount = 1000000000;

(async (chunkSize) => {
    const first = await RG.headAsync(getDocs(amount).start());
    const last  = await RG.lastAsync(getDocs(amount).start()); // Without this line the print of the results would start immediately 

    const DbIterator = R.pipe(
        getDocs(amount).start,
        RG.splitEveryAsync(chunkSize),
        RG.mapAsync(i => "emit " + JSON.stringify(i)),
        RG.mapAsync(res => ({ first, last, res })),
    );

    for await (const el of DbIterator()) 
        console.log(el);

})(100);