如何使用结果-错误模式简化异步的JavaScript

106 阅读4分钟

在过去18年的编程生涯中,我几乎在每个项目中都要处理异步行为。

自从在JavaScript中采用async-await后,我们了解到async-await使很多代码更令人愉快,更容易推理。

最近我注意到,当我在处理一个需要异步连接和断开的资源时,我最终会写出这样的代码:

// NOT MY FAVORITE PATTERN
router.get('/users/:id', async (req, res) => {
  const client = new Client();
  let user;
  try {
    await client.connect();
    user = await client.find('users').where('id', req.path.id);
  } catch(error) {
    res.status(500);
    user = { error };
  } finally {
    await client.close();
  }
  res.json(user);
});

它变得冗长,因为我们必须使用try/catch来处理错误。

这种资源的例子包括数据库、ElasticSearch、命令行和ssh。 在这些用例中,我已经确定了一种代码模式,我称之为结果-错误模式。

考虑把上面的代码改写成这样:

// I LIKE THIS PATTERN BETTER
router.get('/users/:id', async (req, res) => {
  const { result: user, error } = await withDbClient(client => {
    return client.find('users').where('id', req.path.id);
  });
  if (error) {
    res.status(500);
  }
  res.json({ user, error });
});

注意一些事情:

  1. 数据库客户端为我们创建,我们的回调可以直接利用它。
  2. 我们没有在try-catch块中捕获错误,而是依靠withDbClient 来返回错误。
  3. 结果总是被称为result ,因为我们的回调可以返回任何类型的数据。
  4. 我们不需要关闭资源。

那么,withDbClient 是做什么的呢?

  1. 它处理创建资源、连接和关闭。
  2. 它处理 try、catch 和 finally。
  3. 它确保从withDbClient ,不会有未捕获的异常被抛出。
  4. 它确保在处理程序中抛出的任何异常也会在withDbClient 中被捕获。
  5. 它确保{ result, error } 总是被返回。

下面是一个实现的例子:

// EXAMPLE IMPLEMENTATION
async function withDbClient(handler) {
  const client = new DbClient();
  let result = null;
  let error = null;
  try {
    await client.connect();
    result = await handler(client);
  } catch (e) {
    error = e;
  } finally {
    await client.close();
  }
  return { result, error };
}

更进一步

一个不需要被关闭的资源怎么办?好吧,结果错误模式仍然可以很好地解决这个问题。

考虑到以下对fetch 的使用:

// THIS IS NICE AND SHORT
const { data, error, response } = await fetchJson('/users/123');

它的实现可能是这样的:

// EXAMPLE IMPLEMENTATION
async function fetchJson(...args) {
  let data = null;
  let error = null;
  let response = null;
  try {
    const response = await fetch(...args);
    if (response.ok) {
      try {
        data = await response.json();
      } catch (e) {
        // not json
      }
    } else {
      // note that statusText is always "" in HTTP2
      error = `${response.status} ${response.statusText}`;
    }
  } catch(e) {
    error = e;  
  }
  return { data, error, response };
}

更高层次的使用

我们不必止步于低级别的使用。那其他可能以结果或错误结束的函数呢?

最近,我写了一个有大量ElasticSearch交互的应用程序。我决定在更高层次的函数上也使用结果-错误模式。

例如,搜索帖子会产生一个ElasticSearch文档的数组,并像这样返回结果和错误:

const { result, error, details } = await findPosts(query);

如果你使用过ElasticSearch,你会知道响应是冗长的,数据在响应中被嵌套了好几层。这里,result 是一个包含的对象:

  1. records - 一个文档的数组
  2. total - 如果没有应用限制,文档的总数量
  3. aggregations - ElasticSearch的分面搜索信息

正如你可能猜到的,error 可能是一个错误信息,而details 是完整的 ElasticSearch 响应,以防你需要错误元数据、亮点或查询时间等东西。

我用一个查询对象搜索ElasticSearch的实现是这样的:

// Fetch from the given index name with the given query
async function query(index, query) {
  // Our Result-Error Pattern at the low level  
  const { result, error } = await withEsClient(client => {
    return client.search({
      index,
      body: query.getQuery(),
    });
  });
  // Returning a similar object also with result-error
  return {
    result: formatRecords(result),
    error,
    details: result || error?.meta,
  };
}
    
// Extract records from responses 
function formatRecords(result) {
  // Notice how deep ElasticSearch buries results?
  if (result?.body?.hits?.hits) {
    const records = [];
    for (const hit of result.body.hits.hits) {
      records.push(hit._source);
    }
    return {
      records,
      total: result.body.hits.total?.value || 0,
      aggregations: result.aggregations,
    };
  } else {
    return { records: [], total: null, aggregations: null };
  }
}    

然后,findPosts 函数就变成了这样一个简单的东西:

function findPosts(query) {
  return query('posts', query);
}

总结

以下是实现结果-错误模式的函数的关键方面:

  1. 不要抛出异常。
  2. 总是返回一个带有结果和错误的对象,其中一个可能为空。
  3. 隐藏任何异步的资源创建或清理。

以下是调用实现结果-错误模式的函数的相应好处:

  1. 你不需要使用try-catch块。
  2. 处理错误情况就像if (error)
  3. 你不需要担心设置或清理的操作。

不要相信我的话,自己试试吧