mustache_templates-8.x-1.0-beta4/src/MustacheHttp.php

src/MustacheHttp.php
<?php

namespace Drupal\mustache;

use Drupal\Core\Cache\BackendChain;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Http\ClientFactory;
use Drupal\Core\Logger\LoggerChannelInterface;
use Drupal\Core\Render\BubbleableMetadata;
use Drupal\Core\Url;
use Drupal\mustache\Exception\MustacheException;
use GuzzleHttp\Exception\GuzzleException;
use Psr\Http\Message\ResponseInterface;

/**
 * Handling and caching of HTTP traffic to retrieve data for Mustache templates.
 */
class MustacheHttp {

  /**
   * The minimum threshold of time in seconds retrieved data should be cached.
   *
   * @var int
   */
  protected $ttlMin;

  /**
   * The maximum threshold of time in seconds retrieved data should be cached.
   *
   * @var int
   */
  protected $ttlMax;

  /**
   * A volatility value that is a range around the given TTL minimum value.
   *
   * @var int
   */
  protected $volatility;

  /**
   * The HTTP client.
   *
   * @var \GuzzleHttp\Client
   */
  protected $client;

  /**
   * The cache backend that is an in-memory cache for Mustache data.
   *
   * @var \Drupal\Core\Cache\CacheBackendInterface
   */
  protected $memoryCache;

  /**
   * The consistent cache backend for Mustache data.
   *
   * @var \Drupal\Core\Cache\CacheBackendInterface
   */
  protected $consistentCache;

  /**
   * The chained cache backends for Mustache data.
   *
   * @var \Drupal\Core\Cache\CacheBackendInterface
   */
  protected $chainedCache;

  /**
   * The logger channel.
   *
   * @var \Drupal\Core\Logger\LoggerChannelInterface
   */
  protected $logger;

  /**
   * A list of lock wait counts.
   *
   * @var array
   */
  protected $lockWait = [];

  /**
   * The MustacheHttp constructor.
   *
   * @param int $cache_ttl_min
   *   The minimum threshold of time in seconds retrieved data should be cached.
   *   Setting the value to CacheBackendInterface::CACHE_PERMANENT means caching
   *   forever, regardless of the TTL submitted by the response. Setting to 0
   *   means caching in accordance to the submitted TTL of the response.
   * @param int $cache_ttl_max
   *   The maximum threshold of time in seconds retrieved data should be cached.
   *   Setting the value to CacheBackendInterface::CACHE_PERMANENT means caching
   *   in accordance to the submitted TTL of the response, also possibly caching
   *   forever if the response says it's cacheable and does not (correctly)
   *   specify when it expires. Setting to 0 means no caching at all.
   * @param int $cache_volatility
   *   A volatility value that is used as a range around the calculated expiry.
   *   Without using a volatility, cache items might expire all at once and
   *   would possibly result in a request flood. This value is meant for trying
   *   to reduce that risk. Set to 0 to not use any volatility.
   * @param \Drupal\Core\Http\ClientFactory $client_factory
   *   The HTTP client factory.
   * @param \Drupal\Core\Cache\CacheBackendInterface $memory_cache
   *   The cache backend that is an in-memory cache for Mustache data.
   * @param \Drupal\Core\Cache\CacheBackendInterface $consistent_cache
   *   The consistent cache backend for Mustache data.
   * @param \Drupal\Core\Logger\LoggerChannelInterface $logger_channel
   *   The logger channel.
   */
  public function __construct($cache_ttl_min, $cache_ttl_max, $cache_volatility, ClientFactory $client_factory, CacheBackendInterface $memory_cache, CacheBackendInterface $consistent_cache, LoggerChannelInterface $logger_channel) {
    $this->ttlMin = $cache_ttl_min < 0 ? CacheBackendInterface::CACHE_PERMANENT : (int) $cache_ttl_min;
    $this->ttlMax = $cache_ttl_max < 0 ? CacheBackendInterface::CACHE_PERMANENT : (int) $cache_ttl_max;
    $this->volatility = (int) $cache_volatility;
    $client_config = [
      'headers' => [
        'User-Agent' => 'Drupal/' . \Drupal::VERSION . ' (+https://www.drupal.org/) ' . \GuzzleHttp\default_user_agent() . ' Mustache/2',
      ],
    ];
    $this->client = $client_factory->fromOptions($client_config);
    $this->memoryCache = $memory_cache;
    $this->consistentCache = $consistent_cache;
    $this->chainedCache = (new BackendChain())
      ->appendBackend($memory_cache)
      ->appendBackend($consistent_cache);
    $this->logger = $logger_channel;
  }

  /**
   * Get data from the given URL.
   *
   * @param \Drupal\Core\Url|string $url
   *   The URL to get the data from.
   * @param \Drupal\Core\Render\BubbleableMetadata|null $bubbleable_metadata
   *   (optional) The bubbleable metadata for attaching cacheability metadata.
   * @param bool $throw_exception
   *   Set to TRUE when an exception should be thrown on errors.
   * @param bool $use_cache
   *   Set to FALSE to bypass caching and always fire up a request instead.
   *
   * @return array|false
   *   Either the decoded data as associative array, or FALSE on failure.
   */
  public function getData($url, BubbleableMetadata $bubbleable_metadata = NULL, $throw_exception = FALSE, $use_cache = TRUE) {
    if (!($url instanceof Url) && !is_string($url)) {
      throw new MustacheException(t('The $url param must be a Url object or string.'));
    }

    $uri = $url instanceof Url ? (clone $url)->setAbsolute(TRUE)->toString() : $url;
    if (!isset($bubbleable_metadata)) {
      $bubbleable_metadata = new BubbleableMetadata();
    }

    if ($data = $this->readCache($use_cache, $uri, $bubbleable_metadata)) {
      if (['__m_error' => TRUE] == $data) {
        $this->logger->error(t('The endpoint @url seems to be faulty. Please see previous logged error messages for more information. This error message would disappear on its own, in case the endpoint has recovered.',
        ['@url' => $uri]));
        return FALSE;
      }
      return $data;
    }

    $body = NULL;
    $data = NULL;

    $lock = \Drupal::lock();
    $lid = $this->buildLockId($uri);
    $lock_acquired = $lock->acquire($lid);
    if (!$lock_acquired) {
      if (!isset($this->lockWait[$lid])) {
        $this->lockWait[$lid] = 0;
      }
      sleep(1);
      $this->lockWait[$lid]++;
      if ($this->lockWait[$lid] > 5) {
        $this->logger->warning(t('Lock wait exceeded for endpoint @uri. Now trying to fetch data from it. This may cause more traffic than needed.', ['@url' => $uri]));
      }
      else {
        return $this->getData($url, $bubbleable_metadata, $throw_exception, $use_cache);
      }
    }
    unset($this->lockWait[$lid]);

    try {
      $response = $this->client->request('GET', $uri);
    }
    catch (GuzzleException $e) {
      $this->logger->error(t('Failed to fetch data from @url. Exception message: @message',
        ['@url' => $uri, '@message' => $e->getMessage()]));
      if ($throw_exception) {
        throw $e;
      }
      return $this->fallbackOnFailure($use_cache, $uri, $body, $data, $bubbleable_metadata);
    }

    if (!($response->getStatusCode() == 200)) {
      $message = t('Failed to fetch data from @url. Returned status code was: @status',
      ['@url' => $uri, '@status' => $response->getStatusCode()]);
      $this->logger->error($message);
      if ($throw_exception) {
        throw new MustacheException($message);
      }
      return $this->fallbackOnFailure($use_cache, $uri, $body, $data, $bubbleable_metadata, $response);
    }

    try {
      $body = trim($response->getBody()->getContents());
    }
    catch (\RuntimeException $e) {
      $this->logger->error(t('Failed to read response body contents from @url. Exception message: @message',
        ['@url' => $uri, '@message' => $e->getMessage()]));
      if ($throw_exception) {
        throw $e;
      }
      return $this->fallbackOnFailure($use_cache, $uri, $body, $data, $bubbleable_metadata, $response);
    }
    $data = $this->decode($body);
    if ($data === FALSE) {
      $message = t('Invalid Json data retrieved from url @url.', ['@url' => $uri]);
      $this->logger->error($message);
      if ($throw_exception) {
        throw new MustacheException($message);
      }
      return $this->fallbackOnFailure($use_cache, $uri, $body, $data, $bubbleable_metadata, $response);
    }

    if (is_array($data) && !isset($data['previous']) && !empty($data) && !static::arrayIsSequential($data)) {
      if ($previous = $this->readCache($use_cache, $uri, new BubbleableMetadata(), TRUE)) {
        if (isset($previous['previous'])) {
          $prev_prev = $previous['previous'];
          // Unset the "previous" key from the previously fetched data, in order
          // to avoid unlimited growth.
          unset($previous['previous']);
          if ($previous == $data) {
            // Previously fetched data appears to be the same, thus jump once
            // more back to previous data of the previously fetched one.
            unset($prev_prev['previous']);
            $previous = $prev_prev;
          }
        }
        $data['previous'] = $previous;
        // We trick a little bit here, telling ::writeCache that we originally
        // received JSON-encoded data, but now having the "previous" data key.
        $body = json_encode($data);
      }
    }
    $this->writeCache($use_cache, $uri, $body, $data, $bubbleable_metadata, $response);
    if ($lock_acquired) {
      $lock->release($lid);
    }

    return $data;
  }

  /**
   * Get the memory cache for Mustache data.
   *
   * @return \Drupal\Core\Cache\CacheBackendInterface
   *   The memory cache.
   */
  public function getDataMemoryCache() {
    return $this->memoryCache;
  }

  /**
   * Get the consistent cache for Mustache data.
   *
   * @return \Drupal\Core\Cache\CacheBackendInterface
   *   The consistent cache.
   */
  public function getDataConsistentCache() {
    return $this->consistentCache;
  }

  /**
   * Get the chained cache for Mustache data.
   *
   * @return \Drupal\Core\Cache\CacheBackendInterface
   *   The chained cache.
   */
  public function getDataChainedCache() {
    return $this->chainedCache;
  }

  /**
   * Implementation method of reading data from cache.
   *
   * @param bool $use_cache
   *   Whether caching should be used or not.
   * @param string &$uri
   *   The URI that is being used to perform the request.
   * @param \Drupal\Core\Render\BubbleableMetadata $bubbleable_metadata
   *   The bubbleable metadata for attaching cacheability metadata.
   * @param bool $allow_invalid
   *   (optional) If TRUE, a cache item may be returned even if it is expired or
   *   has been invalidated.
   *
   * @return array|false
   *   The data as array on successful cache hit, FALSE otherwise.
   */
  protected function readCache($use_cache, &$uri, BubbleableMetadata $bubbleable_metadata, $allow_invalid = FALSE) {
    if (!$use_cache) {
      return FALSE;
    }
    $cid = $this->buildCacheId($uri);
    // When TTL min is set to a permanent cache lifetime, it would mean that
    // one does not care about any TTLs from received responses. For that reason
    // we allow invalid items to be returned, as this may also save some lives.
    $allow_invalid = $allow_invalid || ($this->ttlMin == CacheBackendInterface::CACHE_PERMANENT);
    if ($cached = $this->chainedCache->get($cid, $allow_invalid)) {
      if ($cached->expire != CacheBackendInterface::CACHE_PERMANENT) {
        $request_time = \Drupal::time()->getRequestTime();
        $max_age = $cached->expire > $request_time ? $cached->expire - $request_time : 0;
        $max_age = $this->ttlMin > $max_age ? $this->ttlMin : $max_age;
        $bubbleable_metadata->setCacheMaxAge($max_age);
      }
      elseif (!$cached->valid) {
        $max_age = $this->ttlMin > 0 ? $this->ttlMin : 30;
        $bubbleable_metadata->setCacheMaxAge($max_age);
      }
      if (is_string($cached->data)) {
        // A string indicates that the contents of the response body was saved.
        // Thus we may try to convert it to a decoded array here, so that
        // subsequent calls within the same process don't need to decode again.
        $data = $this->decode($cached->data);
        $this->memoryCache->set($cid, $data, $cached->expire, $cached->tags);
        return $data;
      }
      return $cached->data;
    }
    return FALSE;
  }

  /**
   * Implementation method of writing data into the cache.
   *
   * @param bool $use_cache
   *   Whether caching should be used or not.
   * @param string $uri
   *   The URI that was used for performing the request.
   * @param string $body
   *   The extracted contents of the response body.
   * @param array &$data
   *   The data to cache.
   * @param \Drupal\Core\Render\BubbleableMetadata $bubbleable_metadata
   *   The bubbleable metadata for attaching cacheability metadata.
   * @param \Psr\Http\Message\ResponseInterface|null $response
   *   The received response object (if any).
   */
  protected function writeCache($use_cache, $uri, $body, array &$data, BubbleableMetadata $bubbleable_metadata, ResponseInterface $response = NULL) {
    if (!$use_cache || $this->ttlMax === 0) {
      return;
    }
    $expire = $this->ttlMin;
    $exclude = ['private', 'no-cache', 'no-store', 'must-revalidate'];
    $current_time = \Drupal::time()->getCurrentTime();
    // Read out TTL from response header (if available),
    // compare it with TTL max and set expiring time accordingly.
    if ($response && $this->ttlMin != CacheBackendInterface::CACHE_PERMANENT) {
      $values = [];
      foreach ($response->getHeader('Cache-Control') as $header) {
        foreach (explode(',', $header) as $value) {
          $value = mb_strtolower(trim($value));
          if (in_array($value, $exclude)) {
            $expire = $this->ttlMin;
            $values = [];
            break 2;
          }
          if ($value == 'public') {
            $expire = $this->ttlMax;
            continue;
          }
          $values[] = $value;
        }
      }
      foreach ($values as $value) {
        if (strpos($value, 'age=')) {
          $value = explode('=', $value);
          $value = intval(end($value));
          // Fetch the highest possible value.
          $expire = $expire < $value ? $value : $expire;
        }
      }
      if ($this->ttlMax != CacheBackendInterface::CACHE_PERMANENT && $expire > $this->ttlMax) {
        $expire = $this->ttlMax;
      }
      elseif ($expire >= PHP_INT_MAX - $current_time) {
        $expire = CacheBackendInterface::CACHE_PERMANENT;
      }
      elseif ($expire < 0 && $expire != CacheBackendInterface::CACHE_PERMANENT) {
        $expire = $this->ttlMin;
      }
    }
    if ($expire > 0 && $this->volatility > 0) {
      $vola = $this->volatility < $expire ? random_int(-$this->volatility, $this->volatility) : random_int(-$expire + 1, $expire - 1);
      $expire += $vola;
    }

    $cid = $this->buildCacheId($uri);
    $tags = ['mustache:http'];
    if ($expire === 0 && $this->ttlMax !== 0) {
      // At least store in memory cache, so that the current PHP process is not
      // getting busy on redundant request calls.
      $expire = $this->ttlMin > 0 ? $this->ttlMin : 30;
      $bubbleable_metadata->setCacheMaxAge($expire);
      // Convert from the relative time period to an absolute Unix timestamp.
      $expire += $current_time;
      $this->memoryCache->set($cid, $data, $expire, $tags);
      return;
    }
    elseif ($expire > 0) {
      $bubbleable_metadata->setCacheMaxAge($expire);
      // Convert from the relative time period to an absolute Unix timestamp.
      $expire += $current_time;
    }
    $this->memoryCache->set($cid, $data, $expire, $tags);
    // Write the response body as string into the consistent backend, in order
    // to avoid serialization and possibly dangerous unserialization of the
    // retrieved data. We cannot verdict the data's trustworthiness here.
    $this->consistentCache->set($cid, $body, $expire, $tags);
  }

  /**
   * Builds the cache ID.
   *
   * @param string $uri
   *   The URI that was or is being used for performing the request.
   *
   * @return string
   *   The cache ID.
   */
  protected function buildCacheId($uri) {
    return 'mustache:http:' . $uri;
  }

  /**
   * Builds the lock ID.
   *
   * @param string $uri
   *   The URI that was or is being used for performing the request.
   *
   * @return string
   *   The lock ID.
   */
  protected function buildLockId($uri) {
    return 'mustache:http:' . $uri;
  }

  /**
   * Decodes the contents of a response body.
   *
   * @param string $body
   *   The extracted contents of the response body.
   *
   * @return array|false
   *   The decoded data as associative array, or FALSE on failure.
   */
  protected function decode($body) {
    $data = json_decode($body, TRUE);
    if (!is_array($data)) {
      if ($data === NULL && ($body === 'null' || $body === 'NULL')) {
        $data = [];
      }
      elseif ($data !== NULL) {
        $data = [$data];
      }
      else {
        $data = FALSE;
      }
    }
    return $data;
  }

  /**
   * Implementation of a fallback behavior when something went wrong.
   *
   * @param bool $use_cache
   *   Whether caching should be used or not.
   * @param string $uri
   *   The URI that was used for performing the request.
   * @param mixed &$body
   *   The extracted contents of the response body (if any).
   * @param mixed &$data
   *   The decoded data (if any).
   * @param \Drupal\Core\Render\BubbleableMetadata $bubbleable_metadata
   *   The bubbleable metadata for attaching cacheability metadata.
   * @param \Psr\Http\Message\ResponseInterface|null $response
   *   The received response object (if any).
   *
   * @return array|false
   *   Either decoded data to be used as fallback value, or FALSE if nothing
   *   else could have helped as a fallback solution.
   */
  protected function fallbackOnFailure($use_cache, $uri, &$body, &$data, BubbleableMetadata $bubbleable_metadata, ResponseInterface $response = NULL) {
    if ($ttl_min = $this->ttlMin) {
      $this->ttlMin = 30;
    }
    // Allow to self-heal by setting max-age to 0 by default. Writing into the
    // cache would most probably raise the value, but it shouldn't be possible
    // to cache faulty and stale data indefinitely.
    $bubbleable_metadata->setCacheMaxAge(0);
    // As long as we still have something in our cache, use it and let's hope
    // that the endpoint will get well soon.
    if ($data = $this->readCache($use_cache, $uri, $bubbleable_metadata, TRUE)) {
      // We trick a little bit here, telling ::writeCache that we received
      // valid Json (but we didn't).
      $body = json_encode($data);
      $this->writeCache($use_cache, $uri, $body, $data, $bubbleable_metadata);
      $this->ttlMin = $ttl_min;
      return $data;
    }
    // Flag this operation for our own cache, so that we don't retry too often
    // within the next few seconds. Give the endpoint a chance to breathe.
    $error_data = ['__m_error' => TRUE];
    $this->writeCache($use_cache, $uri, json_encode($error_data), $error_data, $bubbleable_metadata);
    $this->ttlMin = $ttl_min;
    return FALSE;
  }

  /**
   * Checks whether the given array is sequential.
   *
   * @param array $array
   *   The array to check for.
   *
   * @return bool
   *   Returns TRUE when it is sequential, FALSE otherwise.
   */
  protected static function arrayIsSequential(array $array) {
    $i = 0;
    foreach (array_keys($array) as $key) {
      if ($key !== $i) {
        return FALSE;
      }
      $i++;
    }
    return TRUE;
  }

}

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc