在处理远程数据时 JavaScript 抽象的重要性

Avatar of Kaloyan Kosev
Kaloyan Kosev 发布

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

最近我体验了审查一个项目并评估其可扩展性和可维护性的过程。这里有一些不好的实践,一些奇怪的代码片段缺少有意义的注释。对于一个相对较大的(遗留)代码库来说,这并不罕见,对吧?

但是,我一直在发现一些东西。一种在整个代码库和其他一些我查看过的项目中重复出现的模式。它们都可以概括为 **缺乏抽象**。最终,这是导致维护困难的原因。

在面向对象编程中,**抽象**是四个核心原则之一(连同 **封装**、**继承** 和 **多态**)。抽象之所以有价值,有两个主要原因

  • 抽象隐藏某些细节,只显示对象的必要特征。它试图减少和提取细节,以便开发人员可以一次专注于几个概念。这种方法提高了代码的可理解性和可维护性。
  • 抽象有助于我们减少代码重复。抽象提供了处理横切关注点的方法,并使我们能够避免紧耦合代码。

缺乏抽象不可避免地会导致可维护性问题。

我经常看到同事想要更进一步,编写更易维护的代码,但他们难以确定和实现基本的抽象。因此,在本文中,我将分享一些我用于 Web 世界中最常见的事情的实用抽象:处理远程数据。

需要提及的是,就像 JavaScript 世界中的所有事物一样,有无数种方法和不同的方法来实现类似的概念。我将分享我的方法,但您可以根据自己的需求对其进行升级或调整。或者更好的是 - 改进它并在下面的评论中分享!❤️

API 抽象

我已经有一段时间没有遇到不使用外部 API 来接收和发送数据的项目了。这通常是我定义的第一个和最基本的抽象之一。我尝试在那里存储尽可能多的与 API 相关的配置和设置,例如

  • API 基本 URL
  • 请求头
  • 全局错误处理逻辑
    const API = {
      /**
       * Simple service for generating different HTTP codes. Useful for
       * testing how your own scripts deal with varying responses.
       */
      url: 'http://httpstat.us/',
    
      /**
       * fetch() will only reject a promise if the user is offline,
       * or some unlikely networking error occurs, such a DNS lookup failure.
       * However, there is a simple `ok` flag that indicates
       * whether an HTTP response's status code is in the successful range.
       */
      _handleError(_res) {
          return _res.ok ? _res : Promise.reject(_res.statusText);
      },
    
      /**
       * Get abstraction.
       * @return {Promise}
       */
      get(_endpoint) {
          return window.fetch(this.url + _endpoint, {
              method: 'GET',
              headers: new Headers({
                  'Accept': 'application/json'
              })
          })
          .then(this._handleError)
          .catch( error => { throw new Error(error) });
      },
    
      /**
       * Post abstraction.
       * @return {Promise}
       */
      post(_endpoint, _body) {
          return window.fetch(this.url + _endpoint, {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              body: _body,
    
          })
          .then(this._handleError)
          .catch( error => { throw new Error(error) });
      }
    };

在这个模块中,我们有两个公共方法,get()post(),它们都返回一个 Promise。在所有需要处理远程数据的地方,我们使用我们的 API 模块抽象 - API.get()API.post(),而不是直接通过 window.fetch() 调用 Fetch API。

因此,Fetch API **不与我们的代码紧密耦合**。

假设在以后我们阅读了 Zell Liew 关于使用 Fetch 的全面 总结,并且我们意识到我们的错误处理并不是真正的高级,比如它可以是。在进一步处理我们的逻辑之前,我们希望检查内容类型。没问题。我们只修改我们的 API 模块,我们到处使用的公共方法 API.get()API.post() 工作得很好。

const API = {
    /* ...  */

    /**
     * Check whether the content type is correct before you process it further.
     */
    _handleContentType(_response) {
        const contentType = _response.headers.get('content-type');

        if (contentType && contentType.includes('application/json')) {
            return _response.json();
        }

        return Promise.reject('Oops, we haven\'t got JSON!');
    },

    get(_endpoint) {
        return window.fetch(this.url + _endpoint, {
            method: 'GET',
            headers: new Headers({
                'Accept': 'application/json'
            })
        })
        .then(this._handleError)
        .then(this._handleContentType)
        .catch( error => { throw new Error(error) })
    },

    post(_endpoint, _body) {
        return window.fetch(this.url + _endpoint, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: _body
        })
        .then(this._handleError)
        .then(this._handleContentType)
        .catch( error => { throw new Error(error) })
    }
};

假设我们决定切换到 zlFetch,Zell 引入的抽象化响应处理的库(这样你就可以跳过并处理你的数据和错误,而不用担心响应)。**只要我们的公共方法返回一个 Promise,就没有问题:**

import zlFetch from 'zl-fetch';

const API = {
    /* ...  */

    /**
     * Get abstraction.
     * @return {Promise}
     */
    get(_endpoint) {
        return zlFetch(this.url + _endpoint, {
            method: 'GET'
        })
        .catch( error => { throw new Error(error) })
    },

    /**
     * Post abstraction.
     * @return {Promise}
     */
    post(_endpoint, _body) {
        return zlFetch(this.url + _endpoint, {
            method: 'post',
            body: _body
        })
        .catch( error => { throw new Error(error) });
    }
};

假设由于任何原因,我们决定在将来切换到 jQuery Ajax 来处理远程数据。再次不是什么大问题,只要我们的公共方法返回一个 Promise。从 jQuery 1.5 开始,$.ajax() 返回的 jqXHR 对象实现了 Promise 接口,赋予它们 Promise 的所有属性、方法和行为。

const API = {
    /* ...  */

    /**
     * Get abstraction.
     * @return {Promise}
     */
    get(_endpoint) {
        return $.ajax({
            method: 'GET',
            url: this.url + _endpoint
        });
    },

    /**
     * Post abstraction.
     * @return {Promise}
     */
    post(_endpoint, _body) {
        return $.ajax({
            method: 'POST',
            url: this.url + _endpoint,
            data: _body
        });
    }
};

但即使 jQuery 的 $.ajax() 没有返回 Promise,你也可以始终 将任何内容包装在一个新的 Promise() 中。一切顺利。可维护性++!

现在让我们抽象化接收和本地存储数据。

数据仓库

假设我们需要获取当前天气。API 返回给我们温度、体感温度、风速(m/s)、气压(hPa)和湿度(%)。一个常见的模式是,为了使 JSON 响应尽可能精简,属性被压缩到第一个字母。所以我们从服务器接收到的内容如下

{
    "t": 30,
    "f": 32,
    "w": 6.7,
    "p": 1012,
    "h": 38
}

我们可以继续在需要的地方使用 API.get('weather').tAPI.get('weather').w,但这在语义上看起来不太好。我不喜欢这种一个字母且上下文不明显的命名方式。

此外,假设我们不使用湿度(h)和体感温度(f)。我们不需要它们。实际上,服务器可能会返回很多其他信息,但我们可能只想使用几个参数。不限制我们的天气模块实际需要(存储)的内容可能会导致很大的开销。

引入仓库式模式抽象!

import API from './api.js'; // Import it into your code however you like

const WeatherRepository = {
    _normalizeData(currentWeather) {
        // Take only what our app needs and nothing more.
        const { t, w, p } = currentWeather;

        return {
            temperature: t,
            windspeed: w,
            pressure: p
        };
    },

    /**
     * Get current weather.
     * @return {Promise}
     */
    get(){
        return API.get('/weather')
            .then(this._normalizeData);
    }
}

现在在整个代码库中使用 WeatherRepository.get() 并访问有意义的属性,如 .temperature.windspeed。更好!

此外,通过 _normalizeData(),我们只公开我们需要的参数。

还有一个很大的好处。想象一下,我们需要将我们的应用程序与另一个天气 API 连接起来。令人惊讶的是,这个 API 的响应属性名称不同

{
    "temp": 30,
    "feels": 32,
    "wind": 6.7,
    "press": 1012,
    "hum": 38
}

不用担心!拥有我们的 WeatherRepository 抽象,我们只需要调整 _normalizeData() 方法即可!不是单个其他模块(或文件)。

const WeatherRepository = {
    _normalizeData(currentWeather) {
        // Take only what our app needs and nothing more.
        const { temp, wind, press } = currentWeather;

        return {
            temperature: temp,
            windspeed: wind,
            pressure: press
        };
    },

    /* ...  */
};

API 响应对象的属性名称 **不与我们的代码库紧密耦合**。可维护性++!

在以后,假设我们希望在当前获取的数据不超过 15 分钟时显示缓存的天气信息。因此,我们选择使用 localStorage 存储天气信息,而不是每次引用 WeatherRepository.get() 时都进行实际的网络请求并调用 API。

只要 WeatherRepository.get() 返回一个 Promise,我们就无需更改任何其他模块中的实现。所有想要访问当前天气的其他模块都不(也不应该)关心数据是如何检索的 - 是否来自本地存储、来自 API 请求、通过 Fetch API 或通过 jQuery 的 $.ajax()。这无关紧要。它们只关心以它们实现的“约定”格式接收它 - 一个包装实际天气数据的 Promise。

因此,我们引入了两个“私有”方法 _isDataUpToDate() - 用于检查我们的数据是否超过 15 分钟 - 和 _storeData() 用于简单地将我们的数据存储在浏览器存储中。

const WeatherRepository = {
    /* ...  */

    /**
     * Checks weather the data is up to date or not.
     * @return {Boolean}
     */
    _isDataUpToDate(_localStore) {
        const isDataMissing =
            _localStore === null || Object.keys(_localStore.data).length === 0;

        if (isDataMissing) {
            return false;
        }

        const { lastFetched } = _localStore;
        const outOfDateAfter = 15 * 1000; // 15 minutes

        const isDataUpToDate =
            (new Date().valueOf() - lastFetched) < outOfDateAfter;

        return isDataUpToDate;
    },

    _storeData(_weather) {
        window.localStorage.setItem('weather', JSON.stringify({
            lastFetched: new Date().valueOf(),
            data: _weather
        }));
    },

    /**
     * Get current weather.
     * @return {Promise}
     */
    get(){
        const localData = JSON.parse( window.localStorage.getItem('weather') );

        if (this._isDataUpToDate(localData)) {
            return new Promise(_resolve => _resolve(localData));
        }

        return API.get('/weather')
            .then(this._normalizeData)
            .then(this._storeData);
    }
};

最后,我们调整 get() 方法:如果天气数据是最新的,我们将其包装在一个 Promise 中并返回它。否则 - 我们发出 API 调用。太棒了!

可能还有其他用例,但我希望你理解了这个想法。如果更改只需要调整一个模块 - 那太棒了!您以一种可维护的方式设计了实现!

如果您决定使用这种仓库式模式,您可能会注意到它会导致一些代码和逻辑重复,因为您在项目中定义的所有数据仓库(实体)可能都会有像 _isDataUpToDate()_normalizeData()_storeData() 等等这样的方法……

由于我在我的项目中大量使用它,我决定围绕这种模式创建一个库,它完全按照我在本文中描述的那样,甚至更多!

介绍 SuperRepo

SuperRepo 是一个帮助您实现客户端数据处理和存储最佳实践的库。

/**
 * 1. Define where you want to store the data,
 *    in this example, in the LocalStorage.
 *
 * 2. Then - define a name of your data repository,
 *    it's used for the LocalStorage key.
 *
 * 3. Define when the data will get out of date.
 *
 * 4. Finally, define your data model, set custom attribute name
 *    for each response item, like we did above with `_normalizeData()`.
 *    In the example, server returns the params 't', 'w', 'p',
 *    we map them to 'temperature', 'windspeed', and 'pressure' instead.
 */
const WeatherRepository = new SuperRepo({
  storage: 'LOCAL_STORAGE',                // [1]
  name: 'weather',                         // [2]
  outOfDateAfter: 5 * 60 * 1000, // 5 min  // [3]
  request: () => API.get('weather'),       // Function that returns a Promise
  dataModel: {                             // [4]
      temperature: 't',
      windspeed: 'w',
      pressure: 'p'
  }
});

/**
 * From here on, you can use the `.getData()` method to access your data.
 * It will first check if out data outdated (based on the `outOfDateAfter`).
 * If so - it will do a server request to get fresh data,
 * otherwise - it will get it from the cache (Local Storage).
 */
WeatherRepository.getData().then( data => {
    // Do something awesome.
    console.log(`It is ${data.temperature} degrees`);
});

该库执行我们之前实现的操作

  • 从服务器获取数据(如果我们的数据丢失或已过期)或从缓存中获取数据。
  • 就像我们使用 _normalizeData() 一样,dataModel 选项将映射应用于我们的原始数据。这意味着
    • 在整个代码库中,我们将访问有意义和语义化的属性,例如
    • .temperature.windspeed 而不是 .t.s
    • 仅公开您需要的参数,并且简单地不包含任何其他参数。
    • 如果响应属性名称更改(或您需要连接具有不同响应结构的其他 API),您只需要在此处调整 - 在代码库的唯一 1 个位置。

此外,还有一些额外的改进

  • 性能:如果从应用程序的不同部分多次调用 WeatherRepository.getData(),则只会触发 1 个服务器请求。
  • 可扩展性
    • 您可以将数据存储在 localStorage 中、浏览器存储中(如果您正在构建浏览器扩展)或本地变量中(如果您不想跨浏览器会话存储数据)。请参阅 storage 设置 的选项。
    • 您可以使用 WeatherRepository.initSyncer() 启动自动数据同步。这将启动一个 setInterval,它将倒计时到数据过期的时间点(基于 outOfDateAfter 值),并触发服务器请求以获取最新数据。太棒了。

要使用 SuperRepo,请使用 NPM 或 Bower 安装(或简单下载)它。

npm install --save super-repo

然后,通过三种可用方法之一将其导入您的代码。

  • 静态 HTML
    <script src="/node_modules/super-repo/src/index.js"></script>
  • 使用 ES6 导入
    // If transpiler is configured (Traceur Compiler, Babel, Rollup, Webpack)
    import SuperRepo from 'super-repo';
  • …或使用 CommonJS 导入
    // If module loader is configured (RequireJS, Browserify, Neuter)
    const SuperRepo = require('super-repo');

最后,定义您的 SuperRepo 存储库 :)

有关高级用法,请阅读我编写的文档。包含示例!

摘要

我上面描述的抽象可以成为应用程序架构和软件设计的基石之一。随着您经验的增长,尝试思考并在处理远程数据时以及在其他有意义的情况下应用类似的概念。

在实现功能时,始终尝试与您的团队讨论更改弹性、可维护性和可扩展性。未来的您会感谢您的!