歡迎光臨
每天分享高質量文章

使用 Docker 和 Elasticsearch 構建一個全文搜尋應用程式 | Linux 中國

我們將使用 Docker 去配置我們自己的專案環境和依賴。這使我寫這個教程快速又簡單。
— Patrick Triest


致謝
編譯自 | https://blog.patricktriest.com/text-search-docker-elasticsearch/ 
 作者 | Patrick Triest
 譯者 | qhwdw ? ? ? ? ? 共計翻譯:105 篇 貢獻時間:177 天

如何在超過 500 萬篇文章的 Wikipedia 上找到與你研究相關的文章?

如何在超過 20 億使用者的 Facebook 中找到你的朋友(並且還拼錯了名字)?

谷歌如何在整個因特網上搜索你的模糊的、充滿拼寫錯誤的查詢?

在本教程中,我們將帶你探索如何配置我們自己的全文搜尋應用程式(與上述問題中的系統相比,它的複雜度要小很多)。我們的示例應用程式將提供一個 UI 和 API 去從 100 部經典文學(比如,《彼得·潘》 、  《弗蘭肯斯坦》 和  《金銀島》)中搜索完整的文字。

你可以在這裡(https://search.patricktriest.com)預覽該教程應用的完整版本。

preview webapp

這個應用程式的原始碼是 100% 開源的,可以在 GitHub 倉庫上找到它們 —— https://github.com/triestpa/guttenberg-search 。

在應用程式中新增一個快速靈活的全文搜尋可能是個挑戰。大多數的主流資料庫,比如,PostgreSQL[3] 和 MongoDB[4],由於受其查詢和索引結構的限制只能提供一個非常基礎的文字搜尋功能。為實現高質量的全文搜尋,通常的最佳選擇是單獨的資料儲存。Elasticsearch[5] 是一個開源資料儲存的領導者,它專門為執行靈活而快速的全文搜尋進行了最佳化。

我們將使用 Docker[6] 去配置我們自己的專案環境和依賴。Docker 是一個容器化引擎,它被 Uber[7]Spotify[8]ADP[9] 以及 Paypal[10] 使用。構建容器化應用的一個主要優勢是,專案的設定在 Windows、macOS、以及 Linux 上都是相同的 —— 這使我寫這個教程快速又簡單。如果你還沒有使用過 Docker,不用擔心,我們接下來將經歷完整的專案配置。

我也會使用 Node.js[11] (使用 Koa[12] 框架)和 Vue.js[13],用它們分別去構建我們自己的搜尋 API 和前端 Web 應用程式。

1 – Elasticsearch 是什麼?

全文搜尋在現代應用程式中是一個有大量需求的特性。搜尋也可能是最難的一項特性 —— 許多流行的網站的搜尋功能都不合格,要麼傳回結果太慢,要麼找不到精確的結果。通常,這種情況是被底層的資料庫所侷限:大多數標準的關係型資料庫侷限於基本的 CONTAINS 或 LIKESQL 查詢上,它僅提供最基本的字串匹配功能。

我們的搜尋應用程式將具備:

☉ 快速 – 搜尋結果將快速傳回,為使用者提供一個良好的體驗。
☉ 靈活 – 我們希望能夠去修改搜尋如何執行的方式,這是為了便於在不同的資料庫和使用者場景下進行最佳化。
☉ 容錯 – 如果所搜尋的內容有拼寫錯誤,我們將仍然會傳回相關的結果,而這個結果可能正是使用者希望去搜索的結果。
☉ 全文 – 我們不想限制我們的搜尋只能與指定的關鍵字或者標簽相匹配 —— 我們希望它可以搜尋在我們的資料儲存中的任何東西(包括大的文字欄位)。

Elastic Search Logo

為了構建一個功能強大的搜尋功能,通常最理想的方法是使用一個為全文搜尋任務最佳化過的資料儲存。在這裡我們使用 Elasticsearch[5],Elasticsearch 是一個開源的記憶體中的資料儲存,它是用 Java 寫的,最初是在 Apache Lucene[14] 庫上構建的。

這裡有一些來自 Elastic 官方網站[15] 上的 Elasticsearch 真實使用案例。

◈ Wikipedia 使用 Elasticsearch 去提供帶高亮搜尋片斷的全文搜尋功能,並且提供按型別搜尋和 “did-you-mean” 建議。
◈ Guardian 使用 Elasticsearch 把社交網路資料和訪客日誌相結合,為編輯去提供新文章的公眾意見的實時反饋。
◈ Stack Overflow 將全文搜尋和地理查詢相結合,並使用 “類似” 的方法去找到相關的查詢和回答。
◈ GitHub 使用 Elasticsearch 對 1300 億行程式碼進行查詢。

與 “普通的” 資料庫相比,Elasticsearch 有什麼不一樣的地方?

Elasticsearch 之所以能夠提供快速靈活的全文搜尋,秘密在於它使用反轉索引inverted index 。

“索引” 是資料庫中的一種資料結構,它能夠以超快的速度進行資料查詢和檢索操作。資料庫透過儲存與表中行相關聯的欄位來生成索引。在一種可搜尋的資料結構(一般是 B 樹[16])中排序索引,在最佳化過的查詢中,資料庫能夠達到接近線性的時間(比如,“使用 ID=5 查詢行”)。

Relational Index

我們可以將資料庫索引想像成一個圖書館中老式的卡片式目錄 —— 只要你知道書的作者和書名,它就會告訴你書的準確位置。為加速特定欄位上的查詢速度,資料庫表一般有多個索引(比如,在 name 列上的索引可以加速指定名字的查詢)。

反轉索引本質上是不一樣的。每行(或檔案)的內容是分開的,並且每個獨立的條目(在本案例中是單詞)反向指向到包含它的任何檔案上。

Inverted Index

這種反轉索引資料結構可以使我們非常快地查詢到,所有出現 “football” 的檔案。透過使用大量最佳化過的記憶體中的反轉索引,Elasticsearch 可以讓我們在儲存的資料上,執行一些非常強大的和自定義的全文搜尋。

2 – 專案設定

2.0 – Docker

我們在這個專案上使用 Docker[6] 管理環境和依賴。Docker 是個容器引擎,它允許應用程式執行在一個獨立的環境中,不會受到來自主機作業系統和本地開發環境的影響。現在,許多公司將它們的大規模 Web 應用程式主要執行在容器架構上。這樣將提升靈活性和容器化應用程式元件的可組構性。

Docker Logo

對我來說,使用 Docker 的優勢是,它對本教程的作者非常方便,它的本地環境設定量最小,並且跨 Windows、macOS 和 Linux 系統的一致性很好。我們只需要在 Docker 配置檔案中定義這些依賴關係,而不是按安裝說明分別去安裝 Node.js、Elasticsearch 和 Nginx,然後,就可以使用這個配置檔案在任何其它地方執行我們的應用程式。而且,因為每個應用程式元件都執行在它自己的獨立容器中,它們受本地機器上的其它 “垃圾” 幹擾的可能性非常小,因此,在除錯問題時,像“它在我這裡可以工作!”這類的問題將非常少。

2.1 – 安裝 Docker & Docker-Compose

這個專案只依賴 Docker[6] 和 docker-compose[17],docker-compose 是 Docker 官方支援的一個工具,它用來將定義的多個容器配置 組裝  成單一的應用程式棧。

◈ 安裝 Docker – https://docs.docker.com/engine/installation/
◈ 安裝 Docker Compose – https://docs.docker.com/compose/install/

2.2 – 設定專案主目錄

為專案建立一個主目錄(名為 guttenberg_search)。我們的專案將工作在主目錄的以下兩個子目錄中。

◈ /public – 儲存前端 Vue.js Web 應用程式。
◈ /server – 伺服器端 Node.js 原始碼。

2.3 – 新增 Docker-Compose 配置

接下來,我們將建立一個 docker-compose.yml 檔案來定義我們的應用程式棧中的每個容器。

☉ gs-api – 後端應用程式邏輯使用的 Node.js 容器
☉ gs-frontend – 前端 Web 應用程式使用的 Ngnix 容器。
☉ gs-search – 儲存和搜尋資料的 Elasticsearch 容器。
  1. version: '3'

  2. services:

  3.  api: # Node.js App

  4.    container_name: gs-api

  5.    build: .

  6.    ports:

  7.      - "3000:3000" # Expose API port

  8.      - "9229:9229" # Expose Node process debug port (disable in production)

  9.    environment: # Set ENV vars

  10.     - NODE_ENV=local

  11.     - ES_HOST=elasticsearch

  12.     - PORT=3000

  13.    volumes: # Attach local book data directory

  14.      - ./books:/usr/src/app/books

  15.  frontend: # Nginx Server For Frontend App

  16.    container_name: gs-frontend

  17.    image: nginx

  18.    volumes: # Serve local "public" dir

  19.      - ./public:/usr/share/nginx/html

  20.    ports:

  21.      - "8080:80" # Forward site to localhost:8080

  22.  elasticsearch: # Elasticsearch Instance

  23.    container_name: gs-search

  24.    image: docker.elastic.co/elasticsearch/elasticsearch:6.1.1

  25.    volumes: # Persist ES data in seperate "esdata" volume

  26.      - esdata:/usr/share/elasticsearch/data

  27.    environment:

  28.      - bootstrap.memory_lock=true

  29.      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"

  30.      - discovery.type=single-node

  31.    ports: # Expose Elasticsearch ports

  32.      - "9300:9300"

  33.      - "9200:9200"

  34. volumes: # Define seperate volume for Elasticsearch data

  35.  esdata:

這個檔案定義了我們全部的應用程式棧 —— 不需要在你的本地系統上安裝 Elasticsearch、Node 和 Nginx。每個容器都將埠轉發到宿主機系統(localhost)上,以便於我們在宿主機上去訪問和除錯 Node API、Elasticsearch 實體和前端 Web 應用程式。

2.4 - 新增 Dockerfile

對於 Nginx 和 Elasticsearch,我們使用了官方預構建的映象,而 Node.js 應用程式需要我們自己去構建。

在應用程式的根目錄下定義一個簡單的 Dockerfile 配置檔案。

  1. # Use Node v8.9.0 LTS

  2. FROM node:carbon

  3. # Setup app working directory

  4. WORKDIR /usr/src/app

  5. # Copy package.json and package-lock.json

  6. COPY package*.json ./

  7. # Install app dependencies

  8. RUN npm install

  9. # Copy sourcecode

  10. COPY . .

  11. # Start app

  12. CMD [ "npm", "start" ]

這個 Docker 配置擴充套件了官方的 Node.js 映象、複製我們的應用程式原始碼、以及在容器內安裝 NPM 依賴。

我們也增加了一個 .dockerignore 檔案,以防止我們不需要的檔案複製到容器中。

  1. node_modules/

  2. npm-debug.log

  3. books/

  4. public/

請註意:我們之所以不複製 node_modules 目錄到我們的容器中 —— 是因為我們要在容器構建過程裡面執行 npm install。從宿主機系統複製 node_modules 到容器裡面可能會引起錯誤,因為一些包需要為某些作業系統專門構建。比如說,在 macOS 上安裝 bcrypt 包,然後嘗試將這個模組直接複製到一個 Ubuntu 容器上將不能工作,因為 bcyrpt 需要為每個作業系統構建一個特定的二進位制檔案。

2.5 - 新增基本檔案

為了測試我們的配置,我們需要新增一些佔位符檔案到應用程式目錄中。

在 public/index.html 檔案中新增如下內容。

  1. Hello World From The Frontend Container

接下來,在 server/app.js 中新增 Node.js 佔位符檔案。

  1. const Koa = require('koa')

  2. const app = new Koa()

  3. app.use(async (ctx, next) => {

  4.  ctx.body = 'Hello World From the Backend Container'

  5. })

  6. const port = process.env.PORT || 3000

  7. app.listen(port, err => {

  8.  if (err) console.error(err)

  9.  console.log(`App Listening on Port ${port}`)

  10. })

最後,新增我們的 package.json  Node 應用配置。

  1. {

  2.  "name": "guttenberg-search",

  3.  "version": "0.0.1",

  4.  "description": "Source code for Elasticsearch tutorial using 100 classic open source books.",

  5.  "scripts": {

  6.    "start": "node --inspect=0.0.0.0:9229 server/app.js"

  7.  },

  8.  "repository": {

  9.    "type": "git",

  10.    "url": "git+https://github.com/triestpa/guttenberg-search.git"

  11.  },

  12.  "author": "patrick.triest@gmail.com",

  13.  "license": "MIT",

  14.  "bugs": {

  15.    "url": "https://github.com/triestpa/guttenberg-search/issues"

  16.  },

  17.  "homepage": "https://github.com/triestpa/guttenberg-search#readme",

  18.  "dependencies": {

  19.    "elasticsearch": "13.3.1",

  20.    "joi": "13.0.1",

  21.    "koa": "2.4.1",

  22.    "koa-joi-validate": "0.5.1",

  23.    "koa-router": "7.2.1"

  24.  }

  25. }

這個檔案定義了應用程式啟動命令和 Node.js 包依賴。

註意:不要執行 npm install —— 當它構建時,依賴會在容器內安裝。

2.6 - 測試它的輸出

現在一切新緒,我們來測試應用程式的每個元件的輸出。從應用程式的主目錄執行 docker-compose build,它將構建我們的 Node.js 應用程式容器。

docker build output

接下來,執行 docker-compose up 去啟動整個應用程式棧。

docker compose output

這一步可能需要幾分鐘時間,因為 Docker 要為每個容器去下載基礎映象。以後再次執行,啟動應用程式會非常快,因為所需要的映象已經下載完成了。

在你的瀏覽器中嘗試訪問 localhost:8080 —— 你將看到簡單的 “Hello World” Web 頁面。

frontend sample output

訪問 localhost:3000 去驗證我們的 Node 伺服器,它將傳回 “Hello World” 資訊。

backend sample output

最後,訪問 localhost:9200 去檢查 Elasticsearch 執行狀態。它將傳回類似如下的內容。

  1. {

  2.  "name" : "SLTcfpI",

  3.  "cluster_name" : "docker-cluster",

  4.  "cluster_uuid" : "iId8e0ZeS_mgh9ALlWQ7-w",

  5.  "version" : {

  6.    "number" : "6.1.1",

  7.    "build_hash" : "bd92e7f",

  8.    "build_date" : "2017-12-17T20:23:25.338Z",

  9.    "build_snapshot" : false,

  10.    "lucene_version" : "7.1.0",

  11.    "minimum_wire_compatibility_version" : "5.6.0",

  12.    "minimum_index_compatibility_version" : "5.0.0"

  13.  },

  14.  "tagline" : "You Know, for Search"

  15. }

如果三個 URL 都顯示成功,祝賀你!整個容器棧已經正常運行了,接下來我們進入最有趣的部分。

3 - 連線到 Elasticsearch

我們要做的第一件事情是,讓我們的應用程式連線到我們本地的 Elasticsearch 實體上。

3.0 - 新增 ES 連線模組

在新檔案 server/connection.js 中新增如下的 Elasticsearch 初始化程式碼。

  1. const elasticsearch = require('elasticsearch')

  2. // Core ES variables for this project

  3. const index = 'library'

  4. const type = 'novel'

  5. const port = 9200

  6. const host = process.env.ES_HOST || 'localhost'

  7. const client = new elasticsearch.Client({ host: { host, port } })

  8. /** Check the ES connection status */

  9. async function checkConnection () {

  10.  let isConnected = false

  11.  while (!isConnected) {

  12.    console.log('Connecting to ES')

  13.    try {

  14.      const health = await client.cluster.health({})

  15.      console.log(health)

  16.      isConnected = true

  17.    } catch (err) {

  18.      console.log('Connection Failed, Retrying...', err)

  19.    }

  20.  }

  21. }

  22. checkConnection()

現在,我們重新構建我們的 Node 應用程式,我們將使用 docker-compose build 來做一些改變。接下來,執行 docker-compose up -d 去啟動應用程式棧,它將以守護行程的方式在後臺執行。

應用程式啟動之後,在命令列中執行 docker exec gs-api "node" "server/connection.js",以便於在容器內執行我們的指令碼。你將看到類似如下的系統輸出資訊。

  1. { cluster_name: 'docker-cluster',

  2.  status: 'yellow',

  3.  timed_out: false,

  4.  number_of_nodes: 1,

  5.  number_of_data_nodes: 1,

  6.  active_primary_shards: 1,

  7.  active_shards: 1,

  8.  relocating_shards: 0,

  9.  initializing_shards: 0,

  10.  unassigned_shards: 1,

  11.  delayed_unassigned_shards: 0,

  12.  number_of_pending_tasks: 0,

  13.  number_of_in_flight_fetch: 0,

  14.  task_max_waiting_in_queue_millis: 0,

  15.  active_shards_percent_as_number: 50 }

繼續之前,我們先刪除最下麵的 checkConnection() 呼叫,因為,我們最終的應用程式將呼叫外部的連線模組。

3.1 - 新增函式去重置索引

在 server/connection.js 中的 checkConnection 下麵新增如下的函式,以便於重置 Elasticsearch 索引。

  1. /** Clear the index, recreate it, and add mappings */

  2. async function resetIndex (index) {

  3.  if (await client.indices.exists({ index })) {

  4.    await client.indices.delete({ index })

  5.  }

  6.  await client.indices.create({ index })

  7.  await putBookMapping()

  8. }

3.2 - 新增圖書樣式

接下來,我們將為圖書的資料樣式新增一個 “對映”。在 server/connection.js 中的 resetIndex 函式下麵新增如下的函式。

  1. /** Add book section schema mapping to ES */

  2. async function putBookMapping () {

  3.  const schema = {

  4.    title: { type: 'keyword' },

  5.    author: { type: 'keyword' },

  6.    location: { type: 'integer' },

  7.    text: { type: 'text' }

  8.  }

  9.  return client.indices.putMapping({ index, type, body: { properties: schema } })

  10. }

這是為 book 索引定義了一個對映。Elasticsearch 中的 index 大概類似於 SQL 的 table 或者 MongoDB 的  collection。我們透過新增對映來為儲存的檔案指定每個欄位和它的資料型別。Elasticsearch 是無樣式的,因此,從技術角度來看,我們是不需要新增對映的,但是,這樣做,我們可以更好地控制如何處理資料。

比如,我們給 title 和 author 欄位分配 keyword 型別,給 text 欄位分配 text 型別。之所以這樣做的原因是,搜尋引擎可以區別處理這些字串欄位 —— 在搜尋的時候,搜尋引擎將在 text 欄位中搜索可能的匹配項,而對於 keyword 型別欄位,將對它們進行全文匹配。這看上去差別很小,但是它們對在不同的搜尋上的速度和行為的影響非常大。

在檔案的底部,匯出對外釋出的屬性和函式,這樣我們的應用程式中的其它模組就可以訪問它們了。

  1. module.exports = {

  2.  client, index, type, checkConnection, resetIndex

  3. }

4 - 載入原始資料

我們將使用來自 古登堡專案[20] 的資料 ——  它致力於為公共提供免費的線上電子書。在這個專案中,我們將使用 100 本經典圖書來充實我們的圖書館,包括《福爾摩斯探案集》、《金銀島》、《基督山復仇記》、《環遊世界八十天》、《羅密歐與朱麗葉》 和《奧德賽》。

Book Covers

4.1 - 下載圖書檔案

我將這 100 本書打包成一個檔案,你可以從這裡下載它 —— https://cdn.patricktriest.com/data/books.zip

將這個檔案解壓到你的專案的 books/ 目錄中。

你可以使用以下的命令來完成(需要在命令列下使用 wget[22] 和 The Unarchiver[23])。

  1. wget https://cdn.patricktriest.com/data/books.zip

  2. unar books.zip

4.2 - 預覽一本書

嘗試開啟其中的一本書的檔案,假設開啟的是 219-0.txt。你將註意到它開頭是一個公開訪問的協議,接下來是一些標識這本書的書名、作者、發行日期、語言和字元編碼的行。

  1. Title: Heart of Darkness

  2. Author: Joseph Conrad

  3. Release Date: February 1995 [EBook #219]

  4. Last Updated: September 7, 2016

  5. Language: English

  6. Character set encoding: UTF-8

在 *** START OF THIS PROJECT GUTENBERG EBOOK HEART OF DARKNESS *** 這些行後面,是這本書的正式內容。

如果你滾動到本書的底部,你將看到類似 *** END OF THIS PROJECT GUTENBERG EBOOK HEART OF DARKNESS *** 資訊,接下來是本書更詳細的協議版本。

下一步,我們將使用程式從檔案頭部來解析書的元資料,提取 *** START OF 和 ***END OF 之間的內容。

4.3 - 讀取資料目錄

我們將寫一個指令碼來讀取每本書的內容,並將這些資料新增到 Elasticsearch。我們將定義一個新的 Javascript 檔案 server/load_data.js 來執行這些操作。

首先,我們將從 books/ 目錄中獲取每個檔案的串列。

在 server/load_data.js 中新增下列內容。

  1. const fs = require('fs')

  2. const path = require('path')

  3. const esConnection = require('./connection')

  4. /** Clear ES index, parse and index all files from the books directory */

  5. async function readAndInsertBooks () {

  6.  try {

  7.    // Clear previous ES index

  8.    await esConnection.resetIndex()

  9.    // Read books directory

  10.    let files = fs.readdirSync('./books').filter(file => file.slice(-4) === '.txt')

  11.    console.log(`Found ${files.length} Files`)

  12.    // Read each book file, and index each paragraph in elasticsearch

  13.    for (let file of files) {

  14.      console.log(`Reading File - ${file}`)

  15.      const filePath = path.join('./books', file)

  16.      const { title, author, paragraphs } = parseBookFile(filePath)

  17.      await insertBookData(title, author, paragraphs)

  18.    }

  19.  } catch (err) {

  20.    console.error(err)

  21.  }

  22. }

  23. readAndInsertBooks()

我們將使用一個快捷命令來重構我們的 Node.js 應用程式,並更新執行的容器。

執行 docker-compose up -d --build 去更新應用程式。這是執行  docker-compose build 和 docker-compose up -d 的快捷命令。

docker build output

為了在容器中執行我們的 load_data 指令碼,我們執行 docker exec gs-api "node" "server/load_data.js" 。你將看到 Elasticsearch 的狀態輸出 Found 100 Books

這之後,指令碼發生了錯誤退出,原因是我們呼叫了一個沒有定義的輔助函式(parseBookFile)。

docker exec output

4.4 - 讀取資料檔案

接下來,我們讀取元資料和每本書的內容。

在 server/load_data.js 中定義新函式。

  1. /** Read an individual book text file, and extract the title, author, and paragraphs */

  2. function parseBookFile (filePath) {

  3.  // Read text file

  4.  const book = fs.readFileSync(filePath, 'utf8')

  5.  // Find book title and author

  6.  const title = book.match(/^Title:\s(.+)$/m)[1]

  7.  const authorMatch = book.match(/^Author:\s(.+)$/m)

  8.  const author = (!authorMatch || authorMatch[1].trim() === '') ? 'Unknown Author' : authorMatch[1]

  9.  console.log(`Reading Book - ${title} By ${author}`)

  10.  // Find Guttenberg metadata essay-header and footer

  11.  const startOfBookMatch = book.match(/^\*{3}\s*START OF (THIS|THE) PROJECT GUTENBERG EBOOK.+\*{3}$/m)

  12.  const startOfBookIndex = startOfBookMatch.index + startOfBookMatch[0].length

  13.  const endOfBookIndex = book.match(/^\*{3}\s*END OF (THIS|THE) PROJECT GUTENBERG EBOOK.+\*{3}$/m).index

  14.  // Clean book text and split into array of paragraphs

  15.  const paragraphs = book

  16.    .slice(startOfBookIndex, endOfBookIndex) // Remove Guttenberg essay-header and footer

  17.    .split(/\n\s+\n/g) // Split each paragraph into it's own array entry

  18.    .map(line => line.replace(/\r\n/g, ' ').trim()) // Remove paragraph line breaks and whitespace

  19.    .map(line => line.replace(/_/g, '')) // Guttenberg uses "_" to signify italics.  We'll remove it, since it makes the raw text look messy.

  20.    .filter((line) => (line && line.length !== '')) // Remove empty lines

  21.  console.log(`Parsed ${paragraphs.length} Paragraphs\n`)

  22.  return { title, author, paragraphs }

  23. }

這個函式執行幾個重要的任務。

☉ 從檔案系統中讀取書的文字。
☉ 使用正則運算式(關於正則運算式,請參閱 這篇文章[24] )解析書名和作者。
☉ 透過匹配 “古登堡專案” 的頭部和尾部,識別書的正文內容。
☉ 提取書的內容文字。
☉ 分割每個段落到它的陣列中。
☉ 清理文字並刪除空白行。

它的傳回值,我們將構建一個物件,這個物件包含書名、作者、以及書中各段落的陣列。

再次執行 docker-compose up -d --build 和 docker exec gs-api "node" "server/load_data.js",你將看到輸出同之前一樣,在輸出的末尾有三個額外的行。

docker exec output

成功!我們的指令碼從文字檔案中成功解析出了書名和作者。指令碼再次以錯誤結束,因為到現在為止,我們還沒有定義輔助函式。

4.5 - 在 ES 中索引資料檔案

最後一步,我們將批次上傳每個段落的陣列到 Elasticsearch 索引中。

在 load_data.js 中新增新的 insertBookData 函式。

  1. /** Bulk index the book data in Elasticsearch */

  2. async function insertBookData (title, author, paragraphs) {

  3.  let bulkOps = [] // Array to store bulk operations

  4.  // Add an index operation for each section in the book

  5.  for (let i = 0; i < paragraphs.length; i++) {

  6.    // Describe action

  7.    bulkOps.push({ index: { _index: esConnection.index, _type: esConnection.type } })

  8.    // Add document

  9.    bulkOps.push({

  10.      author,

  11.      title,

  12.      location: i,

  13.      text: paragraphs[i]

  14.    })

  15.    if (i > 0 && i % 500 === 0) { // Do bulk insert in 500 paragraph batches

  16.      await esConnection.client.bulk({ body: bulkOps })

  17.      bulkOps = []

  18.      console.log(`Indexed Paragraphs ${i - 499} - ${i}`)

  19.    }

  20.  }

  21.  // Insert remainder of bulk ops array

  22.  await esConnection.client.bulk({ body: bulkOps })

  23.  console.log(`Indexed Paragraphs ${paragraphs.length - (bulkOps.length / 2)} - ${paragraphs.length}\n\n\n`)

  24. }

這個函式將使用書名、作者和附加元資料的段落位置來索引書中的每個段落。我們透過批次操作來插入段落,它比逐個段落插入要快的多。

我們分批索引段落,而不是一次性插入全部,是為執行這個應用程式的記憶體稍有點小(1.7 GB)的伺服器  search.patricktriest.com 上做的一個重要最佳化。如果你的機器記憶體還行(4 GB 以上),你或許不用分批上傳。

執行 docker-compose up -d --build 和 docker exec gs-api "node" "server/load_data.js" 一次或多次 —— 現在你將看到前面解析的 100 本書的完整輸出,並插入到了 Elasticsearch。這可能需要幾分鐘時間,甚至更長。

data loading output

5 - 搜尋

現在,Elasticsearch 中已經有了 100 本書了(大約有 230000 個段落),現在我們嘗試搜尋查詢。

5.0 - 簡單的 HTTP 查詢

首先,我們使用 Elasticsearch 的 HTTP API 對它進行直接查詢。

在你的瀏覽器上訪問這個 URL - http://localhost:9200/library/_search?q=text:Java&pretty;

在這裡,我們將執行一個極簡的全文搜尋,在我們的圖書館的書中查詢 “Java” 這個詞。

你將看到類似於下麵的一個 JSON 格式的響應。

  1. {

  2.  "took" : 11,

  3.  "timed_out" : false,

  4.  "_shards" : {

  5.    "total" : 5,

  6.    "successful" : 5,

  7.    "skipped" : 0,

  8.    "failed" : 0

  9.  },

  10.  "hits" : {

  11.    "total" : 13,

  12.    "max_score" : 14.259304,

  13.    "hits" : [

  14.      {

  15.        "_index" : "library",

  16.        "_type" : "novel",

  17.        "_id" : "p_GwFWEBaZvLlaAUdQgV",

  18.        "_score" : 14.259304,

  19.        "_source" : {

  20.          "author" : "Charles Darwin",

  21.          "title" : "On the Origin of Species",

  22.          "location" : 1080,

  23.          "text" : "Java, plants of, 375."

  24.        }

  25.      },

  26.      {

  27.        "_index" : "library",

  28.        "_type" : "novel",

  29.        "_id" : "wfKwFWEBaZvLlaAUkjfk",

  30.        "_score" : 10.186235,

  31.        "_source" : {

  32.          "author" : "Edgar Allan Poe",

  33.          "title" : "The Works of Edgar Allan Poe",

  34.          "location" : 827,

  35.          "text" : "After many years spent in foreign travel, I sailed in the year 18-- , from the port of Batavia, in the rich and populous island of Java, on a voyage to the Archipelago of the Sunda islands. I went as passenger--having no other inducement than a kind of nervous restlessness which haunted me as a fiend."

  36.        }

  37.      },

  38.      ...

  39.    ]

  40.  }

  41. }

用 Elasticseach 的 HTTP 介面可以測試我們插入的資料是否成功,但是如果直接將這個 API 暴露給 Web 應用程式將有極大的風險。這個 API 將會暴露管理功能(比如直接新增和刪除檔案),最理想的情況是完全不要對外暴露它。而是寫一個簡單的 Node.js API 去接收來自客戶端的請求,然後(在我們的本地網路中)生成一個正確的查詢傳送給 Elasticsearch。

5.1 - 查詢指令碼

我們現在嘗試從我們寫的 Node.js 指令碼中查詢 Elasticsearch。

建立一個新檔案,server/search.js

  1. const { client, index, type } = require('./connection')

  2. module.exports = {

  3.  /** Query ES index for the provided term */

  4.  queryTerm (term, offset = 0) {

  5.    const body = {

  6.      from: offset,

  7.      query: { match: {

  8.        text: {

  9.          query: term,

  10.          operator: 'and',

  11.          fuzziness: 'auto'

  12.        } } },

  13.      highlight: { fields: { text: {} } }

  14.    }

  15.    return client.search({ index, type, body })

  16.  }

  17. }

我們的搜尋模組定義一個簡單的 search 函式,它將使用輸入的詞 match 查詢。

這是查詢的欄位分解 -

◈ from - 允許我們分頁查詢結果。預設每個查詢傳回 10 個結果,因此,指定 from: 10 將允許我們取回 10-20 的結果。
◈ query - 這裡我們指定要查詢的詞。
◈ operator - 我們可以修改搜尋行為;在本案例中,我們使用 and 操作去對查詢中包含所有字元(要查詢的詞)的結果來確定優先順序。
◈ fuzziness - 對拼寫錯誤的容錯調整,auto 的預設為 fuzziness: 2。模糊值越高,結果越需要更多校正。比如,fuzziness: 1 將允許以 Patricc 為關鍵字的查詢中傳回與 Patrick 匹配的結果。
◈ highlights - 為結果傳回一個額外的欄位,這個欄位包含 HTML,以顯示精確的文字字集和查詢中匹配的關鍵詞。

你可以去瀏覽 Elastic Full-Text Query DSL[25],學習如何隨意調整這些引數,以進一步自定義搜尋查詢。

6 - API

為了能夠從前端應用程式中訪問我們的搜尋功能,我們來寫一個快速的 HTTP API。

6.0 - API 伺服器

用以下的內容替換現有的 server/app.js 檔案。

  1. const Koa = require('koa')

  2. const Router = require('koa-router')

  3. const joi = require('joi')

  4. const validate = require('koa-joi-validate')

  5. const search = require('./search')

  6. const app = new Koa()

  7. const router = new Router()

  8. // Log each request to the console

  9. app.use(async (ctx, next) => {

  10.  const start = Date.now()

  11.  await next()

  12.  const ms = Date.now() - start

  13.  console.log(`${ctx.method} ${ctx.url} - ${ms}`)

  14. })

  15. // Log percolated errors to the console

  16. app.on('error', err => {

  17.  console.error('Server Error', err)

  18. })

  19. // Set permissive CORS essay-header

  20. app.use(async (ctx, next) => {

  21.  ctx.set('Access-Control-Allow-Origin', '*')

  22.  return next()

  23. })

  24. // ADD ENDPOINTS HERE

  25. const port = process.env.PORT || 3000

  26. app

  27.  .use(router.routes())

  28.  .use(router.allowedMethods())

  29.  .listen(port, err => {

  30.    if (err) throw err

  31.    console.log(`App Listening on Port ${port}`)

  32.  })

這些程式碼將為 Koa.js[12] Node API 伺服器匯入伺服器依賴,設定簡單的日誌,以及錯誤處理。

6.1 - 使用查詢連線端點

接下來,我們將在伺服器上新增一個端點,以便於釋出我們的 Elasticsearch 查詢功能。

在  server/app.js 檔案的 // ADD ENDPOINTS HERE  下麵插入下列的程式碼。

  1. /**

  2. * GET /search

  3. * Search for a term in the library

  4. */

  5. router.get('/search', async (ctx, next) => {

  6.    const { term, offset } = ctx.request.query

  7.    ctx.body = await search.queryTerm(term, offset)

  8.  }

  9. )

使用 docker-compose up -d --build 重啟動應用程式。之後在你的瀏覽器中嘗試呼叫這個搜尋端點。比如,http://localhost:3000/search?term=java 這個請求將搜尋整個圖書館中提到 “Java” 的內容。

結果與前面直接呼叫 Elasticsearch HTTP 介面的結果非常類似。

  1. {

  2.    "took": 242,

  3.    "timed_out": false,

  4.    "_shards": {

  5.        "total": 5,

  6.        "successful": 5,

  7.        "skipped": 0,

  8.        "failed": 0

  9.    },

  10.    "hits": {

  11.        "total": 93,

  12.        "max_score": 13.356944,

  13.        "hits": [{

  14.            "_index": "library",

  15.            "_type": "novel",

  16.            "_id": "eHYHJmEBpQg9B4622421",

  17.            "_score": 13.356944,

  18.            "_source": {

  19.                "author": "Charles Darwin",

  20.                "title": "On the Origin of Species",

  21.                "location": 1080,

  22.                "text": "Java, plants of, 375."

  23.            },

  24.            "highlight": {

  25.                "text": ["Java, plants of, 375."]

  26.            }

  27.        }, {

  28.            "_index": "library",

  29.            "_type": "novel",

  30.            "_id": "2HUHJmEBpQg9B462xdNg",

  31.            "_score": 9.030668,

  32.            "_source": {

  33.                "author": "Unknown Author",

  34.                "title": "The King James Bible",

  35.                "location": 186,

  36.                "text": "10:4 And the sons of Javan; Elishah, and Tarshish, Kittim, and Dodanim."

  37.            },

  38.            "highlight": {

  39.                "text": ["10:4 And the sons of Javan; Elishah, and Tarshish, Kittim, and Dodanim."]

  40.            }

  41.        }

  42.        ...

  43.      ]

  44.   }

  45. }

6.2 - 輸入校驗

這個端點現在還很脆弱 —— 我們沒有對請求引數做任何的校驗,因此,如果是無效的或者錯誤的值將使伺服器出錯。

我們將新增一些使用 Joi[26] 和 Koa-Joi-Validate[27] 庫的中介軟體,以對輸入做校驗。

  1. /**

  2. * GET /search

  3. * Search for a term in the library

  4. * Query Params -

  5. * term: string under 60 characters

  6. * offset: positive integer

  7. */

  8. router.get('/search',

  9.  validate({

  10.    query: {

  11.      term: joi.string().max(60).required(),

  12.      offset: joi.number().integer().min(0).default(0)

  13.    }

  14.  }),

  15.  async (ctx, next) => {

  16.    const { term, offset } = ctx.request.query

  17.    ctx.body = await search.queryTerm(term, offset)

  18.  }

  19. )

現在,重啟伺服器,如果你使用一個沒有搜尋關鍵字的請求(http://localhost:3000/search),你將傳回一個帶相關訊息的 HTTP 400 錯誤,比如像 Invalid URL Query - child "term" fails because ["term" is required]

如果想從 Node 應用程式中檢視實時日誌,你可以執行 docker-compose logs -f api

7 - 前端應用程式

現在我們的 /search 端點已經就緒,我們來連線到一個簡單的 Web 應用程式來測試這個 API。

7.0 - Vue.js 應用程式

我們將使用 Vue.js 去協調我們的前端。

新增一個新檔案 /public/app.js,去控制我們的 Vue.js 應用程式程式碼。

  1. const vm = new Vue ({

  2.  el: '#vue-instance',

  3.  data () {

  4.    return {

  5.      baseUrl: 'http://localhost:3000', // API url

  6.      searchTerm: 'Hello World', // Default search term

  7.      searchDebounce: null, // Timeout for search bar debounce

  8.      searchResults: [], // Displayed search results

  9.      numHits: null, // Total search results found

  10.      searchOffset: 0, // Search result pagination offset

  11.      selectedParagraph: null, // Selected paragraph object

  12.      bookOffset: 0, // Offset for book paragraphs being displayed

  13.      paragraphs: [] // Paragraphs being displayed in book preview window

  14.    }

  15.  },

  16.  async created () {

  17.    this.searchResults = await this.search() // Search for default term

  18.  },

  19.  methods: {

  20.    /** Debounce search input by 100 ms */

  21.    onSearchInput () {

  22.      clearTimeout(this.searchDebounce)

  23.      this.searchDebounce = setTimeout(async () => {

  24.        this.searchOffset = 0

  25.        this.searchResults = await this.search()

  26.      }, 100)

  27.    },

  28.    /** Call API to search for inputted term */

  29.    async search () {

  30.      const response = await axios.get(`${this.baseUrl}/search`, { params: { term: this.searchTerm, offset: this.searchOffset } })

  31.      this.numHits = response.data.hits.total

  32.      return response.data.hits.hits

  33.    },

  34.    /** Get next page of search results */

  35.    async nextResultsPage () {

  36.      if (this.numHits > 10) {

  37.        this.searchOffset += 10

  38.        if (this.searchOffset + 10 > this.numHits) { this.searchOffset = this.numHits - 10}

  39.        this.searchResults = await this.search()

  40.        document.documentElement.scrollTop = 0

  41.      }

  42.    },

  43.    /** Get previous page of search results */

  44.    async prevResultsPage () {

  45.      this.searchOffset -= 10

  46.      if (this.searchOffset < 0) { this.searchOffset = 0 }

  47.      this.searchResults = await this.search()

  48.      document.documentElement.scrollTop = 0

  49.    }

  50.  }

  51. })

這個應用程式非常簡單 —— 我們只定義了一些共享的資料屬性,以及添加了檢索和分頁搜尋結果的方法。為防止每次按鍵一次都呼叫 API,搜尋輸入有一個 100 毫秒的除顫功能。

解釋 Vue.js 是如何工作的已經超出了本教程的範圍,如果你使用過 Angular 或者 React,其實一些也不可怕。如果你完全不熟悉 Vue,想快速瞭解它的功能,我建議你從官方的快速指南入手 —— https://vuejs.org/v2/guide/

7.1 - HTML

使用以下的內容替換 /public/index.html 檔案中的佔位符,以便於載入我們的 Vue.js 應用程式和設計一個基本的搜尋介面。

  1. lang="en">

  2.   charset="utf-8">

  3.  </span><span class="pln" style="word-wrap: break-word;color: rgb(184, 255, 184);">Elastic Library</span><span class="tag" style="word-wrap: break-word;color: rgb(137, 189, 255);"/></code></p></li></ol></pre> <li> <p><code style="word-wrap: break-word;background: none;color: rgb(33, 150, 243);line-height: 1.2em;padding-left: 10px !important;border-radius: 0px !important;margin-top: 1em !important;margin-bottom: 1em !important;border-width: initial !important;border-style: none !important;border-color: initial !important;"><span class="pln" style="word-wrap: break-word;color: rgb(184, 255, 184);">  </span><span class="tag" style="word-wrap: break-word;color: rgb(137, 189, 255);"><meta/><span class="pln" style="word-wrap: break-word;color: rgb(184, 255, 184);"> </span><span class="atn" style="word-wrap: break-word;color: rgb(189, 183, 107);">name</span><span class="pun" style="word-wrap: break-word;color: rgb(184, 255, 184);">=</span><span class="atv" style="word-wrap: break-word;color: rgb(101, 176, 66);">"description"</span><span class="pln" style="word-wrap: break-word;color: rgb(184, 255, 184);"> </span><span class="atn" style="word-wrap: break-word;color: rgb(189, 183, 107);">content</span><span class="pun" style="word-wrap: break-word;color: rgb(184, 255, 184);">=</span><span class="atv" style="word-wrap: break-word;color: rgb(101, 176, 66);">"Literary Classic Search Engine."</span><span class="tag" style="word-wrap: break-word;color: rgb(137, 189, 255);">></span></span></code></p> </li> <li> <p><code style="word-wrap: break-word;background: none;color: rgb(33, 150, 243);line-height: 1.2em;padding-left: 10px !important;border-radius: 0px !important;margin-top: 1em !important;margin-bottom: 1em !important;border-width: initial !important;border-style: none !important;border-color: initial !important;"><span class="pln" style="word-wrap: break-word;color: rgb(184, 255, 184);">  </span><span class="tag" style="word-wrap: break-word;color: rgb(137, 189, 255);"><meta/><span class="pln" style="word-wrap: break-word;color: rgb(184, 255, 184);"> </span><span class="atn" style="word-wrap: break-word;color: rgb(189, 183, 107);">name</span><span class="pun" style="word-wrap: break-word;color: rgb(184, 255, 184);">=</span><span class="atv" style="word-wrap: break-word;color: rgb(101, 176, 66);">"viewport"</span><span class="pln" style="word-wrap: break-word;color: rgb(184, 255, 184);"> </span><span class="atn" style="word-wrap: break-word;color: rgb(189, 183, 107);">content</span><span class="pun" style="word-wrap: break-word;color: rgb(184, 255, 184);">=</span><span class="atv" style="word-wrap: break-word;color: rgb(101, 176, 66);">"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"</span><span class="tag" style="word-wrap: break-word;color: rgb(137, 189, 255);">></span></span></code></p> </li> <li> <p><code style="word-wrap: break-word;background: none;color: rgb(33, 150, 243);line-height: 1.2em;padding-left: 10px !important;border-radius: 0px !important;margin-top: 1em !important;margin-bottom: 1em !important;border-width: initial !important;border-style: none !important;border-color: initial !important;"><span class="pln" style="word-wrap: break-word;color: rgb(184, 255, 184);">  </span><span class="tag" style="word-wrap: break-word;color: rgb(137, 189, 255);"><link/><span class="pln" style="word-wrap: break-word;color: rgb(184, 255, 184);"> </span><span class="atn" style="word-wrap: break-word;color: rgb(189, 183, 107);">href</span><span class="p"/></span></code></p> </li> </div> </article> <div class="post-actions"> <a href="javascript:;" class="post-like action action-like" data-pid="8257"><i class="fa fa-thumbs-o-up"></i>贊(<span>0</span>)</a> </div> <div class="action-share"></div> <div class="article-tags">標籤:<a href="https://www.ipshop.xyz/tag/angular" rel="tag">Angular</a><a href="https://www.ipshop.xyz/tag/apache" rel="tag">Apache</a><a href="https://www.ipshop.xyz/tag/bootstrap" rel="tag">Bootstrap</a><a href="https://www.ipshop.xyz/tag/docker" rel="tag">Docker</a><a href="https://www.ipshop.xyz/tag/elasticsearch" rel="tag">Elasticsearch</a><a href="https://www.ipshop.xyz/tag/facebook" rel="tag">Facebook</a><a href="https://www.ipshop.xyz/tag/git" rel="tag">Git</a><a href="https://www.ipshop.xyz/tag/google" rel="tag">Google</a><a href="https://www.ipshop.xyz/tag/linux" rel="tag">Linux</a><a href="https://www.ipshop.xyz/tag/mongodb" rel="tag">MongoDB</a><a href="https://www.ipshop.xyz/tag/nginx" rel="tag">Nginx</a><a href="https://www.ipshop.xyz/tag/react" rel="tag">React</a><a href="https://www.ipshop.xyz/tag/sql" rel="tag">SQL</a><a href="https://www.ipshop.xyz/tag/vue" rel="tag">Vue</a><a href="https://www.ipshop.xyz/tag/%e5%84%aa%e5%8c%96" rel="tag">最佳化</a></div> <nav class="article-nav"> <span class="article-nav-prev">上一篇<br><a href="https://www.ipshop.xyz/8256.html" rel="prev">運維攻城獅如何突破職業天花板?</a></span> <span class="article-nav-next">下一篇<br><a href="https://www.ipshop.xyz/8258.html" rel="next">在 Linux 下 9 個有用的 touch 命令示例 | Linux 中國</a></span> </nav> <div class="relates"><div class="title"><h3>相關推薦</h3></div><ul><li><a href="https://www.ipshop.xyz/18292.html">分庫分表實戰:可能是使用者表最佳分庫分表方案</a></li><li><a href="https://www.ipshop.xyz/17827.html">分散式鏈路追蹤 SkyWalking 原始碼分析 —— Agent 收集 Trace 資料</a></li><li><a href="https://www.ipshop.xyz/17854.html">HBase 資料遷移方案介紹</a></li><li><a href="https://www.ipshop.xyz/17853.html">C# 管道式程式設計</a></li><li><a href="https://www.ipshop.xyz/17787.html">不會SQL註入,連漫畫都看不懂了</a></li><li><a href="https://www.ipshop.xyz/17782.html">向Excel說再見,神級編輯器統一表格與Python</a></li><li><a href="https://www.ipshop.xyz/17824.html">阿裡不讓 MySQL 多表 Join ?我偏要!</a></li><li><a href="https://www.ipshop.xyz/17558.html">一個高頻面試題:怎麼保證快取與資料庫的雙寫一致性?</a></li></ul></div> </div> </div> <div class="sidebar"> <div class="widget widget_ui_asb"><div class="item"><script async src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script> <!-- ad3 --> <ins class="adsbygoogle" style="display:block" data-ad-client="ca-pub-3715363832600463" data-ad-slot="8216000168" data-ad-format="auto" data-full-width-responsive="true"></ins> <script> (adsbygoogle = window.adsbygoogle || []).push({}); </script></div></div><div class="widget widget_ui_tags"><h3>熱門標籤</h3><div class="items"><a href="https://www.ipshop.xyz/tag/ios">iOS (11238)</a><a href="https://www.ipshop.xyz/tag/%e5%be%ae%e8%bb%9f">微軟 (4955)</a><a href="https://www.ipshop.xyz/tag/linux">Linux (4274)</a><a href="https://www.ipshop.xyz/tag/%e5%ae%89%e5%85%a8">安全 (4180)</a><a href="https://www.ipshop.xyz/tag/python">Python (4161)</a><a href="https://www.ipshop.xyz/tag/%e6%80%a7%e8%83%bd">效能 (3165)</a><a href="https://www.ipshop.xyz/tag/%e9%81%8b%e7%b6%ad">運維 (2774)</a><a href="https://www.ipshop.xyz/tag/%e5%84%aa%e5%8c%96">最佳化 (2419)</a><a href="https://www.ipshop.xyz/tag/net">.NET (2262)</a><a href="https://www.ipshop.xyz/tag/google">Google (2136)</a><a href="https://www.ipshop.xyz/tag/%e6%a9%9f%e5%99%a8%e5%ad%b8%e7%bf%92">機器學習 (1795)</a><a href="https://www.ipshop.xyz/tag/%e4%bd%b5%e7%99%bc">併發 (1613)</a><a href="https://www.ipshop.xyz/tag/%e5%88%86%e4%bd%88%e5%bc%8f">分散式 (1559)</a><a href="https://www.ipshop.xyz/tag/%e9%9b%86%e7%be%a4">叢集 (1240)</a><a href="https://www.ipshop.xyz/tag/sql">SQL (1174)</a><a href="https://www.ipshop.xyz/tag/mysql">Mysql (1060)</a><a href="https://www.ipshop.xyz/tag/%e5%8d%80%e5%a1%8a%e9%8f%88">區塊鏈 (1017)</a><a href="https://www.ipshop.xyz/tag/docker">Docker (977)</a><a href="https://www.ipshop.xyz/tag/%e5%be%ae%e6%9c%8d%e5%8b%99">微服務 (922)</a><a href="https://www.ipshop.xyz/tag/%e9%9d%a2%e8%a9%a6">面試 (919)</a><a href="https://www.ipshop.xyz/tag/apache">Apache (743)</a><a href="https://www.ipshop.xyz/tag/nlp">NLP (725)</a><a href="https://www.ipshop.xyz/tag/redis">Redis (719)</a><a href="https://www.ipshop.xyz/tag/android">Android (668)</a><a href="https://www.ipshop.xyz/tag/git">Git (640)</a><a href="https://www.ipshop.xyz/tag/%e6%9e%b6%e6%a7%8b%e5%b8%ab">架構師 (632)</a><a href="https://www.ipshop.xyz/tag/nginx">Nginx (630)</a><a href="https://www.ipshop.xyz/tag/facebook">Facebook (599)</a><a href="https://www.ipshop.xyz/tag/jvm">JVM (595)</a><a href="https://www.ipshop.xyz/tag/%e7%88%ac%e8%9f%b2">爬蟲 (476)</a></div></div><div class="widget widget_ui_posts"><h3>熱門文章</h3><ul class="nopic"><li><a href="https://www.ipshop.xyz/15779.html"><span class="text">用 docker-compose 啟動 WebApi 和 SQL Server</span><span class="muted">2019-06-26</span></a></li> <li><a href="https://www.ipshop.xyz/9049.html"><span class="text">電線電纜的平方數及平方數和電流的換算公式</span><span class="muted">2018-04-02</span></a></li> <li><a href="https://www.ipshop.xyz/1250.html"><span class="text">實體 :手把手教你用PyTorch快速準確地建立神經網路(附4個學習用例)</span><span class="muted">2019-02-02</span></a></li> <li><a href="https://www.ipshop.xyz/1208.html"><span class="text">面試官讓用5種python方法實現字串反轉?對不起我有16種……</span><span class="muted">2019-01-13</span></a></li> <li><a href="https://www.ipshop.xyz/12632.html"><span class="text">小樣本學習(Few-shot Learning)綜述</span><span class="muted">2019-04-01</span></a></li> <li><a href="https://www.ipshop.xyz/4542.html"><span class="text">黎曼猜想仍舊,素數依然孤獨</span><span class="muted">2018-09-26</span></a></li> </ul></div></div></section> <div class="branding branding-black"> <div class="container"> <h2>分享創造快樂</h2> </div> </div> <footer class="footer"> <div class="container"> <p>© 2024 <a href="https://www.ipshop.xyz">知識星球</a>   <a href="http://www.ipshop.xyz/sitemap.xml">網站地圖</a> </p> <!-- Global site tag (gtag.js) - Google Analytics --> <script async src="https://www.googletagmanager.com/gtag/js?id=UA-133465382-1"></script> <script> window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('js', new Date()); gtag('config', 'UA-133465382-1'); </script> </div> </footer> <script> window.jsui={ www: 'https://www.ipshop.xyz', uri: 'https://www.ipshop.xyz/wp-content/themes/dux', ver: '5.0', roll: [], ajaxpager: '0', url_rp: '', qq_id: '', qq_tip: '' }; </script> <script type='text/javascript' src='https://www.ipshop.xyz/wp-content/themes/dux/js/libs/bootstrap.min.js?ver=5.0' id='bootstrap-js'></script> <script type='text/javascript' src='https://www.ipshop.xyz/wp-content/themes/dux/js/loader.js?ver=5.0' id='_loader-js'></script> </body> </html>