در این پست به معرفی Elasticsearch میپردازیم. سپس به صورت عملی با نحوه کار کردن با آن آشنا میشویم.
در ساده ترین حالت Elasticsearch یک موتور جست و جوی متنباز (open-source) هست که به صورت یک سرویس کاملا مجزا (stand-alone) هست و به ما امکانات زیادی برای سرچ کردن میدهد! اما این پایان ماجرا نیست. با این موتور جستوجو نه تنها قابلیت سرچ روی متن داریم (full-text search) بلکه میتوانیم روی دادههایی از جنس اعداد، زمان و حتی نقاط و محدوده های جغرافیایی فیلترهایی ایجاد کنیم.
سه حرف ELK هر کدام ابتدای اسم سه نرم افزار Elasticsearch، Logstash، Kibana است. به مجموعه این سه با هم Elk Stack میگویند.
نرمافزار Logstash بستری برای جمعآوری دادگان از منابع مختلف فراهم میکند. سپس میتواند آنها را آمادهسازی کند و به Elasticsearch بفرستد.
نرمافزار Kibana در کنار Elasticsearch استفاده میشود که امکانات قدرتمندی برای مصورسازی (visualization) دادگان در اختیار ما قرار میدهد. با این نرمافزار میتوانیم در لحظه داده ها را روی نمودارهای مختلف ببینیم و تحلیل کنیم همچنین داشبورد های مختلفی در اختیار ما قرار میدهد که با آن میتوانیم تنظیمات Elasticsearch را به صورت گرافیکی انجام دهیم که کار را برای ما ساده میکند.
در این پست تنها در مورد Elasticsearch است اما با نصب Kibana میتوانید راحت تر کوئریها را console مربوط به Kibana بنویسید و به Elasticsearch بفرستید (به جای استفاده از curl).
اگر بخواهیم Elasticsearch را با یک پایگاهداده رابطهای مقایسه کنیم تعریف ها به این شکل میشوند.
در ادامه میبینیم که الستیکسرچ ذاتا یک سیستم توزیع شده است هر چقدر هم که دادگان ما زیاد باشند میتوان با توزیع دادگان روی سرورهای مختلف بازدهی خوبی گرفت.
تعداد replica ها، تعداد shard های هر index و ... قابل تنظیم است. در واقع با توجه به نیاز پروژه باید این تنظیمات را انجام دهیم که به بهترین بازدهی برسیم.
بعد از گفتن همه این ها جا داره اعلام کنم از مفاهیم مربوط به مقیاس پذیری قرار نیست در ادامه استفاده کنیم :))
برای کار های ساده و معمولی نیاز نمیشه با این تنظیمات درگیر بشیم و تنظیمات default روی یک node کار میکنیم!
از سایت اصلی Elasticsearch را نصب کنید. بعد از اجرای برنامه Elasticsearch به صورت پیشفرض برنامه روی پورت 9200 بالا میآید. (اگر از اینجا Kibana را نصب و اجرا کنید روی پورت 5601 بالا میآید که میتوانید روی مرورگر ببینید).
localhost:9200 { "name" : "ip520", "cluster_name" : "elasticsearch", "cluster_uuid" : "w7uHLU8eRVq7EXre5asJLA", "version" : { "number" : "7.14.0", "build_flavor" : "default", "build_type" : "tar", "build_hash" : "dd5a0a2acaa2045ff9624f3729fc8a6f40835aa1", "build_date" : "2021-07-29T20:49:32.864135063Z", "build_snapshot" : false, "lucene_version" : "8.9.0", "minimum_wire_compatibility_version" : "6.8.0", "minimum_index_compatibility_version" : "6.0.0-beta1" }, "tagline" : "You Know, for Search" }
نکته جالب: در واقع Elasticsearch بر پایه یک موتور جستوجوی قدیمی تر به نام lucene است (هر shard در واقع یک instance از lucene است). در بالا lucene_version که میبینید مربوط به همین است!
حالا ما یک REST API داریم. برای خواندن داده ها از GET, برای اضافه کردن داده از POST، برای تغییر داده از PUT و برای حذف داده از DELETE استفاده میکنیم.
با ایجاد اولین document در یک index آن index خود به خود به وجود میآید.
POST /movies/_doc { "title": "The Godfather", "director": "Francis Ford Coppola", "year": 1972 }
Response: { "_index": "movies", "_type": "_doc", "_id": "yBBvyX4B5pg45qr_-Du0", "_version": 1, "result": "created", "_shards": { "total": 2, "successful": 1, "failed": 0 }, "_seq_no": 0, "_primary_term": 1 }
در اینجا movies نام index ما است. به عنوان json هر object ای را میتوانیم بنویسیم.
هر document یک id یکتا دارد که در اینجا در قسمت id_ و در reponse به ما داده شده است. با داشتن id_ میتوان document را تغییر داد یا حذف کرد.
PUT /movies/_doc/1 { "title": "The Godfather", "director": "Francis Ford Coppola", "year": 1972, "another field": "new value" }
{ "_index": "movies", "_type": "_doc", "_id": "1", "_version": 2, "result": "updated", "_shards": { "total": 2, "successful": 1, "failed": 0 }, "_seq_no": 2, "_primary_term": 1 }
در اینجا 1 همان id مربوط به document است.
اگر document با چنین id وجود داشته باشد مقدار آن تغییر میکند. در غیر اینصورت یک document جدید با چنین id ساخته میشود.
برای حذف کل index مربوط به movies
DELETE /movies/
برای حذف کردن تنها یک document
DELETE /movies/_doc/1
که در اینجا 1 همان id است.
GET /movies/_doc/1
برای خواندن اطلاعات document با یک id خاص از این کوئری استفاده میکنیم.
اگر اطلاعات یافت نشود خروجی به صورت
Response: { "_index": "movies", "_type": "_doc", "_id": "1", "found": false }
میشود و در صورت پیدا کردن به صورت
Response: { "_index": "movies", "_type": "_doc", "_id": "1", "_version": 1, "_seq_no": 3, "_primary_term": 1, "found": true, "_source": { "title": "The Godfather", "director": "Francis Ford Coppola", "year": 1972, "another field": "new value" } }
که در source_ همان document ما قرار دارد.
فرض کنید نیاز داریم تا در ابتدای کار Elasticsearch را با میلیونها document پر کنیم. آیا تنها راه این است که به ازای هر کدام یک request بزنیم؟ قطعا نه!
کوئری bulk به ما اجازه میدهد که چندین کار را با یک request انجام دهیم. در اینصورت سربار شبکه به حداقل ممکن میرسد.
در کوئری bulk دو فرمت داریم.
در فرمت اول در ابتدا index را مشخص میکنیم کوئری ها را روی همان میزنیم.
کوئری زیر دو document ایجاد میکند.
POST /movies/_bulk { "create": { } } { "title": "Lawrence of Arabia", "director": "David Lean", "year": 1962} { "create": { } } {"title": "To Kill a Mockingbird", "director": "Robert Mulligan", "year": 1962}
در فرمت دوم index را در ابتدا مشخص نمیکنیم و در هر زیر کوئری به صورت جداگانه مشخص میکنیم.
POST /_bulk { "delete" : { "_index" : "test", "_id" : "2" } } { "create" : { "_index" : "movies", "_id" : "3" } } { "title": "Apocalypse Now", "director": "Francis Ford Coppola", "year": 1979} { "update" : {"_id" : "1", "_index" : "movies"} } { "doc": {"title": "Kill Bill: Vol. 1", "director": "Quentin Tarantino", "year": 2003}}
این کوئری ابتدا تلاش میکند از index ای که وجود ندارد چیزی پاک کند که خطا میگیرد و اتفاقی نمیافتد.
سپس یک document جدید با id مشخص ایجاد میکند.
سپس یک document که قبلا وجود داشته را تغییر میدهد.
توجه کنید که در اینجا هنگام create اگر document اگر id مربوطه وجود داشته باشد خطا میخوریم. به همین شکل هنگام update اگر document مربوطه وجود نداشته باشد خطا میخوریم.
فرمت body کوئری به صورت NDJSON است. یعنی هر سطر آن یک JSON است که با new line از هم جدا شده اند. همچنین در انتها نیز به یک new line نیاز داریم.
تا اینجا کوئریهای اولیه CRUD (ایجاد، خواندن، تغییر و حذف) را بررسی کردیم. بحث اصلی از اینجا شروع میشود که به سراغ بررسی کوئری های search میرویم.
قبل از هر چیز یک index جدید به اسم locations میسازیم و در آن دادگان زیر را قرار میدهیم تا بتوانیم کوئری های search را بررسی کنیم.
POST /locations/_bulk { "create": { } } { "type": "خیابان", "name": "آزادی", "importance": 0.9} { "create": { } } { "type": "خیابان", "name": "آزادگان", "importance": 0.4} { "create": { } } { "type": "دانشگاه", "name": "صنعتی شریف", "importance": 0.7} { "create": { } } { "type": "کوچه", "name": "دکتر شریفی", "importance": 0.1} { "create": { } } { "type": "دانشگاه", "name": "امیرکبیر", "importance": 0.7} { "create": { } } { "type": "کوچه", "name": "محمد خیابانی", "importance": 0.7}
از آنجایی که با یک ابزار برای بازیابی و تحلیل اطلاعات نیاز داریم کار اصلی ما گرفتن اطلاعات است! کوئریهای زیر همه از نوع GET هستند.
GET /locations/_search
در ادامه میبینیم که search_ پارامتر های زیادی دارد. در اینجا صرفا بدون هیچ پارامتر یا فیلتری آن را وارد کردیم پس همه دادهها را بر میگرداند!
ضمنا میتوان این را با match_all نیز نوشت:
GET /locations/_search { "query":{ "match_all": {} } }
GET /locations/_search { "query":{ "match": { "name": "شریف" } } }
کوئری بالا کل document هایی که "شریف" در name خود دارند بر میگرداند. (name یک field است).
تمام متنهایی که برای ذخیره به Elasticsearch میدهیم به کلمات شکسته میشوند (که وظیفه Tokenizer است). سپس زمانی که چیزی سرچ میکنیم کلمات سرچ ما با کلمات (نه کل متن) مقایسه میشوند.
کوئری match در سادهترین حالت خود برابری را چک میکند. بنابرین "صنعتی شریف" مطابقت داده میشود و "دکتر شریفی" مطابقت داده نمیشود.
در خروجی در قسمت hits میتوانید ببینید:
"hits": [ { "_index": "locations", "_type": "_doc", "_id": "vhAHyX4B5pg45qr_JDvC", "_score": 1.5797713, "_source": { "type": "دانشگاه", "name": "صنعتی شریف", "importance": 0.7 } }, ]
مقدار score عددی است که Elasticsearch به ازای هر document خروجی میدهد و نشاندهنده این است که این document چقدر با شرایطی که ما تعیین کردیم مطابقت دارد (مثلا شاید بخواهیم ۵ نتیجه برتر را نشان بدهیم).
در قسمت source هم همان document ما قرار دارد.
در صورتیکه بخواهیم جستوجوی ما به شکل چک کردن برابری نباشد باید از fuzzy search استفاده کنیم. مقدار fuzziness مشخص میکند که تا چقدر نزدیکی به رشته مورد نظر، مورد قبول است. منظور از نزدیکی همان Levenshtein distance است.
GET /locations/_search { "query":{ "match": { "name": "شریف", "fuzziness": 1 } } }
این کوئری مثل قبل است فقط به دلیل اضافه کردن fuzziness در خروجی document مربوط به "دکتر شریفی" را نیز میبینیم.
همچین میتوان مقدار fuzziness را AUTO گذاشت.
از این کوئری زمانی استفاده میشود که بخواهیم سرچ را روی چندین field انجام بدهیم.
GET /locations/_search { "query":{ "multi_match": { "query": "خیابان", "fuzziness": "AUTO", "fields": ["type", "name"] } } }
در اینجا "خیابان آزادی"، "خیابان آزادگان" و "کوچه محمد خیابانی" را در خروجی میبینیم (به fuzziness توجه کنید).
در field ها از wildcard یا همان * نیز میتوان استفاده کرد.
GET /locations/_search { "query":{ "multi_match": { "query": "خیابان", "fuzziness": "AUTO", "fields": ["*"] } } }
کوئری بالا "خیابان" را روی همه field ها جستوجو میکند.
یا مثلا اگر دو field به نام های last_name, first_name داشته باشیم آنگاه با قرار دادن
"fields": ["*_name"]
جستوجو روی این دو field انجام میشود.
روی field های از جنس عدد یا زمان میتوان از این کوئری استفاده کرد تا خودمان را محدود به یک بازه بکنیم.
GET /locations/_search { "query": { "range": { "importance": { "gte": 0.2, "lte": 0.6 } } } }
تنها خروجی کوئری بالا "خیابان آزادگان" است که importance آن برابر 0.4 است که بین 0.2 و 0.6 است.
با این کوئری میتوانیم از ماهیت شرط استفاده کنیم.
GET /locations/_search { "query": { "bool": { "must": [ { "match": { "type": "خیابان" } } ], "must_not": [ { "match": { "name": { "query": "آزاد", "fuzziness": AUTO } } } ] } } }
کوئری بالا کل خیابان هایی که نام آنها نزدیک به "آزاد" نیست را بر میگرداند.
در لیستی که داشتیم تنها "خیابان آزادگان" چنین حالتی دارد (فاصله "آزادگان" از "آزاد" ۳ است که بیشتر از حد auto است).
علاوه بر must, must_not دو حالت دیگر should, filter هم وجود دارند که میتوانید در اطلاعات بیشتر با آنها آشنا شوید.