TL;RD
- 🦀 Hay un rico conjunto de herramientas para desarrollar WebAssembly con Rust. ¡Es divertido!
- 🤝 WebAssembly y Next.js funcionan bastante bien juntos, pero tenga en cuenta los problemas conocidos.
- 🧑🔬 Los filtros Xor son estructuras de datos que brindan una gran eficiencia de memoria y una búsqueda rápida de existencia de valor.
- 🧑🍳 El rendimiento y el tamaño del código de WebAssembly no están garantizados. Asegúrese de medir y comparar.
Siempre supe que quería una función de búsqueda de texto completo para los artículos de mi cartera para proporcionar a los visitantes un acceso rápido al contenido que les interesa. Después de migrar a Contentlayer, ya no parece tan descabellado. Entonces comencé a explorar 🚀
Inspirado en tinysearch
: un motor de búsqueda de texto completo de WebAssembly
Después de investigar un poco, encontré un motor de búsqueda llamado tinysearch
. Es un motor de búsqueda estático construido con Rust y WebAssembly (Wasm). El autor Matthias Endler escribió una increíble publicación de blog sobre cómo surgió tinysearch
.
Me encantó la idea de construir un motor de búsqueda minimalista en el momento de la compilación y enviarlo en un código optimizado de bajo nivel a los navegadores. Así que decidí usar tinysearch
como modelo y escribir mi propio motor de búsqueda para integrarlo con mi sitio estático Next.js.
Recomiendo leer el código base de tinysearch
. Está muy bien escrito. La implementación de mi motor de búsqueda es una versión simplificada del mismo. La lógica central es la misma.
¿Qué aspecto tiene la función de búsqueda?
Muy simple:
- Los usuarios escriben cualquier cosa en la entrada de búsqueda.
- El buscador busca las palabras clave en todos los contenidos y encuentra los artículos más relevantes.
- La interfaz de usuario muestra una lista de resultados de búsqueda clasificados.
¡Puedes probar la función de búsqueda en la página de Artículos !
Un poco de estadísticas
Al momento de escribir este artículo, existen:
- 7 artículos (más por venir🚀)
- 13,925 palabras
- 682 KB de archivos de datos (generados por Contentlayer)
Para que la búsqueda de texto completo funcione para sitios estáticos que se preparan para la velocidad, el tamaño del código debe ser pequeño.
¿Cómo funciona la función de búsqueda de texto completo de WebAssembly?
La mayoría de los navegadores modernos ahora admiten WebAssembly . Pueden ejecutar código WebAssembly nativo y binario junto con JavaScript.
El concepto de la función de búsqueda es sencillo. Toma una cadena de consulta como parámetro. En la función, tokenizamos la consulta en términos de búsqueda. A continuación, asignamos una puntuación de clasificación a cada artículo según la cantidad de términos de búsqueda que contiene. Finalmente, clasificamos los artículos por relevancia. Cuanto mayor sea la puntuación, más relevante es.
El flujo se ve así:
La puntuación de los artículos es donde interviene la mayor parte de la computación. Un enfoque ingenuo sería transformar cada artículo en un conjunto hash que contiene todas las palabras únicas del artículo. Podemos calcular la puntuación simplemente contando cuántos términos de búsqueda hay en el conjunto hash.
Puede imaginar que este no es el enfoque más eficiente en memoria con un conjunto hash. Hay mejores estructuras de datos para reemplazarlo: filtros xor .
¿Qué son los filtros Xor?
Los filtros Xor son estructuras de datos relativamente nuevas que nos permiten estimar si un valor existe o no. Es rápido y eficiente en memoria, por lo que es muy adecuado para la búsqueda de texto completo.
En lugar de almacenar los valores de entrada reales como un conjunto hash, los filtros xor almacenan huellas dactilares (secuencia hash de L-bit) de los valores de entrada de una manera específica . Al buscar si existe un valor en el filtro, verifica si la huella digital del valor está presente.
Sin embargo, los filtros Xor tienen un par de compensaciones:
- Los filtros Xor son probabilísticos y existe la posibilidad de que se produzcan falsos positivos.
- Los filtros Xor no pueden estimar la existencia de valores parciales. Entonces, en mi caso de uso, la búsqueda de texto completo solo podrá buscar palabras completas.
¿Cómo construí los filtros Xor con Rust?
Como tenía los datos del artículo generados por Contentlayer, construí los filtros xor alimentándolos con los datos antes de construir el WebAssembly. Luego serialicé los filtros xor y los almacené en un archivo. Para usar los filtros en WebAssembly, todo lo que tenía que hacer era leer el archivo de almacenamiento y deserializar los filtros.
El flujo de generación de filtros se ve así:
xorf
es una buena opción para la implementación de filtros xor porque ofrece serialización/deserialización y algunas funciones que mejoran la eficiencia de la memoria y la tasa de falsos positivos. También proporciona una estructura HashProxy
muy útil para mi caso de uso para construir un filtro xor con una porción de cadenas. La construcción escrita en Rust se ve más o menos así:
use std::collections::hash_map::DefaultHasher; use xorf::{Filter, HashProxy, Xor8}; mod utils; fn build_filter(title: String, body: String) -> HashProxy<String, DefaultHasher, Xor8> { let title_tokens: HashSet<String> = utils::tokenize(&title); let body_tokens: HashSet<String> = utils::tokenize(&body); let tokens: Vec<String> = body_tokens.union(&title_tokens).cloned().collect(); HashProxy::from(&tokens) }
Si está interesado en la implementación real, puede leer más en el repositorio .
Poniendolo todo junto
Integrando WebAssembly en Next.js
Así es como integré el script de generación de filtros xor y WebAssembly dentro de Next.js.
La estructura del archivo se ve así:
my-portfolio ├── next.config.js ├── pages ├── scripts │ └── fulltext-search ├── components │ └── Search.tsx └── wasm └── fulltext-search
Para admitir WebAssembly, actualicé mi configuración de Webpack para cargar módulos WebAssembly como módulos asíncronos. Para que funcione para la generación de sitios estáticos, necesitaba una solución alternativa para generar el módulo WebAssembly en el directorio .next/server
para que las páginas estáticas puedan prerenderizarse correctamente al ejecutar el next build
.
siguiente.config.js
webpack: function (config, { isServer }) { // it makes a WebAssembly modules async modules config.experiments = { asyncWebAssembly: true } // generate wasm module in ".next/server" for ssr & ssg if (isServer) { config.output.webassemblyModuleFilename = './../static/wasm/[modulehash].wasm' } else { config.output.webassemblyModuleFilename = 'static/wasm/[modulehash].wasm' } return config },
Eso es todo lo que hay para la integración✨
Uso de The WebAssembly en el componente React
Para compilar el módulo WebAssembly a partir del código de Rust, utilizo wasm-pack
.
El archivo .wasm
generado y el código de conexión para JavaScript se encuentran en wasm/fulltext-search/pkg
. Todo lo que tenía que hacer era usar next/dynamic
para importarlos dinámicamente. Como esto:
componentes/Search.tsx
import React, { useState, useCallback, ChangeEvent, useEffect } from 'react' import dynamic from 'next/dynamic' type Title = string; type Url = string; type SearchResult = [Title, Url][]; const Search = dynamic({ loader: async () => { const wasm = await import('../../wasm/fulltext-search/pkg') return () => { const [term, setTerm] = useState('') const [results, setResults] = useState<SearchResult>([]) const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => { setTerm(e.target.value) }, []) useEffect(() => { const pending = wasm.search(term, 5) setResults(pending) }, [term]) return ( <div> <input value={term} onChange={onChange} placeholder="🔭 search..." /> {results.map(([title, url]) => ( <a key={url} href={url}>{title}</a> ))} </div> ) } }, }) export default Search
Optimización del tamaño del código WebAssembly
Sin ninguna optimización, el tamaño original del archivo 114.56KB
era de 114,56 KB. Usé Twiggy para averiguar el tamaño del código.
Shallow Bytes │ Shallow % │ Item ───────────────┼───────────┼───────────────────── 117314 ┊ 100.00% ┊ Σ [1670 Total Rows]
En comparación con los 628KB
de archivos de datos sin procesar, era mucho más pequeño de lo que esperaba. Ya estaba feliz de enviarlo a producción, pero tenía curiosidad por ver cuánto tamaño de código podía recortar con la recomendación de optimización de The Rust And WebAssembly Working Group .
El primer experimento fue alternar LTO y probar diferentes opt-level
. La siguiente configuración produce el tamaño de código .wasm
más pequeño:
Cargo.toml
[profile.release] + opt-level = 's' + lto = true
Shallow Bytes │ Shallow % │ Item ───────────────┼───────────┼───────────────────── 111319 ┊ 100.00% ┊ Σ [1604 Total Rows]
A continuación, reemplacé el asignador predeterminado con wee_alloc
.
wasm/búsqueda de texto completo/src/lib.rs
+ #[global_allocator] + static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
Shallow Bytes │ Shallow % │ Item ───────────────┼───────────┼───────────────────── 100483 ┊ 100.00% ┊ Σ [1625 Total Rows]
Luego probé la herramienta wasm-opt
en Binaryen .
wasm-opt -Oz -o wasm/fulltext-search/pkg/fulltext_search_core_bg.wasm wasm/fulltext-search/pkg/fulltext_search_core_bg.wasm
Shallow Bytes │ Shallow % │ Item ───────────────┼───────────┼───────────────────── 100390 ┊ 100.00% ┊ Σ [1625 Total Rows]
Eso es un 14.4%
de descuento del tamaño del código original.
Al final, pude enviar un motor de búsqueda de texto completo en:
- 98,04 KB en bruto
- 45.92 KB comprimido con gzip
No está mal 😎
¿Es realmente ágil?
Perfilé el rendimiento con web-sys
y recopilé algunos datos:
número de búsquedas: 208
mín.: 0,046 ms
máx.: 0,814 ms
media: 0.0994 ms ✨
desviación estándar: 0.0678
De media, se tarda menos de 0,1 ms en realizar una búsqueda de texto completo.
Es bastante ágil 😎
Pensamientos finales
Después de algunos experimentos, pude crear una búsqueda de texto completo rápida y liviana con los filtros WebAssembly, Rust y xor. Se integra bien con Next.js y la generación de sitios estáticos.
La velocidad y el tamaño vienen con algunas compensaciones, pero no tienen un gran impacto en la experiencia del usuario. Si está buscando una funcionalidad de búsqueda más completa, aquí hay algunos productos geniales disponibles:
Motores de búsqueda SaaS
Motores de búsqueda estáticos
Motores de búsqueda basados en servidor
Motores de búsqueda en el navegador
Referencias
- Artículo: Un motor de búsqueda pequeño, estático y de texto completo que usa Rust y WebAssembly - Matthias Endler
- Artículo: Escribiendo un motor de búsqueda de texto completo usando filtros Bloom - Stavros Korokithakis
- Artículo: Filtros Xor: más rápidos y más pequeños que los filtros Bloom - Daniel Lemire
- Artículo: Reducción del tamaño del código .wasm - The Rust and WebAssembly Working Group
- Artículo: Creación de mejores sitios estáticos Next.js con MDX y Contentlayer - Daw-Chih Liou
- Artículo: Next.js - Importación dinámica
- Sitio web: Filtros Xor: más rápidos y más pequeños que los filtros Bloom y Cuckoo
- Sitio web: Portafolio de Daw-Chih Liou
- Sitio web: Meilisearch
- Sitio web: Typesense
- Sitio web: Algolia
- Sitio web: Elasticlunr.js
- Sitio web: óxido
- Sitio web: WebAssembly
- Sitio web: MDN WebAssembly
- Sitio web: Next.js
- Sitio web: ¿Puedo usar WebAssembly?
- Conferencia: Consultas aproximadas de membresía - Stanford
- Wiki: búsqueda de texto completo
- GitHub: ejemplo de Next.js y WebAssembly
- GitHub: Minimizar el tamaño binario de Rust
- GitHub: dawchihliou.github.io
- GitHub: Webpack 5 interrumpe la importación dinámica de wasm para SSR
- Github: búsqueda minúscula
- GitHub: Meilisearch
- GitHub: xorf
- GitHub: wasm-pack
- GitHub: Binario
- GitHub: Twiggy
- GitHub: Clippy
- GitHub: web-sys
- GitHub: una vez_célula
- GitHub: código binario
- GitHub: wee_alloc
- GitHub: Serde
- GitHub: De todos modos
- GitHub: capa de contenido
- GitHub: Búsqueda flexible
- GitHub: Minibúsqueda
Este artículo también se publicó en el sitio web de Daw-Chih .