A simple Django's Search APIs with AWS ElasticSearch service and django-haystack



       เคยมั้ยครับทำ API search เพื่อหาของจาก Model แล้วมันช้า เคยมั้ยครับที่ Filter parameter ที่ใช้กับ ORM ซับซ้อนขึ้นเรื่อยๆ เคยมั้ยครับที่ต้องมานั่งจัดการ Pagination หลังจาก Query เสร็จแล้ว ปัญหาเหล่านั้นจะน้อยลงไป (ไม่หมดไปนะครับ ) ด้วย Search Engine Stack ที่ผมกำลังจะพูดถึงนี้ ผมจะ assume ว่าก่อนจะมาถึงเอนทรี่นี้คนที่มาอ่านน่าจะ Setup Django + Django REST Framework เป็นระดับนึงแล้วนะครับ ผมจะข้ามเนื้อหาตรงนั้นไปเลย แต่ถ้าสงสัยจริงๆ ลองศึกษาจาก บล็อกนี้ ได้ครับ

Setting up Elastic Search service

       ก่อนอื่นเลยเราต้องมี Search engine ไว้ทำงานให้เรา ซึ่งในตลาดก็มีให้เลือกใช้เยอะมาก แต่ตัวที่ผมเลือกใช้ตอนนี้คือ ElasticSearch ครับ วิธีลงก็หลากหลายตั้งแต่ลงเองในเครื่อง,​โหลด docker image มารันเอง หรือจะใช้ service อื่น แต่รอบนี้ผมขี้เกียจ Setup docker อีกก็เลยเลือกใช้ AWS Elasticsearch Service แทน ตรงจุดนี้มีสิ่งที่ต้องบันทึกไว้สองสามอย่าง

  • ตอนที่เราสร้าง instance ของ ElasticSearch ขึ้นมาใน aws ให้เลือก version เป็น 2.3 เหตุผลเพราะว่าปลั๊กอิน django-haystack ที่เราจะใช้ต่อไปนั้นยังไม่รองรับ ElasticSearch version 5.x ครับ
  • เราไม่มี Permission สำหรับ ElasticSearch ที่จะ assign ใส่ IAM Group ได้เพราะฉะนั้นวิธีนึงที่จะรักษาความปลอดภัยให้กับ elasticsearch เราได้คือใส่ access policy โดยเลือกแบบ  Allow or deny access to one or more AWS accounts or IAM users แล้วใส่ ARN ของ user ที่เราต้องการให้ใช้ไปจะได้ access policy หน้าตาประมาณนี้
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": [
          "arn:aws:iam::AWS-account-ID:user/user-name-1",
        ]
      },
      "Action": [
        "es:*"      ],
      "Resource": "arn:aws:es:ap-southeast-1:084767468056:domain/test/*"    }
  ]
}

  • พอเรากดสร้าง instance จะเสียเวลารอประมาณ 10 นาทีรอก่อนที่ ElasticSearch จะพร้อมแล้วขึ้นสถานะ active
  • สิ่งที่เราต้องการจากหน้านี้อย่างสุดท้ายคือ Endpoint สำหรับ Django project เราที่จะต่อมาหาได้หน้าตาประมาณนี้ search-test-12abcdefgh3abcdef45abcdefg.ap-southeast-1.es.amazonaws.com

Setting up Django

       พอเรามี search engine พร้อมใช้งานแล้วต่อไปเราต้องต่อมันเข้ากับ Django แต่เราต้องมีเครื่องมีก่อนก็ไล่ pip ตามนี้หรือจะเอาไปใส่ requirements file ก็ตามสะดวกเลยครับ
  • django-haystack : ตัวนี้เป็นพระเอกของเราในวันนี้เลย เป็น module ที่ช่วยเราต่อกับ Search engine ต่างๆ ได้ง่ายขึ้นจาก Models ของ Django
  • elasticsearch : เป็น client ให้เราใช้งาน elasticsearch จาก python ได้ require จาก haystack ถ้าเราต้องการต่อกับ elasticsearch
  • requests-aws4auth : เนื่องจากเราใช้บริการ AWS Elasticsearch service เป็น search engine เราปลั๊กอินตัวนี้ช่วยให้เรา authenticate IAM ได้ง่ายขึ้น
  • drf-haystack : ปลั๊กอินตัวนี้มี View พื้นฐานจาก DRF's ViewSet ให้ใช้งานกับ Haystack ได้ง่ายขึ้น

class Customer(models.Model):
    name = models.CharField(
        null=False,
        blank=False,
        max_length=256    )
    email = models.EmailField(
        null=False,
        blank=False,
        max_length=300    )


       เริ่มแรกเลยผมมี model Customer ใน app ประกอบด้วย Fields หน้าตาประมาณนี้ แต่ก่อนที่เราจะทำอย่างอื่นเราต้องต่อ Django เราเข้ากับ ElasticSearch service ก่อน โดยเพิ่ม haystack เข้าไปใน INSTALLED_APPS ใน settings ไฟล์ของเราหลังจากนั้นให้เราเพิ่ม Configuration สำหรับต่อ Haystack กับ elasticsearch หน้าตาเหมือนข้างล่างนี้ใน settings ไฟล์เดียวกันครับ

# Django Haystack config
AWS_HOST = ''
ACCESS_KEY = ''
AWS_SECRET_KEY = ''
REGION = ''
import elasticsearch
from requests_aws4auth import AWS4Auth

ENGINE = 'haystack.backends.elasticsearch_backend.ElasticsearchSearchEngine'
AWS_AUTH = AWS4Auth(ACCESS_KEY, AWS_SECRET_KEY, REGION, 'es')

HAYSTACK_CONNECTIONS = {
    'default': {
        'ENGINE': ENGINE,
        'URL': AWS_HOST,
        'INDEX_NAME': 'haystack',
        'KWARGS': {
            'port': 443,
            'http_auth': AWS_AUTH,
            'use_ssl': True,
            'verify_certs': True,
            'connection_class': elasticsearch.RequestsHttpConnection,
        }
    },
}

       จะเห็นว่ามีตัวแปรที่ผมเว้นว่างไว้อย่าง AWS_HOST, ACCESS_KEY, AWS_SECRET_KEY และก็ REGION ตัวแปรพวกนี้คือรายละเอียดของ user ที่เรามีบน AWS สำหรับ authenticate ส่วน AWS_HOST นั้นคือ domain ของ ElasticSearch service ที่เราได้จาก Endpoint ข้างบนครับ ส่วนค่าตัวสุดท้ายใน function AWS4Auth ข้างบนคือ 'es' หรือ elasticsearch หมายความว่าเราจะ authenticate สำหรับ AWS ElasticSearch service 
       ในส่วนของ HAYSTACK_CONNECTIONS เราต้องเลือก Search engine เป็น haystack.backends.elasticsearch_backend.ElasticsearchSearchEngine แล้ว URL ชี้ไปที่ ElasticSearch service เรา ในส่วนของ Kwargs จะเป็นเรื่องของ authentication โดยเราแนบ AWS_AUTH header ที่ AWS4Auth จัดการให้เราไปและเลือก connection_class เป็น elasticsearch.RequestsHttpConnection เพราะเราคุยกันผ่าน HTTP นะครับ

Creating Search Index

       สาเหตุหนึ่งที่ search engine ทำงานเร็วกว่าการค้นหา Object ผ่าน Database ตรงๆ คือการทำ Indexing ครับ ในกรณีนี้เราจะสร้าง Search Index ให้กับ Model Customer กันวิธีการที่แนะนำคือ สร้างไฟล์ search_indexes.py ไว้ใน app เดียวกับที่ Model เราอยู่หลังจากนั้นให้สร้าง Index Class กับ Fields ที่เราต้องการหน้าตาก็จะประมาณข้างล่างนี้ครับ

from django.utils import timezone
from haystack import indexes
from .models import Customer


class CustomerIndex(indexes.SearchIndex, indexes.Indexable):
    text = indexes.CharField(document=True)
    name = indexes.CharField(model_attr="name")
    email = indexes.CharField(model_attr="email")

    def get_model(self):
        return Customer

    def index_queryset(self, using=None):
        return self.get_model().objects.filter(created__lte=timezone.now())


       เตือนความจำไว้อย่างนึงว่าทุก Haystack Index Class ต้องมี Field ที่มี attribute document=True ไว้สำหรับเป็น Primary field สำหรับ search หาของจากทุก Field ที่เรา Index  ไว้ของ Model นี้ เพิ่มเติมไว้อย่างนึงว่าถ้า Field ที่เรา index นั้นมี constriain null=True แล้วเราก็ต้องใส่ใน Index Field Attribute ให้เหมือนกันด้วย เมื่อเรามี Index Class แล้วแต่ Django ยังไม่ทำ Index ให้เราวิธีการก็คือเราต้องสั่ง python manage.py rebuild_index จะเป็นการสร้าง index ของ Model เราแล้ว push ไปเก็บไว้ใน ElasticSearch service ของเรา (ขั้นตอนนี้ใช้เวลานานขึ้นอยู่กับจำนวนข้อมูลที่เรามีนะครับ)

Create Serializer, View and Url

       เรามี Index ใน Search Engine พร้อมแล้วต่อไปก็คือทำ API ที่จะให้ Client เราใช้ได้ แต่ก่อนอื่นต้องทำ Serializer สำหรับแปลงข้อมูลเป็น Format ที่พร้อมส่งในที่นี่ก็คือ JSON ก่อนซึ่งใช้ Class ที่ drf_haystack เตรียมมาให้เราแล้วที่เหลือก็เพียงแค่ config ใน Meta class ตามนี้เลยครับ

from drf_haystack.serializers import HaystackSerializer
from .search_indexes import CustomerIndex

class CustomerSerializer(HaystackSerializer):

    class Meta:
        index_classes = [CustomerIndex]
        fields = ["text", "name", "email"]

       ต่อมาเราต้องมี View สำหรับรับ Requests ที่เรียกมาหาเราซึ่งเราก็ใช้ ViewSet ที่ drf_haystack เตรียมมาให้อีกเหมือนกัน หน้าที่เราเพียงแค่ config ตัวแปร index_models กับ serializer_class ให้ถูกที่แค่นั้น

from drf_haystack.viewsets import HaystackViewSet
from .models import Customer
from .serializer import CustomerSerializer

class CustomerSearchView(HaystackViewSet):
    index_models = [Customer]
    serializer_class = CustomerSerializer

       หลังจากนั้นใน urls.py เราก็เพิ่ม Endpoint ที่จะชี้มาที่ View เราเหมือนที่ทำปกติกับ API อื่นๆ

from django.conf.urls import url

urlpatterns = [
    url(r'^search/$', CustomerSearchView.as_view({'get': 'list'}), 
        name='customers_search')
]

หลังจากนี้พอเราจะเทสเราก็แค่ไปที่ endpoint เราผ่าน HTTP Client เช่น http://localhost:8000/api/customers/search/?text=banana แค่นี้ API เราก็จะคืน search results ออกมาในรูปแบบ json เหมือน API อื่นๆ ทั่วไป

Notes

  • ระหว่างเขียนเอนทรี่นี้ทดลองกับ Django รันใน Docker for Mac เจอปัญหา authenticate AWS ไม่ผ่านเพราะ Signature expired เนื่องจากเวลาใน docker ผิดจากความเป็นจริง (เพราะปิด mac แล้วรัน docker ทิ้งไว้) ต้อง restart docker ใหม่ปัญหานี้ถึงจะหายไป
  • ตอนลองครั้งแรกสร้าง elasticsearch instance เป็น version 5.3 แล้วเจอ elasticsearch.exceptions.RequestError: TransportError(400, 'parsing_exception', 'no [query] registered for [filtered]') เพราะ API ของ elasticsearch version 5.x break change เยอะมากจน django-haystack ยังไม่ support

Reference


Comments