比较Java与Node.js的并发性和性能- maxant

21-11-24 banq

想象一个简单的市场,对同一产品感兴趣的买家和卖家聚集在一起进行交易。对于市场上的每个产品,对该产品感兴趣的买家可以形成一个有序的队列,按照“先到先得”的原则进行排序。然后,每个买家可以接近最便宜的卖家并进行交易,以卖家指定的价格从卖家那里购买尽可能多的产品。如果没有卖家以足够低的价格提供产品,买家可以让开站到一边,让下一个买家有机会进行交易。一旦所有买家都有机会进行交易,当市场上的所有产品都经过循环后,整个过程可以重新开始,在满意的买家和卖家离开后,新的循环开始。在互联网时代,买家和卖家没有理由不能坐在舒适的扶手椅上使用这种算法在虚拟平台上进行交易。事实上,这样的交易平台已经存在多年。

 

虽然很基本,但当用于构建基于计算机的交易引擎时,这类问题变得有趣。简单的问题带来挑战:

市场如何跨多个核心扩展?

市场如何跨多台机器扩展?

本质上,答案归结为需要某种形式的并发性,以便这样的交易引擎可以扩展。通常,我会使用执行池和synchronized 关键字来编写基于 Java 的解决方案,以确保多个线程以有序的方式更新中心模型。但最近我开始尝试使用 Node.js,这个平台对解决上述问题很有趣,因为它是一个单线程非阻塞平台。这个想法是程序员在设计和编写算法时没有太多的理由,因为两个线程可能想要同时访问公共数据是没有危险的。

  

NodeJs实现

我花时间用 JavaScript 对上述市场进行建模,交易功能如下(其余部JavaScript 代码可以在这里找到)。

 

  this.trade = function(){
        var self = this;
        var sales = [];
 
        var productsInMarket = this.getProductsInMarket().values();
...
        //trade each product in succession
        _.each(productsInMarket, function(productId){
            var soldOutOfProduct = false;
            logger.debug('trading product ' + productId);
            var buyersInterestedInProduct = self.getBuyersInterestedInProduct(productId);
            if(buyersInterestedInProduct.length === 0){
                logger.info('no buyers interested in product ' + productId);
            }else{
                _.each(buyersInterestedInProduct, function(buyer){
                    if(!soldOutOfProduct){
                        logger.debug('  buyer ' + buyer.name + ' is searching for product ' + productId);
                        //select the cheapest seller
                        var cheapestSeller = _.chain(self.sellers)
                                              .filter(function(seller){return seller.hasProduct(productId);})
                                              .sortBy(function(seller){return seller.getCheapestSalesOrder(productId).price;})
                                              .first()
                                              .value();
 
                        if(cheapestSeller){
                            logger.debug('    cheapest seller is ' + cheapestSeller.name);
                            var newSales = self.createSale(buyer, cheapestSeller, productId);
                            sales = sales.concat(newSales);
                            logger.debug('    sales completed');
                        }else{
                            logger.warn('    market sold out of product ' + productId);
                            soldOutOfProduct = true;
                        }
                    }
                });
            }
        });
 
        return sales;
    };

代码使用了Underscore.js库,它提供了一堆有用的函数式助手,很像添加到Java 8 Streams 中的那些。下一步是创建一个交易引擎,它封装了一个市场,如下面的代码片段所示, 通过删除没有合适的买家和卖家可以配对的超时销售来准备第 1 行的市场;运行第3行的交易流程;注释第 6 行的统计数据;并在第 8 行持续销售。

 

prepareMarket(self.market, timeout);
 
        var sales = self.market.trade();
        logger.info('trading completed');
 
        noteMarketPricesAndVolumes(self.marketPrices, self.volumeRecords, sales);
 
        persistSale(sales, function(err){
            if(err) logger.warn(err);
            else {
                logger.info('persisting completed, notifying involved parties...');
                _.each(sales, function(sale){
                    if(sale.buyer.event) sale.buyer.event(exports.EventType.PURCHASE, sale);
                    if(sale.seller.event) sale.seller.event(exports.EventType.SALE, sale);
                });
            }
            ...
            setTimeout(loop, 0 + delay); //let the process handle other stuff too
            ...
        });
    }

到目前为止,我们还没有看到任何真正有趣的代码,除了上面的第 8 行,销售是持久的。Sales 被插入到一个表中,该表包含有关销售 ID(自动递增的主键)、产品 ID、销售订单 ID 和采购订单 ID(来自程序)的索引。对persistSale(...)函数的调用会调用 MySQL 数据库,所使用的库在调用数据库时使用非阻塞 I/O。它必须这样做,因为在 Node.js 中,进程中没有其他可用的线程以及所有内容在进程中运行会在等待数据库插入结果时阻塞。实际发生的是 Node.js 进程触发插入请求,其余代码立即运行,直至完成。

如果您检查JavaScript 代码的其余部分,您会注意到实际上没有其他代码在调用该persistSale(...)函数之后运行。那时,Node.js 会进入事件队列并寻找其他事情要做。

为了使交易引擎有用,我决定在我的环境中将它构建为一个独立的组件,并将其接口公开为一个简单的 HTTP 服务。通过这种方式,我可以通过多种方式获利,例如拥有一个可部署单元,该单元可以通过将其部署在集群中的多个节点上来向外扩展,并使后端与我尚未创建的任何前端分离。命名的脚本trading-engine-parent3.js依赖于一个名为express的小网络框架,该脚本的相关部分如下所示:

logger.info('setting up HTTP server for receiving commands');
 
var express = require('express')
var app = express()
var id = 0;
app.get('/buy', function (req, res) {
    logger.info(id + ') buying "' + req.query.quantity + '" of "' + req.query.productId + '"');
    ...
});
app.get('/sell', function (req, res) {
    logger.info(id + ') selling "' + req.query.quantity + '" of "' + req.query.productId + '" at price "' + req.query.price + '"');
    ...
});
app.get('/result', function (req, res) {
    var key = parseInt(req.query.id);
    var r = results.get(key);
    if(r){
        results.delete(key);
        res.json(r);
    }else{
        res.json({msg: 'UNKNOWN OR PENDING'});
    }
});
 
var server = app.listen(3000, function () {
  logger.warn('Trading engine listening at http://%s:%s', host, port)
});

第 8 行和第 12 行调用引擎并分别添加采购订单/销售订单。我们将很快检查某事究竟如何。第 16 行显示了我在设计中做出的一个重要选择,即 HTTP 请求在等待交易订单结果时不保持打开状态。最初我尝试保持请求打开,但在负载测试期间我遇到了经典的死锁问题。市场包含订单,但没有匹配的产品,并且服务器在其TCP 积压填充后不会接受新请求(另请参见此处),因此其他客户端无法创建新的采购和销售订单,因此市场没有'不包含能让销售持续流动所必需的产品。

因此,让我们回到保持交易销售后发生的情况。由于持久化是异步的,我们在前一个脚本 (trading-engine-loop.js) 的第 8-20 行提供了一个回调函数,该函数通过向买方/卖方发送适当的事件(第 13-14 行)来处理结果setTimeout(loop, 0+delay)告诉 Node.jsloop在至少delay几毫秒后运行该函数的调用。该setTimeout函数将这项工作放到事件队列中。通过调用此函数,我们允许 Node.js 为已放置在事件队列中的其他工作提供服务,例如添加采购或销售订单的 HTTP 请求,或者确实调用该loop函数以再次开始交易。

 

为此 Node.js 解决方案编写的代码具有非阻塞异步性质,因此确实不需要更多线程。除了……我们如何扩大进程并使用机器上的其他内核?Node.js 支持创建子进程,这样做确实非常容易,如下面的代码片段所示。

// ////////////////
// Parent
// ////////////////
...
var cp = require('child_process');
...
//TODO use config to decide how many child processes to start
var NUM_KIDS = 2;
var PRODUCT_IDS = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 
                   '10', '11', '12', '13', '14', '15', '16', '17', '18', '19',
                   ...
                  ];
 
var chunk = PRODUCT_IDS.length / NUM_KIDS;
var kids = new Map();
for (var i=0, j=PRODUCT_IDS.length; i<j; i+=chunk) {
    var n = cp.fork('./lib/trading-engine-child.js');
    n.on('message', messageFromChild);
    var temparray = PRODUCT_IDS.slice(i,i+chunk);
    logger.info('created child process for products ' + temparray);
    _.each(temparray, function(e){
        logger.debug('mapping productId "' + e + '" to child process ' + n.pid);
        kids.set(e, n);
    });
}
...

// ////////////////
// Child
// ////////////////
process.on('message', function(model) {
    logger.debug('received command: "' + model.command + '"');
    if(model.command == t.EventType.PURCHASE){
        var buyer = ...
        var po = new m.PurchaseOrder(model.what.productId, model.what.quantity, model.what.maxPrice, model.id);
        buyer.addPurchaseOrder(po);
    }else if(model.command == t.EventType.SALE){
        ...
    }else{
        var msg = 'Unknown command ' + model.command;
        process.send({id: model.id, err: msg});            
    }
});

第 5 行导入用于处理子进程的 API,我们通过在第 14-25 行对产品 ID 进行分组来划分市场。对于每个分区,我们启动一个新的子进程(第 17 行)并在第 18 行注册一个回调,用于接收从子进程传送回父进程的数据。我们将对子进程的引用粘贴到以产品 ID 为键的映射中在第23行,使我们可以通过调用例如发送的消息:n.send(someObject)。如何简单地发送和接收对象以及它们如何作为(大概)JSON 传输是非常漂亮的——它与 Java 中的 RMI 调用非常相似。通过上述解决方案,交易引擎可以通过添加子进程来垂直扩展,也可以通过在多个节点上部署交易引擎父级(包括其 Web 服务器)并使用负载均衡器将基于产品 ID 的请求分发到正确的节点处理该产品的交易。

如果您想知道买家是否可以出现在多个市场中,那么答案当然是肯定的 - 市场是虚拟的,买家不受现实生活中物理位置的限制:-)

  

Java实现

 Java 解决方案是什么样的,它会如何执行?完整的Java 代码可在此处获得

从市场及其trade()方法,Java 代码看起来类似于 JavaScript 版本,使用 Java 8 Streams 而不是 Underscore 库。有趣的是,它在代码行数上几乎相同,或者更主观地说,可维护性。

public List<Sale> trade() {
    List<Sale> sales = new ArrayList<>();
    Set<String> productsInMarket = getProductsInMarket();
    collectMarketInfo();

    // trade each product in succession
    productsInMarket.stream()
        .forEach(productId -> {
            MutableBoolean soldOutOfProduct = new MutableBoolean(false);
            LOGGER.debug("trading product " + productId);
            List<Buyer> buyersInterestedInProduct = getBuyersInterestedInProduct(productId);
            if (buyersInterestedInProduct.size() == 0) {
                LOGGER.info("no buyers interested in product " + productId);
            } else {
                buyersInterestedInProduct.forEach(buyer -> {
                    if (soldOutOfProduct.isFalse()) {
                        LOGGER.debug("  buyer " + buyer.getName() + " is searching for product " + productId);
                        // select the cheapest seller
                        Optional<Seller> cheapestSeller = sellers.stream()
                            .filter(seller -> { return seller.hasProduct(productId);})
                            .sorted((s1, s2) -> 
                                Double.compare(s1.getCheapestSalesOrder(productId).getPrice(),
                                               s2.getCheapestSalesOrder(productId).getPrice()))
                            .findFirst();
                        if (cheapestSeller.isPresent()) {
                            LOGGER.debug("    cheapest seller is " + cheapestSeller.get().getName());
                            List<Sale> newSales = createSale(buyer, cheapestSeller.get(), productId);
                            sales.addAll(newSales);
                            LOGGER.debug("    sales completed");
                        } else {
                            LOGGER.warn("    market sold out of product " + productId);
                            soldOutOfProduct.setTrue();
                        }
                    }
                });
            }
        });
    return sales;
}

正如我几年前在我的书中所写的那样,如今编写多范式解决方案很正常,函数式编程用于数据操作,面向对象用于封装买方、卖方或市场,正如我们将看到的很快,面向服务和方面的编程用于将复杂的框架代码粘合到位以提供类似 REST 的 HTTP 服务。接下来run是Java中交易引擎的方法,只要引擎处于运行状态就进行交易:

public void run() {
    while (running) {
        prepareMarket();
 
        List<Sale> sales = market.trade();
        LOGGER.info("trading completed");
 
        noteMarketPricesAndVolumes(sales);
 
        persistSale(sales);
        LOGGER.info("persisting completed, notifying involved parties...");
        sales.stream().forEach(sale -> {
            if (sale.getBuyer().listener != null)
                sale.getBuyer().listener.onEvent(EventType.PURCHASE, sale);
            if (sale.getSeller().listener != null)
                sale.getSeller().listener.onEvent(EventType.SALE, sale);
        });
        ...
        try {
            Thread.sleep(delay);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

Java 设计与 Node.js 设计略有不同,因为我创建了一个简单的方法,命名为run我将调用一次。只要命名的布尔字段为running真,它就会一遍又一遍地运行。我可以在 Java 中做到这一点,因为我可以利用其他线程与交易并行工作。为了调整引擎,我在每次迭代结束时引入了一个短暂的可配置延迟,线程暂停。在我所做的所有测试中,它被设置为暂停 3 毫秒,这与 JavaScript 解决方案使用的相同。

现在我刚刚提到使用线程来扩展系统。在这种情况下,线程类似于 Node.js 解决方案中使用的子进程。正如在 Node.js 解决方案中一样,Java 解决方案按产品 ID 划分市场,但不使用子进程,Java 解决方案在不同的线程上运行每个交易引擎(封装市场)。理论上,最佳分区数将与内核数相似,但经验表明,它还取决于线程被阻塞的程度,例如在数据库中持久销售。被阻塞的线程为其他线程在它们的位置上运行腾出空间,但线程过多会降低性能,因为线程之间的上下文切换变得更加相关。线程只是将运行委托给引擎,如上所示,它在循环中运行,直到它关闭。

public class TradingEngineThread extends Thread {
    private final TradingEngine engine;
 
    public TradingEngineThread(long delay, long timeout, Listener listener) throws NamingException {
        super("engine-" + ID++);
        engine = new TradingEngine(delay, timeout, listener);
    }
 
    @Override
    public void run() {
        engine.run();
    }

对于 Java 解决方案,我使用 Tomcat 作为 Web 服务器并创建了一个简单HttpServlet的处理请求以创建采购和销售订单。servlet 划分市场并创建相关线程并启动它们(请注意,更好的方法是在 servlet 启动时启动线程并在 servlet 停止时关闭引擎 - 显示的代码未准备好生产!)。以下代码的第 15 行启动了上一个代码段中显示的线程。

@WebServlet(urlPatterns = { "/sell", "/buy", "/result" })
public class TradingEngineServlet extends HttpServlet {
    private static final Map<String, TradingEngineThread> kids = new HashMap<>();
    static {
        int chunk = PRODUCT_IDS.length / NUM_KIDS;
        for (int i = 0, j = PRODUCT_IDS.length; i < j; i += chunk) {
            String[] temparray = Arrays.copyOfRange(PRODUCT_IDS, i, i + chunk);
            LOGGER.info("created engine for products " + temparray);
            TradingEngineThread engineThread = new TradingEngineThread(DELAY, TIMEOUT, (type, data) -> event(type, data));
            for (int k = 0; k < temparray.length; k++) {
                LOGGER.debug("mapping productId '" + temparray[k] + "' to engine " + i);
                kids.put(temparray[k], engineThread);
            }
            LOGGER.info("---started trading");
            engineThread.start();
        }

servlet 处理购买和销售请求如下:

@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
    String path = req.getServletPath();
    LOGGER.debug("received command: '" + path + "'");

    String who = req.getParameter("userId");
    String productId = req.getParameter("productId");
    TradingEngineThread engine = kids.get(productId);
    int quantity = Integer.parseInt(req.getParameter("quantity"));
    int id = ID.getAndIncrement();

    // e.g. /buy?productId=1&quantity=10&userId=ant
    if (path.equals("/buy")) {
        PurchaseOrder po = engine.addPurchaseOrder(who, productId, quantity, id);
        resp.getWriter().write("\"id\":" + id + ", " + String.valueOf(po));
    } else if (path.equals("/sell")) {

在第 8 行查找相关引擎,并给出详细信息,例如在第 14 行创建采购订单。现在它最初看起来好像我们拥有 Java 解决方案所需的一切,但在我将负载加载到服务器上之后,我遇到了ConcurrentModificationExceptions,很明显发生了什么:上面代码片段中的第 14 行正在向引擎中的模型添加采购订单,同时市场说迭代买家采购订单以确定哪些买家感兴趣在哪些产品中。

  

不同点

Node.js 正是通过其单线程方法避免了这种问题。这也是在 Java 世界中很难解决的问题!以下提示可能会有所帮助:

使用synchronized关键字来确保对给定(数据)对象的同步访问,

如果您只需要读取数据并对其做出反应,请复制数据,

为您的数据结构使用线程安全集合,

修改设计。

第一个技巧可能会导致死锁,并且在 Java 世界中有些臭​​名昭著。第二个技巧有时很有用,但涉及复制数据的开销。第三个技巧有时会有所帮助,但请注意 Javadocs 中包含的以下注释java.util.Collections#synchronizedCollection(Collection):

返回由指定集合支持的同步(线程安全)集合...当遍历返回的集合时,用户必须手动同步它...不遵循此建议可能会导致非确定性行为。

使用线程安全的集合是不够的,与第一个技巧相关的问题不会像人们希望的那样简单地消失。这就留下了第四个技巧。

  

Actor模型

如果你回顾一下上面的代码,你会发现一个名为 的方法prepareMarket()。为什么我们不在自己的模型中存储所有采购和销售订单,直到在其自己的线程中运行的交易引擎到达需要准备市场的程度,然后将所有这些未结订单添加进来到市场模型,在交易开始之前?这样我们就可以避免来自多个线程的并发访问以及同步数据的需要。当您查看所有 Java 源代码时,您会看到TradingEngine使用名为newPurchaseOrders和的两个字段来执行此操作newSalesOrders。

这种设计的有趣之处在于它非常类似于actor模型,并且已经存在完美的Java库,即Akka. 因此,我向使用 Akka 而不是线程的应用程序添加了第二个 servlet,以展示它如何解决并发问题。基本上,actor 是一个包含状态(数据)、行为和消息收件箱的对象。除了演员之外,没有人可以访问状态,因为它应该是演员私有的。Actor 响应收件箱中的消息并根据消息告诉它做什么来运行它的行为。Actor 保证它在任何时候都只会读取和响应一条消息,因此不会发生并发的状态修改。新的 servlet 在第 13 行使用第 4 行创建的角色系统创建新角色,如下所示。因为actor系统应该在servlet启动时启动,而不是在如下所示的静态上下文中启动,并且应该在servlet停止时关闭。第 19 行向新创建的 actor 发送一条消息,告诉它启动它包含的交易引擎。

@WebServlet(urlPatterns = { "/sell2", "/buy2", "/result2" })
public class TradingEngineServletWithActors extends HttpServlet {
 
    private static final ActorSystem teSystem = ActorSystem.create("TradingEngines");
    private static final Map<String, ActorRef> kids = new HashMap<>();
 
    static {
        int chunk = PRODUCT_IDS.length / NUM_KIDS;
        for (int i = 0, j = PRODUCT_IDS.length; i < j; i += chunk) {
            String[] temparray = Arrays.copyOfRange(PRODUCT_IDS, i, i + chunk);
            LOGGER.info("created engine for products " + temparray);
     
            ActorRef actor = teSystem.actorOf(Props.create(TradingEngineActor.class), "engine-" + i);
            for (int k = 0; k < temparray.length; k++) {
                LOGGER.debug("mapping productId '" + temparray[k] + "' to engine " + i);
                kids.put(temparray[k], actor);
            }
            LOGGER.info("---started trading");
            actor.tell(TradingEngineActor.RUN, ActorRef.noSender());
        }
        

Actor类如下所示,其数据和行为被封装在其交易引擎实例中。

private static class TradingEngineActor extends AbstractActor {
 
    // STATE
    private TradingEngine engine = new TradingEngine(DELAY, TIMEOUT, (type, data) -> handle(type, data), true);
 
    public TradingEngineActor() throws NamingException {
 
        // INBOX
        receive(ReceiveBuilder
            .match(SalesOrder.class, so -> {
                // BEHAVIOUR (delegated to engine)
                engine.addSalesOrder(so.getSeller().getName(),
                    so.getProductId(),
                    so.getRemainingQuantity(),
                    so.getPrice(), so.getId());
                })
            .match(PurchaseOrder.class, po -> {
                ...
            .match(String.class, s -> RUN.equals(s), command -> {
                engine.run();
            })
            .build());
    } 
}

你可以看到 actor 类的第 4 行的交易引擎是私有的,只有在接收到消息时才会使用,例如在第 12、18 或 20 行。这样,保证没有两个线程可以同时访问它时间是可以坚持的,对我们来说更重要的是,完全不需要在引擎上同步,这意味着我们的并发推理能力得到了极大的提升!请注意,为了允许处理收件箱中的消息,交易引擎运行一个交易会话,然后将新的“运行”消息推送到收件箱。这样,在交易继续之前,首先处理来自 HTTP 服务器的任何添加采购/销售订单的消息。

 

性能测试

现在是开始查看负载下设计性能的时候了。我可以使用三台机器:

  • 具有 16 GB RAM 运行 Linux(Fedora Core 20)的“高性能”6 核 AMD 处理器,
  • “中等性能”四核 I5 处理器,4GB RAM,运行 Windows 7,以及
  • 具有 4GB RAM 的“低性能”Intel Core 2 Duo 处理器也运行 Linux。

三台机器连接在一个 100 兆位/秒的有线网络上。负载测试客户端是一个定制的 Java 程序它使用执行池运行 50 个并行线程,连续进行随机采购和销售订单。在请求之间,客户端暂停。暂停时间经过调整,以便 Java 和 Node.js 进程性能较差的进程可以跟上负载,但接近它们开始滞后的临界点,并记录在下面的结果中。在持续至少 50 万销售额之前,以及在吞吐量稳定之前(想想热点优化),才会记录结果。吞吐量是使用插入数据库的行数来衡量的,而不是程序输出的不可靠的统计数据。

结果:

  • 案例 1 - 200ms 客户端等待时间,4 个交易引擎快速交易引擎,慢速数据库

吞吐量(每分钟销售额) 同步Java:5,100 带有Akka的Java:5,000 NodeJS:6,400
带有交易引擎的机器上的平均 CPU  同步Java:<50% 带有Akka的Java:<40% NodeJS:40-60%

  • 案例 2 - 50ms 客户端等待时间,2 个交易引擎慢速交易引擎,快速数据库

吞吐量(每分钟销售额) 同步Java:32,800 带有Akka的Java:30,100 NodeJS:15,000
带有交易引擎的机器上的平均 CPU  同步Java:85% 带有Akka的Java:90% NodeJS:>95%

在第一种情况下,交易引擎不受 CPU 限制。在案例二中,交易引擎受 CPU 限制,但整个系统的执行速度比案例一要快。在这两种情况下,系统网络都不受限制,因为我测量的最大传输速度为每秒 300 千字节,不到网络容量的 3%。在第一种情况下,数据库是最慢的组件,交易引擎似乎受 I/O 限制,等待数据库插入的结果。由于 Node.js 对其所有代码使用非阻塞范式,因此它的性能优于 Java 解决方案。

当我使用带有预配置非阻塞 (NIO) 连接器的 Tomcat 8 时,MySQL 驱动程序是标准的 JDBC 阻塞版本。在第二种情况下,数据库速度更快,交易引擎受 CPU 限制,Java 解决方案运行速度更快。

 

我的结果实际上并不那么令人惊讶——众所周知,Node.js 表现良好,尤其是在阻塞条件下。有关我认为与我的结果非常相关的结果,请参阅以下两个链接: What Makes Node.js Faster Than Java?和 PayPal 的 Node-vs-Java 基准分析。第二个链接末尾的评论非常有趣,我觉得大部分都是有效的观点。

 

我没有尝试通过使持久性也非阻塞来优化 Java 解决方案,因此它也是一个完全非阻塞的解决方案。这是可能的,因为存在非阻塞(尽管是非 JDBC)MySQL 驱动程序. 但它也需要改变 Java 解决方案的设计。

而且,如在上面的链接一个评论指出,这也许重新设计将是最具挑战性的部分的平均Java 程序员,直到最近(如果有的话)从未在异步非阻塞范式中编程。不是难,而是不同,我怀疑随着最近 Node.js 的成功,越来越多的异步 Java 库会开始出现。

请注意,最后一段并不意味着会引发任何类型的争论——我绝不是说 Java、JavaScript、JVM 或 Node.js 中的任何一个更好。我要说的是

  • a) 我曾经是 Java 及其生态系统的坚定支持者,在过去的几年里,我已经成熟到意识到其他平台也很棒,并且
  • b) 为手头的工作选择正确的工具使用概念验证进行评估,例如我在这里所做的。

请注意,本文中提供的代码不适用于任何目的,而且肯定不是生产就绪的,也不代表我可能会专业生产的东西

 

1
猜你喜欢