Connection.php 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613
  1. <?php
  2. /**
  3. * @link http://www.yiiframework.com/
  4. * @copyright Copyright (c) 2008 Yii Software LLC
  5. * @license http://www.yiiframework.com/license/
  6. */
  7. namespace yii\elasticsearch;
  8. use Yii;
  9. use yii\base\Component;
  10. use yii\base\InvalidConfigException;
  11. use yii\base\InvalidArgumentException;
  12. use yii\helpers\Json;
  13. /**
  14. * Elasticsearch Connection is used to connect to an Elasticsearch cluster version 0.20 or higher
  15. *
  16. * @property-read string $driverName Name of the DB driver. This property is read-only.
  17. * @property-read bool $isActive Whether the DB connection is established. This property is read-only.
  18. * @property-read QueryBuilder $queryBuilder This property is read-only.
  19. *
  20. * @author Carsten Brandt <mail@cebe.cc>
  21. * @since 2.0
  22. */
  23. class Connection extends Component
  24. {
  25. /**
  26. * @event Event an event that is triggered after a DB connection is established
  27. */
  28. const EVENT_AFTER_OPEN = 'afterOpen';
  29. /**
  30. * @var boolean whether to autodetect available cluster nodes on [[open()]]
  31. */
  32. public $autodetectCluster = true;
  33. /**
  34. * @var array The Elasticsearch cluster nodes to connect to.
  35. *
  36. * This is populated with the result of a cluster nodes request when [[autodetectCluster]] is true.
  37. *
  38. * Additional special options:
  39. *
  40. * - `auth`: overrides [[auth]] property. For example:
  41. *
  42. * ```php
  43. * [
  44. * 'http_address' => 'inet[/127.0.0.1:9200]',
  45. * 'auth' => ['username' => 'yiiuser', 'password' => 'yiipw'], // Overrides the `auth` property of the class with specific login and password
  46. * //'auth' => ['username' => 'yiiuser', 'password' => 'yiipw'], // Disabled auth regardless of `auth` property of the class
  47. * ]
  48. * ```
  49. *
  50. * - `protocol`: explicitly sets the protocol for the current node (useful when manually defining a HTTPS cluster)
  51. *
  52. * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/cluster-nodes-info.html#cluster-nodes-info
  53. */
  54. public $nodes = [
  55. ['http_address' => 'inet[/127.0.0.1:9200]'],
  56. ];
  57. /**
  58. * @var string the active node. Key of one of the [[nodes]]. Will be randomly selected on [[open()]].
  59. */
  60. public $activeNode;
  61. /**
  62. * @var array Authentication data used to connect to the Elasticsearch node.
  63. *
  64. * Array elements:
  65. *
  66. * - `username`: the username for authentication.
  67. * - `password`: the password for authentication.
  68. *
  69. * Array either MUST contain both username and password on not contain any authentication credentials.
  70. * @see http://www.elasticsearch.org/guide/en/elasticsearch/client/php-api/current/_configuration.html#_example_configuring_http_basic_auth
  71. */
  72. public $auth = [];
  73. /**
  74. * Elasticsearch has no knowledge of protocol used to access its nodes. Specifically, cluster autodetection request
  75. * returns node hosts and ports, but not the protocols to access them. Therefore we need to specify a default protocol here,
  76. * which can be overridden for specific nodes in the [[nodes]] property.
  77. * If [[autodetectCluster]] is true, all nodes received from cluster will be set to use the protocol defined by [[defaultProtocol]]
  78. * @var string Default protocol to connect to nodes
  79. * @since 2.0.5
  80. */
  81. public $defaultProtocol = 'http';
  82. /**
  83. * @var float timeout to use for connecting to an Elasticsearch node.
  84. * This value will be used to configure the curl `CURLOPT_CONNECTTIMEOUT` option.
  85. * If not set, no explicit timeout will be set for curl.
  86. */
  87. public $connectionTimeout = null;
  88. /**
  89. * @var float timeout to use when reading the response from an Elasticsearch node.
  90. * This value will be used to configure the curl `CURLOPT_TIMEOUT` option.
  91. * If not set, no explicit timeout will be set for curl.
  92. */
  93. public $dataTimeout = null;
  94. /**
  95. * @var integer version of the domain-specific language to use with the server.
  96. * This must be set to the major version of the Elasticsearch server in use, e.g. `5` for Elasticsearch 5.x.x,
  97. * `6` for Elasticsearch 6.x.x, and `7` for Elasticsearch 7.x.x.
  98. */
  99. public $dslVersion = 5;
  100. /**
  101. * @var resource the curl instance returned by [curl_init()](http://php.net/manual/en/function.curl-init.php).
  102. */
  103. private $_curl;
  104. public function init()
  105. {
  106. foreach ($this->nodes as &$node) {
  107. if (!isset($node['http_address'])) {
  108. throw new InvalidConfigException('Elasticsearch node needs at least a http_address configured.');
  109. }
  110. if (!isset($node['protocol'])) {
  111. $node['protocol'] = $this->defaultProtocol;
  112. }
  113. if (!in_array($node['protocol'], ['http', 'https'])) {
  114. throw new InvalidConfigException('Valid node protocol settings are "http" and "https".');
  115. }
  116. }
  117. }
  118. /**
  119. * Closes the connection when this component is being serialized.
  120. * @return array
  121. */
  122. public function __sleep()
  123. {
  124. $this->close();
  125. return array_keys(get_object_vars($this));
  126. }
  127. /**
  128. * Returns a value indicating whether the DB connection is established.
  129. * @return bool whether the DB connection is established
  130. */
  131. public function getIsActive()
  132. {
  133. return $this->activeNode !== null;
  134. }
  135. /**
  136. * Establishes a DB connection.
  137. * It does nothing if a DB connection has already been established.
  138. * @throws Exception if connection fails
  139. */
  140. public function open()
  141. {
  142. if ($this->activeNode !== null) {
  143. return;
  144. }
  145. if (empty($this->nodes)) {
  146. throw new InvalidConfigException('Elasticsearch needs at least one node to operate.');
  147. }
  148. $this->_curl = curl_init();
  149. if ($this->autodetectCluster) {
  150. $this->populateNodes();
  151. }
  152. $this->selectActiveNode();
  153. Yii::trace('Opening connection to Elasticsearch. Nodes in cluster: ' . count($this->nodes)
  154. . ', active node: ' . $this->nodes[$this->activeNode]['http_address'], __CLASS__);
  155. $this->initConnection();
  156. }
  157. /**
  158. * Populates [[nodes]] with the result of a cluster nodes request.
  159. * @throws Exception if no active node(s) found
  160. * @since 2.0.4
  161. */
  162. protected function populateNodes()
  163. {
  164. $node = reset($this->nodes);
  165. $host = $node['http_address'];
  166. $protocol = isset($node['protocol']) ? $node['protocol'] : $this->defaultProtocol;
  167. if (strncmp($host, 'inet[/', 6) === 0) {
  168. $host = substr($host, 6, -1);
  169. }
  170. $response = $this->httpRequest('GET', "$protocol://$host/_nodes/_all/http");
  171. if (!empty($response['nodes'])) {
  172. $nodes = $response['nodes'];
  173. } else {
  174. $nodes = [];
  175. }
  176. foreach ($nodes as $key => &$node) {
  177. // Make sure that nodes have an 'http_address' property, which is not the case if you're using AWS
  178. // Elasticsearch service (at least as of Oct., 2015). - TO BE VERIFIED
  179. // Temporary workaround - simply ignore all invalid nodes
  180. if (!isset($node['http']['publish_address'])) {
  181. unset($nodes[$key]);
  182. }
  183. $node['http_address'] = $node['http']['publish_address'];
  184. // Protocol is not a standard ES node property, so we add it manually
  185. $node['protocol'] = $this->defaultProtocol;
  186. }
  187. if (!empty($nodes)) {
  188. $this->nodes = array_values($nodes);
  189. } else {
  190. curl_close($this->_curl);
  191. throw new Exception('Cluster autodetection did not find any active node. Make sure a GET /_nodes reguest on the hosts defined in the config returns the "http_address" field for each node.');
  192. }
  193. }
  194. /**
  195. * select active node randomly
  196. */
  197. protected function selectActiveNode()
  198. {
  199. $keys = array_keys($this->nodes);
  200. $this->activeNode = $keys[random_int(0, count($keys) - 1)];
  201. }
  202. /**
  203. * Closes the currently active DB connection.
  204. * It does nothing if the connection is already closed.
  205. */
  206. public function close()
  207. {
  208. if ($this->activeNode === null) {
  209. return;
  210. }
  211. Yii::trace('Closing connection to Elasticsearch. Active node was: '
  212. . $this->nodes[$this->activeNode]['http']['publish_address'], __CLASS__);
  213. $this->activeNode = null;
  214. if ($this->_curl) {
  215. curl_close($this->_curl);
  216. $this->_curl = null;
  217. }
  218. }
  219. /**
  220. * Initializes the DB connection.
  221. * This method is invoked right after the DB connection is established.
  222. * The default implementation triggers an [[EVENT_AFTER_OPEN]] event.
  223. */
  224. protected function initConnection()
  225. {
  226. $this->trigger(self::EVENT_AFTER_OPEN);
  227. }
  228. /**
  229. * Returns the name of the DB driver for the current [[dsn]].
  230. * @return string name of the DB driver
  231. */
  232. public function getDriverName()
  233. {
  234. return 'elasticsearch';
  235. }
  236. /**
  237. * Creates a command for execution.
  238. * @param array $config the configuration for the Command class
  239. * @return Command the DB command
  240. */
  241. public function createCommand($config = [])
  242. {
  243. $this->open();
  244. $config['db'] = $this;
  245. $command = new Command($config);
  246. return $command;
  247. }
  248. /**
  249. * Creates a bulk command for execution.
  250. * @param array $config the configuration for the [[BulkCommand]] class
  251. * @return BulkCommand the DB command
  252. * @since 2.0.5
  253. */
  254. public function createBulkCommand($config = [])
  255. {
  256. $this->open();
  257. $config['db'] = $this;
  258. $command = new BulkCommand($config);
  259. return $command;
  260. }
  261. /**
  262. * Creates new query builder instance
  263. * @return QueryBuilder
  264. */
  265. public function getQueryBuilder()
  266. {
  267. return new QueryBuilder($this);
  268. }
  269. /**
  270. * Performs GET HTTP request
  271. *
  272. * @param string|array $url URL
  273. * @param array $options URL options
  274. * @param string $body request body
  275. * @param bool $raw if response body contains JSON and should be decoded
  276. * @return mixed response
  277. * @throws Exception
  278. * @throws InvalidConfigException
  279. */
  280. public function get($url, $options = [], $body = null, $raw = false)
  281. {
  282. $this->open();
  283. return $this->httpRequest('GET', $this->createUrl($url, $options), $body, $raw);
  284. }
  285. /**
  286. * Performs HEAD HTTP request
  287. *
  288. * @param string|array $url URL
  289. * @param array $options URL options
  290. * @param string $body request body
  291. * @return mixed response
  292. * @throws Exception
  293. * @throws InvalidConfigException
  294. */
  295. public function head($url, $options = [], $body = null)
  296. {
  297. $this->open();
  298. return $this->httpRequest('HEAD', $this->createUrl($url, $options), $body);
  299. }
  300. /**
  301. * Performs POST HTTP request
  302. *
  303. * @param string|array $url URL
  304. * @param array $options URL options
  305. * @param string $body request body
  306. * @param bool $raw if response body contains JSON and should be decoded
  307. * @return mixed response
  308. * @throws Exception
  309. * @throws InvalidConfigException
  310. */
  311. public function post($url, $options = [], $body = null, $raw = false)
  312. {
  313. $this->open();
  314. return $this->httpRequest('POST', $this->createUrl($url, $options), $body, $raw);
  315. }
  316. /**
  317. * Performs PUT HTTP request
  318. *
  319. * @param string|array $url URL
  320. * @param array $options URL options
  321. * @param string $body request body
  322. * @param bool $raw if response body contains JSON and should be decoded
  323. * @return mixed response
  324. * @throws Exception
  325. * @throws InvalidConfigException
  326. */
  327. public function put($url, $options = [], $body = null, $raw = false)
  328. {
  329. $this->open();
  330. return $this->httpRequest('PUT', $this->createUrl($url, $options), $body, $raw);
  331. }
  332. /**
  333. * Performs DELETE HTTP request
  334. *
  335. * @param string|array $url URL
  336. * @param array $options URL options
  337. * @param string $body request body
  338. * @param bool $raw if response body contains JSON and should be decoded
  339. * @return mixed response
  340. * @throws Exception
  341. * @throws InvalidConfigException
  342. */
  343. public function delete($url, $options = [], $body = null, $raw = false)
  344. {
  345. $this->open();
  346. return $this->httpRequest('DELETE', $this->createUrl($url, $options), $body, $raw);
  347. }
  348. /**
  349. * Creates URL
  350. *
  351. * @param string|array $path path
  352. * @param array $options URL options
  353. * @return array
  354. */
  355. private function createUrl($path, $options = [])
  356. {
  357. if (!is_string($path)) {
  358. $url = implode('/', array_map(function ($a) {
  359. return urlencode(is_array($a) ? implode(',', $a) : $a);
  360. }, $path));
  361. if (!empty($options)) {
  362. $url .= '?' . http_build_query($options);
  363. }
  364. } else {
  365. $url = $path;
  366. if (!empty($options)) {
  367. $url .= (strpos($url, '?') === false ? '?' : '&') . http_build_query($options);
  368. }
  369. }
  370. $node = $this->nodes[$this->activeNode];
  371. $protocol = isset($node['protocol']) ? $node['protocol'] : $this->defaultProtocol;
  372. $host = $node['http_address'];
  373. return [$protocol, $host, $url];
  374. }
  375. /**
  376. * Performs HTTP request
  377. *
  378. * @param string $method method name
  379. * @param string $url URL
  380. * @param string $requestBody request body
  381. * @param bool $raw if response body contains JSON and should be decoded
  382. * @return mixed if request failed
  383. * @throws Exception if request failed
  384. * @throws InvalidConfigException
  385. */
  386. protected function httpRequest($method, $url, $requestBody = null, $raw = false)
  387. {
  388. $method = strtoupper($method);
  389. // response body and headers
  390. $headers = [];
  391. $headersFinished = false;
  392. $body = '';
  393. $options = [
  394. CURLOPT_USERAGENT => 'Yii Framework ' . Yii::getVersion() . ' ' . __CLASS__,
  395. CURLOPT_RETURNTRANSFER => false,
  396. CURLOPT_HEADER => false,
  397. // http://www.php.net/manual/en/function.curl-setopt.php#82418
  398. CURLOPT_HTTPHEADER => [
  399. 'Expect:',
  400. 'Content-Type: application/json',
  401. ],
  402. CURLOPT_WRITEFUNCTION => function ($curl, $data) use (&$body) {
  403. $body .= $data;
  404. return mb_strlen($data, '8bit');
  405. },
  406. CURLOPT_HEADERFUNCTION => function ($curl, $data) use (&$headers, &$headersFinished) {
  407. if ($data === '') {
  408. $headersFinished = true;
  409. } elseif ($headersFinished) {
  410. $headersFinished = false;
  411. }
  412. if (!$headersFinished && ($pos = strpos($data, ':')) !== false) {
  413. $headers[strtolower(substr($data, 0, $pos))] = trim(substr($data, $pos + 1));
  414. }
  415. return mb_strlen($data, '8bit');
  416. },
  417. CURLOPT_CUSTOMREQUEST => $method,
  418. CURLOPT_FORBID_REUSE => false,
  419. ];
  420. if (!empty($this->auth) || isset($this->nodes[$this->activeNode]['auth']) && $this->nodes[$this->activeNode]['auth'] !== false) {
  421. $auth = isset($this->nodes[$this->activeNode]['auth']) ? $this->nodes[$this->activeNode]['auth'] : $this->auth;
  422. if (empty($auth['username'])) {
  423. throw new InvalidConfigException('Username is required to use authentication');
  424. }
  425. if (empty($auth['password'])) {
  426. throw new InvalidConfigException('Password is required to use authentication');
  427. }
  428. $options[CURLOPT_HTTPAUTH] = CURLAUTH_BASIC;
  429. $options[CURLOPT_USERPWD] = $auth['username'] . ':' . $auth['password'];
  430. }
  431. if ($this->connectionTimeout !== null) {
  432. $options[CURLOPT_CONNECTTIMEOUT] = $this->connectionTimeout;
  433. }
  434. if ($this->dataTimeout !== null) {
  435. $options[CURLOPT_TIMEOUT] = $this->dataTimeout;
  436. }
  437. if ($requestBody !== null) {
  438. $options[CURLOPT_POSTFIELDS] = $requestBody;
  439. }
  440. if ($method == 'HEAD') {
  441. $options[CURLOPT_NOBODY] = true;
  442. unset($options[CURLOPT_WRITEFUNCTION]);
  443. } else {
  444. $options[CURLOPT_NOBODY] = false;
  445. }
  446. if (is_array($url)) {
  447. list($protocol, $host, $q) = $url;
  448. if (strncmp($host, 'inet[', 5) == 0) {
  449. $host = substr($host, 5, -1);
  450. if (($pos = strpos($host, '/')) !== false) {
  451. $host = substr($host, $pos + 1);
  452. }
  453. }
  454. $profile = "$method $q#$requestBody";
  455. $url = "$protocol://$host/$q";
  456. } else {
  457. $profile = false;
  458. }
  459. Yii::trace("Sending request to Elasticsearch node: $method $url\n$requestBody", __METHOD__);
  460. if ($profile !== false) {
  461. Yii::beginProfile($profile, __METHOD__);
  462. }
  463. $this->resetCurlHandle();
  464. curl_setopt($this->_curl, CURLOPT_URL, $url);
  465. curl_setopt_array($this->_curl, $options);
  466. if (curl_exec($this->_curl) === false) {
  467. throw new Exception('Elasticsearch request failed: ' . curl_errno($this->_curl) . ' - ' . curl_error($this->_curl), [
  468. 'requestMethod' => $method,
  469. 'requestUrl' => $url,
  470. 'requestBody' => $requestBody,
  471. 'responseHeaders' => $headers,
  472. 'responseBody' => $this->decodeErrorBody($body),
  473. ]);
  474. }
  475. $responseCode = curl_getinfo($this->_curl, CURLINFO_HTTP_CODE);
  476. if ($profile !== false) {
  477. Yii::endProfile($profile, __METHOD__);
  478. }
  479. if ($responseCode >= 200 && $responseCode < 300) {
  480. if ($method === 'HEAD') {
  481. return true;
  482. } else {
  483. if (isset($headers['content-length']) && ($len = mb_strlen($body, '8bit')) < $headers['content-length']) {
  484. throw new Exception("Incomplete data received from Elasticsearch: $len < {$headers['content-length']}", [
  485. 'requestMethod' => $method,
  486. 'requestUrl' => $url,
  487. 'requestBody' => $requestBody,
  488. 'responseCode' => $responseCode,
  489. 'responseHeaders' => $headers,
  490. 'responseBody' => $body,
  491. ]);
  492. }
  493. if (isset($headers['content-type'])) {
  494. if (!strncmp($headers['content-type'], 'application/json', 16)) {
  495. return $raw ? $body : Json::decode($body);
  496. }
  497. if (!strncmp($headers['content-type'], 'text/plain', 10)) {
  498. return $raw ? $body : array_filter(explode("\n", $body));
  499. }
  500. }
  501. throw new Exception('Unsupported data received from Elasticsearch: ' . $headers['content-type'], [
  502. 'requestMethod' => $method,
  503. 'requestUrl' => $url,
  504. 'requestBody' => $requestBody,
  505. 'responseCode' => $responseCode,
  506. 'responseHeaders' => $headers,
  507. 'responseBody' => $this->decodeErrorBody($body),
  508. ]);
  509. }
  510. } elseif ($responseCode == 404) {
  511. return false;
  512. } else {
  513. throw new Exception("Elasticsearch request failed with code $responseCode. Response body:\n{$body}", [
  514. 'requestMethod' => $method,
  515. 'requestUrl' => $url,
  516. 'requestBody' => $requestBody,
  517. 'responseCode' => $responseCode,
  518. 'responseHeaders' => $headers,
  519. 'responseBody' => $this->decodeErrorBody($body),
  520. ]);
  521. }
  522. }
  523. private function resetCurlHandle()
  524. {
  525. // these functions do not get reset by curl automatically
  526. static $unsetValues = [
  527. CURLOPT_HEADERFUNCTION => null,
  528. CURLOPT_WRITEFUNCTION => null,
  529. CURLOPT_READFUNCTION => null,
  530. CURLOPT_PROGRESSFUNCTION => null,
  531. CURLOPT_POSTFIELDS => null,
  532. ];
  533. curl_setopt_array($this->_curl, $unsetValues);
  534. if (function_exists('curl_reset')) { // since PHP 5.5.0
  535. curl_reset($this->_curl);
  536. }
  537. }
  538. /**
  539. * Try to decode error information if it is valid json, return it if not.
  540. * @param $body
  541. * @return mixed
  542. */
  543. protected function decodeErrorBody($body)
  544. {
  545. try {
  546. $decoded = Json::decode($body);
  547. if (isset($decoded['error']) && !is_array($decoded['error'])) {
  548. $decoded['error'] = preg_replace('/\b\w+?Exception\[/', "<span style=\"color: red;\">\\0</span>\n ", $decoded['error']);
  549. }
  550. return $decoded;
  551. } catch(InvalidArgumentException $e) {
  552. return $body;
  553. }
  554. }
  555. public function getNodeInfo()
  556. {
  557. return $this->get([]);
  558. }
  559. public function getClusterState()
  560. {
  561. return $this->get(['_cluster', 'state']);
  562. }
  563. }