Функциональность поиска — одна из наиболее распространенных функций, которые вы видите в любом цифровом продукте. Я бы не стал использовать продукт, который не содержит панели поиска (учитывая, что панель поиска необходима). Однако создание такой большой поисковой системы, как Google, потребует много времени и энергии и может быть не под силу разработчику-одиночке. Итак, здесь я продемонстрирую простой способ создания поисковой системы для продуктов малого и среднего размера.

Стек

Прежде чем приступить к кодированию, позвольте мне познакомить вас с техническим стеком. Я буду использовать JavaScript как для внешнего, так и для внутреннего интерфейса, а также LunrJS для индексации и поиска по текстовому контенту.

Если вы не слышали о LunrJS, это библиотека полнотекстового поиска, которая немного похожа на Solr, но намного меньше и не такая яркая. Библиотека, написанная на JavaScript как для клиентской, так и для серверной части. LunrJS индексирует текстовый контент в документ JSON. Рабочий пакет LunrJS имеет размер 8,2 КБ, что делает его также подходящим для внешнего интерфейса.

Некоторые из альтернатив Lunr: js-search, flexsearch, fuse, wade.

Поток

Чтобы интегрировать функцию поиска в веб-сайт, нам нужны некоторые данные. Мы будем искать конкретную информацию из этого озера данных (ну, пока что это небольшое озеро). Для хранения данных мы можем использовать любую из доступных баз данных в зависимости от потребностей проекта. Для этой демонстрации я использую MongoDB (через Mongoose ORM).

Вот как инициализировать соединение с базой данных с помощью Mongoose в бессерверной среде:

import mongoose from "mongoose";

let mongoDBConn: mongoose.Connection | null = null;
const connectionStr = process.env.DATABASE_URI;

if (typeof connectionStr !== `string`) {
  throw new Error(`database uri: not a string`);
  process.exit(1);
}

if (!mongoDBConn) {
  mongoose
    .connect(connectionStr)
    .then((m) => (mongoDBConn = m.connection))
    .catch(console.error);
}

Вы можете заметить необычный способ инициализации объекта подключения к базе данных. Я кэширую его внутри переменной. Таким образом, последующий бессерверный вызов сможет повторно использовать его.

function getBlogSchema() {
  const BlogCollection = new mongoose.Schema({
    title: { type: String, required: true, unique: true },
    // rest of the document fields
  });

  BlogCollection.index({ url: 1, title: 1, description: 1 });

  const model = mongoose.model(`Blog`, BlogCollection);
  model.syncIndexes();
  return model;
}

export const blogModel = mongoose.models.Blog
  ? mongoose.models.Blog
  : getBlogSchema();

Опять же, еще один нетрадиционный способ создания модели базы данных, все благодаря serverless. Поскольку мы кэшировали базу данных в переменную, мы должны проверить, существует ли модель в кеше. Мы не можем воссоздать модель в Mongoose. Попытка сделать это вызовет ошибку.

Двигаясь дальше, мы должны установить пакет lunr, запустив yarn add lunr. После этого пришло время настроить lunr. Начнем с imports.

import fs from "fs";
import lunr from "lunr";
import { blogModal } from "./path/to/blogModel";

Затем я собираюсь написать несколько вспомогательных функций. Эти функции помогут нам систематически выполнять поиск.

  • buildSearchIndex: Как следует из названия, эта функция создаст поисковый индекс. Поисковый индекс — это набор данных, хранящихся на диске или в памяти. Эта коллекция анализируется и хранится таким образом, чтобы запросы к ней становились проще и эффективнее.
export async function buildSearchIndex(): Promise<lunr.Index> {
  try {
    const docs = await blogModel?.find();
    const index = lunr((builder) => {
      builder.ref(`_id`);

      builder.field(`title`);
      // additional fields if any

      for (let i = 0; i < docs.length; i++) {
        const d = docs[i];
        builder.add(d);
      }
    });

    return index;
  } catch (err) {
    console.log(err);
    throw err;
  }
}

Давайте разбираться, что происходит. Сначала мы вызываем функцию lunr, которая принимает обратный вызов. Первый параметр обратного вызова — это объект с именем builder (передается автоматически lunr).

Метод builder.ref используется для ссылки на исходный документ. Назначьте ему уникальное поле. _id, например.

Метод builder.field сообщает builder какое поле индексировать. Добавьте все поля, в которых вы хотите искать.

Наконец, мы добавляем документы в индекс, вызывая метод builder.add внутри цикла for. Наконец, мы добавляем документы в индекс, вызывая метод builder.add внутри цикла for.

  • saveSearchIndex: эта функция сохраняет заданный поисковый индекс в файловой системе. Когда размер озера данных увеличивается, становится неэффективно создавать индекс при каждом вызове API. В таких случаях lunr может загрузить заранее созданный индекс с диска.
export function saveSearchIndex(index: lunr.Index) {
  try {
    fs.writeFileSync(INDEX_PATH, JSON.stringify(index, null, 2), {
      encoding: "utf-8",
    });
  } catch (err) {
    console.log(err);
  }
}
  • loadSearchIndex: эта функция загружает индекс в память, чтобы lunr мог выполнять над ним операции.
export function loadSearchIndex(): lunr.Index {
  try {
    const content = fs.readFileSync(INDEX_PATH, {
      encoding: `utf-8`,
    });

    return lunr.Index.load(JSON.parse(content));
  } catch (err) {
    console.log(err);
    throw err;
  }
}
  • deleteSearchIndex и hasSearchIndex: эти функции используются для удаления существующего поискового индекса с диска и проверки существования данного индекса.
export function deleteSearchIndexFile() {
  return fs.unlinkSync(INDEX_PATH);
}

export function hasSearchIndex() {
  return fs.existsSync(INDEX_PATH);
}

Теперь, когда у нас есть все вспомогательные функции, мы можем приступить к реализации этой функции. Внутри файла конечной точки API мы собираемся инициализировать индекс lunr.

Стоит отметить, что мы должны обновлять индекс через определенный период. В противном случае в индекс будут включены не все данные из базы данных.

let searchIndex: lunr.Index;
let indexBuiltAt: Date;
const TEN_MIN_IN_MILI = 600000;

В приведенном выше фрагменте кода я объявил несколько переменных. Переменная indexBuiltAt хранит отметку времени самой последней сборки. Основываясь на этой метке времени, я буду обновлять файл index.

function createSearchIndex() {
  buildSearchIndex()
    .then((index) => {
      searchIndex = index;
      saveSearchIndex(index);
      indexBuiltAt = new Date();
    })
    .catch(console.log);
}

Приведенная выше функция создает индекс поиска и сохраняет его в объявленных ранее переменных.

Наконец, пришло время склеить все вместе и сделать это рабочим решением.

Следующий кодовый блок в значительной степени объясняет сам себя. Я использовал setImmediate, чтобы он не блокировал основной цикл событий.

setImmediate(() => {
  if (hasSearchIndex()) {
    searchIndex = loadSearchIndex();
  } else {
    createSearchIndex();
  }

  setInterval(() => {
    // reload search index at every 10 mins
    if (
      indexBuiltAt &&
      indexBuiltAt?.getTime() + TEN_MIN_IN_MILI < new Date().getTime()
    ) {
      if (hasSearchIndex()) {
        searchIndex = loadSearchIndex();
      } else {
        createSearchIndex();
      }
    }
  }, 30 * 1000);
});

На данный момент все сделано. И мы готовы выполнять запросы по этому индексу. Чтобы выполнить запрос с использованием lunr, мы должны вызвать метод search.

const ids = [];
const result = searchIndex.search(`*${search.split(` `).join(`*`)}*`);

for (let i = 0; i < result.length; i++) {
  const doc = result[i];
  mongoose.isValidObjectId(doc.ref) && ids.push(doc.ref);
}

Я собираю все совпадающие id в массив. Используя эти id, я получу фактические документы и отправлю их в качестве ответа API.

Заключение

Эта настройка идеальна, если ваш продукт относительно небольшой (и у вас нет большого объема данных для выполнения операций). Я использовал ту же настройку в одном из проектов, которые я построил. Это можно значительно улучшить. Например, вы можете создавать поисковый индекс каждый раз, когда в базе данных появляется новая запись.

Для получения дополнительной информации о lunr посетите официальный сайт. В него встроено много других полезных вещей.

Первоначально опубликовано на https://www.abdus.net.

Создавайте компонуемые приложения

Не создавайте веб-монолиты. Используйте Bit для создания и компоновки несвязанных программных компонентов — в ваших любимых фреймворках, таких как React или Node. Создавайте масштабируемые и модульные приложения с мощными и приятными возможностями разработки.

Перенесите свою команду в Bit Cloud, чтобы совместно размещать и совместно работать над компонентами, а также значительно ускорить, масштабировать и стандартизировать разработку в команде. Начните с компонуемых интерфейсов, таких как Design System или Micro Frontends, или исследуйте компонуемый сервер. Попробуйте →

Узнать больше