Статьи

Узел и обратные вызовы

Один из страхов людей с Node — это модель обратного вызова. Узел работает как единый поток: вы никогда не должны выполнять какую-либо работу, особенно любые операции ввода-вывода, которые блокируют, потому что при наличии только одного потока выполнения любой блок будет блокировать весь процесс.

Вместо этого все организовано вокруг обратных вызовов : вы просите API выполнить некоторую работу, и он вызывает функцию обратного вызова, которую вы предоставляете, когда работа завершается, в более позднее время. Здесь есть некоторые существенные компромиссы … с одной стороны, традиционный подход Java Servlet API включает в себя несколько потоков и изменяемое состояние в этих потоках, и часто эти потоки находятся в заблокированном состоянии во время ввода-вывода (обычно при обмене данными с базой данных ) в процессе. Однако множественные потоки и изменяемые данные означают блокировки, взаимоблокировки и другие нежелательные сложности, которые с этим связаны.

В отличие от этого, Node — это единый поток, и пока вы играете по правилам, вся сложность работы с изменяемыми данными исчезает. Например, вы не сохраняете данные в своей базе данных, не ждете ее завершения, а затем возвращаете сообщение о состоянии по сети: вы сохраняете данные в своей базе данных, передавая обратный вызов. Некоторое время спустя, когда данные действительно сохраняются, вызывается ваш обратный вызов, и в какой момент вы можете вернуть свое статусное сообщение. Это, конечно, компромисс: часть локального кода более сложна и немного сложна для понимания, но общая архитектура может быть быстрой, стабильной и масштабируемой … пока все играют по правилам.

Тем не менее, подход обратного вызова заставляет людей нервничать, потому что глубоко вложенным обратным вызовам может быть трудно следовать. Я видел это, когда преподавал Ajax как часть моей Мастерской Гобеленов.

Я только начинаю с Node, но я создаю приложение, которое ориентировано на клиента; Сервер Node в основном предоставляет API без сохранения состояния. В этой модели сервер Node не делает ничего слишком сложного, требующего вложенных обратных вызовов, и это хорошо. Вы в основном выясняете одну операцию, основанную на параметрах URL и запроса, выполняете некоторую логику и получаете ответный вызов, отправляющий ответ.

Есть еще несколько мест, где вам может понадобиться дополнительный уровень обратных вызовов. Например, у меня есть (временный) API для создания группы тестовых данных по адресу URL / api / create-test-data. Я хочу создать 100 новых объектов Викторины в базе данных, а затем, как только они все будут созданы, вернуть список всех объектов Викторины в базе данных. Вот код:

var Quiz, schema, sendJSON;

schema = require("../schema");

Quiz = schema.Quiz;

sendJSON = function(res, json) {
  res.contentType("text/json");
  return res.send(JSON.stringify(json));
};

module.exports = function(app) {
  app.get("/api/quizzes", function(req, res) {
    return Quiz.find({}, function(err, docs) {
      if (err) throw err;
      return sendJSON(res, docs);
    });
  });
  app["delete"]("/api/quizzes/:id", function(req, res) {
    console.log("Deleting quiz " + req.params.id);
    return Quiz.remove({
      _id: req.params.id
    }, function(err) {
      if (err) throw err;
      return sendJSON(res, {
        result: "ok"
      });
    });
  });
  return app.get("/api/create-test-data", function(req, res) {
    var i, keepCount, remaining, _results;
    remaining = 100;
    keepCount = function(err) {
      if (err) throw err;
      remaining--;
      if (remaining === 0) {
        return Quiz.find({}, function(err, docs) {
          if (err) throw err;
          return sendJSON(res, docs);
        });
      }
    };
    _results = [];
    for (i = 1; 1 <= remaining ? i <= remaining : i >= remaining; 1 <= remaining ? i++ : i--) {
      _results.push(new Quiz({
        title: "Test Quiz \# " + i
      }).save(keepCount));
    }
    return _results;
  });
};

Должно быть довольно легко выбрать логику для создания тестовых данных в конце. Это обычный Node JavaScript, но если он выглядит немного странно, то это потому, что он на самом деле декомпилирован CoffeeScript . Для меня первое правило кодирования Node — это всегда код в CoffeeScript ! В первоначальном виде вложенность колбэков более приятна:

# Exports a single function that is passed the application object, to configure
# its routes

schema = require "../schema"
Quiz = schema.Quiz

sendJSON = (res, json) ->
  res.contentType "text/json"
  # TODO: It would be cool to prettify this in development mode
  res.send JSON.stringify(json)

module.exports = (app) ->

  app.get "/api/quizzes",
    (req, res) ->
      Quiz.find {}, (err, docs) ->
        throw err if err
        sendJSON res, docs

  app.delete "/api/quizzes/:id",
    (req, res) ->
      console.log "Deleting quiz #{req.params.id}"
      # very dangerous! Need to add some permissions checking
      Quiz.remove { _id: req.params.id }, (err) ->
        throw err if err
        sendJSON res, { result: "ok" }

  app.get "/api/create-test-data",
    (req, res) ->
      remaining = 100

      keepCount = (err) ->
        throw err if err
        remaining--

        if (remaining == 0)
          Quiz.find {}, (err, docs) ->
            throw err if err
            sendJSON res, docs

      for i in [1..remaining]
        new Quiz(title: "Test Quiz \# #{i}").save keepCount

У вас есть счетчик, оставшиеся и один обратный вызов, который вызывается для каждого сохраненного объекта Викторины. Когда это число достигает нуля (мы ожидаем, что каждый обратный вызов будет вызван только один раз), можно безопасно выполнить запрос к базе данных и при обратном вызове из этого запроса отправить окончательный ответ. Обратите внимание на немного странную структуру, где мы склонны сначала определять последний шаг (выполнение последнего запроса и отправку ответа), а затем слой поверх того кода, который выполняет работу по добавлению объектов Quiz, с обратным вызовом, который выясняет, когда все объекты были созданы.

CoffeeScript делает это немного проще для понимания, но между упорядочением кода и тремя уровнями обратных вызовов он далек от совершенства, поэтому я подумал, что придумаю простое решение для более разумного управления вещами. Обратите внимание, что я на 100% уверен, что эта проблема уже решалась любым числом разработчиков ранее … Я использую предлог для освоения Node и CoffeeScript в качестве предлога для использования некоторого синдрома Not Invented Here. Вот мой первый проход:

event = require "events"
_ = require "underscore"

# Helps to organize callbacks.  At this time, it breaks normal
# conventions and makes not attempt to catch errors or fire an 'error'
# event.
class Flow extends event.EventEmitter

  constructor: ->
    @count = 0
    # Array of zero-arg functions that invoke join callbacks
    @joins = []

  invokeJoins: ->
      # The join callbacks may add further callbacks or further join
      # callbacks, but that only affects future completions.
      joins = @joins
      @joins = []
      join.call(null) for join in joins
      @emit 'join', this

  checkForJoin: ->
    @invokeJoins() if --@count == 0

  # Adds a callaback and returns a function that will invoke the
  # callback. Adding a callback increases the count. The count is
  # decreased after the callback is invoked.  Callbacks are invoked
  # with this set to null.  Join callbacks are invoked when the count
  # reaches zero. Callbacks should be added before join callbacks are
  # added.
  add: (callback) ->

    # One more callback until we can invoke join callbacks
    @count++

    (args...) =>
      callback.apply null, args...

      @checkForJoin()

  # Adds a join callback, which will be invoked after all previously
  # added callbacks have been invoked. Join callbacks are invoked with
  # this set to null and no arguments. Emits a 'join' event, passing
  # this Flow, after invoking any explicitly added join callbacks.
  # Invokes the callback immediately if there are no outstanding
  # callbacks.
  join: (callback) ->

    @joins.push callback

    @invokeJoins() if @count == 0

  # TODO:
  # sub flows (for executing related tasks in parallel)

module.exports = Flow

Объект Flow — это своего рода фабрика для оболочек обратного вызова; Вы передаете ему обратный вызов, и он возвращает новый обратный вызов, который вы можете передать другим API. Как только все добавленные обратные вызовы были вызваны, обратные вызовы присоединения вызываются после того, как были вызваны все другие обратные вызовы. Другими словами, обратные вызовы вызываются параллельно (ну, по крайней мере, в произвольном порядке), и обратный вызов соединения вызывается только после того, как были вызваны все другие обратные вызовы.

На практике это немного упрощает код:

  app.get "/api/create-test-data",
    (req, res) ->

      flow = new Flow
      for i in [1..100]
        quiz = new Quiz
          title: "Test Quiz \# #{i}"
          location: "Undisclosed"

        quiz.save flow.add (err) ->
          throw err if err

      flow.join ->
        Quiz.find {}, (err, docs) ->
          throw err if err
          sendJSON res, docs

Поэтому вместо quiz.save (err) -> … он становится quiz.save flow.add (err) -> … или в прямом JavaScript: quiz.save (flow.add (function (err) {. ..})).

Так что все весело; Я на самом деле наслаждаюсь Node и CoffeeScript по крайней мере так же, как и Clojure; и это хорошо, потому что прошли годы (если вообще когда-либо) с тех пор, как я наслаждался настоящим кодированием на Java (хотя мне, конечно, нравились результаты моего кодирования).