본문 바로가기
DEV/Web 개발

Web개발 :: Code 기능 리뷰(검색), Deploy _TIL81

by 올커 2022. 12. 28.

■ JITHub 개발일지 81일차

□  TIL(Today I Learned) ::

Code 기능 리뷰(검색), Deploy

Algolia 기본 개념

data workflow

 1. 데이터베이스나 static 파일같은 데이터 source에서 데이터를 fetch한다. 

 2. 해당 데이터를 Json Records로 변환한다.

    * 예시 (An Algolia record (or object) is a set of key-value pairs called attributes.)

{
  "title": "Blackberry and blueberry pie",
  "description": "A delicious pie recipe that combines blueberries and blackberries.",
  "image": "https://yourdomain.com/blackberry-blueberry-pie.jpg",
  "likes": 1128,
  "sales": 284,
  "categories": ["pie", "dessert", "sweet"],
  "gluten_free": false
}

3. Algolia에 해당 레코드를 보낸다. → indexing ⇒ `python manage.py algolia_reindex`
    - Algolia index : 데이터 베이스의 테이블과 동일하다. 단지 검색에 특화됐을 뿐.

attributes for searching

 

Format and structure your data | Algolia

How to prepare your data, format and structure your attributes, records, and indices to improve search results with Algolia.

www.algolia.com

모든 attribute는 검색 가능한 게 디폴트. 하지만 searchable attributes  feature로 선별할 수 있음.

# places/index.py
settings = {       
    'searchableAttributes' : ["place_name", "category", "palce_address", "place_number"]
}

Algolia’s default ranking formula 작동 이후 Custom ranking이 작동함.

특정 metric이 너무 정확하면 다른 metric이 작용하지 못하므로 정확도를 줄이는 게 좋다.

 예시

권장 해결법

Simplifying your records

 

Format and structure your data | Algolia

How to prepare your data, format and structure your attributes, records, and indices to improve search results with Algolia.

www.algolia.com

models.py

models.Manager docs

 

Django

The web framework for perfectionists with deadlines.

docs.djangoproject.com

By default, Django adds a Manager with the name objects to every Django model class. However, if you want to use objects as a field name, or if you want to use a name other than objects  for the Manager, you can rename it on a per-model basis. To rename the Manager for a given class, define a class attribute of type models.Manager() on that model. Adding extra Manager methods is the preferred way to add “table-level” functionality to your models. (For “row-level” functionality – i.e., functions that act on a single instance of a model object – use Model methods, not custom Manager methods.)

models.QuerySet docs

 

Django

The web framework for perfectionists with deadlines.

docs.djangoproject.com

While most methods from the standard QuerySet are accessible directly from the Manager, this is only the case for the extra methods defined on a custom QuerySet if you also implement them on the Manager

# models.py

from django.db import models
from django.db.models import Q
from django.core.validators import MaxValueValidator

from users.models import User

class PlaceQerySet(models.QuerySet):
    def search(self, query) :
        lookup = (Q(place_name__contains=query) | Q(category__contains=query) 
        | Q(place_address__contains=query) | Q(place_number__contains=query))
        qs = self.filter(lookup)
        return qs

class PlaceManager(models.Manager):
    def get_queryset(self, *args, **kwargs) :
        return PlaceQerySet(self.model, using=self._db)

    def search(self, query, user=None):
        return self.get_queryset().search(query)

class Place(models.Model):
    place_name = models.CharField('장소명', max_length=50)
    category = models.CharField('카테고리', max_length=20)
    rating = models.DecimalField('별점', max_digits=3, decimal_places=2, default=0, validators=[MaxValueValidator(5)])
    menu = models.TextField('메뉴', null=True)
    place_desc = models.CharField('소개글', max_length=255, null=True)
    place_address = models.CharField('주소', max_length=100)
    place_number = models.CharField('장소 전화번호', max_length=20)
    place_time = models.CharField('영업 시간', max_length=30)
    place_img = models.TextField('장소 이미지', null=True)
    latitude = models.CharField('위도', max_length=50, null=True)
    longitude = models.CharField('경도', max_length=50, null=True)
    hit = models.PositiveIntegerField('조회수', default=0)

    place_bookmark = models.ManyToManyField(User, verbose_name='장소 북마크', related_name="bookmark_place",blank=True)

    objects = PlaceManager()

    class Meta:
        db_table = 'places'

    def __str__(self):
        return f'[장소명]{self.place_name}'

    @property
    def hit_count(self):
        self.hit +=1
        self.save()
# views.py

class SearchListView(APIView):
    permission_classes = [AllowAny]
    
    @swagger_auto_schema(operation_summary="검색",
                    responses={200 : '성공', 400:'쿼리 에러', 500 : '서버 에러'})
    def get(self, request):
        query = request.GET.get('keyword')
        if query == "":
            return Response({"message":"공백은 입력 불가"}, status=status.HTTP_400_BAD_REQUEST)
        if not query:
            return Response({"message":"쿼리 아님"}, status=status.HTTP_400_BAD_REQUEST)
        results = client.perform_search(query)
        return Response(results, status=status.HTTP_200_OK)
# index.py

from algoliasearch_django import AlgoliaIndex
from algoliasearch_django.decorators import register

from .models import Place

@register(Place)
class PlaceIndex(AlgoliaIndex):
    # aoglolia 페이지에서 확인할 필드 지정
    fields = [ 
        'place_name',
        'category',
        'rating',
        'place_address',
        'place_number',
        'place_img'
    ]

    # 검색 가능한 필드 지정
    settings = {       
        'searchableAttributes' : ["place_name", "category", "palce_address", "place_number"]
    }

 

# client.py

from algoliasearch_django import algolia_engine

def get_client():
    return algolia_engine.client

def get_index(index_name='cfe_Place'):
    client = get_client()
    index = client.init_index(index_name)
    return index

def perform_search(qeury, **kwargs):
    index = get_index()
    params = {
    'hitsPerPage': 100 # 검색 결과 개수 조정
}
    index_filters = [f"{k}:{v}" for k, v in kwargs.items() if v]
    if len(index_filters) != 0:
        params["facetFilters"] = index_filters
    results = index.search(qeury, params)
    return results

 

# settings.py

ALGOLIA = {
    'APPLICATION_ID': get_secret("SEARCH_ID"),
    'API_KEY': get_secret("SEARCH_KEY"),
    'INDEX_PREFIX' : 'cfe'
}

 


Deploy

 

1. Docker file

FROM python:3.10.8-slim
파이썬 이미지를 가져와서

ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
환경변수 설정

RUN mkdir /app/          # 앱 폴더 만들어주고
WORKDIR /app/            # 작업 디렉토리 설정
COPY ./django/requirements.txt .       # requirement파일 넣어주기


RUN apt update && apt install libpq-dev gcc -y
RUN pip install --no-cache-dir -r requirements.txt			# requirements 설치
RUN pip install gunicorn psycopg2

2. docker-compose.yml

volumes:
  postgres: {}
  django_media: {}
  django_static: {}

services:
  postgres:
    container_name: postgres
    image: postgres:14.5-alpine
    volumes:
      - postgres:/var/lib/postgresql/data/
    environment:
      - POSTGRES_USER
      - POSTGRES_PASSWORD
      - POSTGRES_DB
    restart: always

  redis:
    image: redis:alpine
    restart: always

							# entrypoint : 컨테이너가 시작될 때 실행하는 코드
  backend:
    container_name: backend
    build: ./backend/
    entrypoint: sh -c "python manage.py collectstatic --no-input && python manage.py migrate && gunicorn gaggamagga.wsgi --workers=5 -b 0.0.0.0:8000"
    volumes:
      - ./backend/django/:/app/
      - /etc/localtime:/etc/localtime:ro
      - django_media:/app/media/
      - django_static:/app/static/
    environment:
      - DEBUG
      - POSTGRES_DB
      - POSTGRES_USER
      - POSTGRES_PASSWORD
      - POSTGRES_HOST
      - POSTGRES_PORT
    depends_on:
      - postgres
    restart: always

	channels:
	    container_name: channels
	    build: ./backend/
	    command: daphne -b 0.0.0.0 -p 8001 gaggamagga.asgi:application
	    volumes:
	      - ./backend/django/:/app/
	    environment:
	      - DEBUG=1
	      - POSTGRES_DB
	      - POSTGRES_USER
	      - POSTGRES_PASSWORD
	      - POSTGRES_HOST
	      - POSTGRES_PORT
	    depends_on:
	      - backend
	      - postgres
	    restart: always
	
	  nginx:
	    container_name : nginx
	    image: nginx:1.23.2-alpine
	    ports:
	      - "80:80"
	      - "443:443"
	    volumes:
	      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf
	      - ./nginx/ssl/:/etc/ssl_test/
	      - django_media:/media/
	      - django_static:/static/
	    depends_on:
	      - channels
	    restart: always

3. nginx/default.conf

upstream ws_server{            # upstream : nginx 내부에서 변수를 선언할 때 사용하는 함수
  server channels:8001;
}

upstream be_server{
  server backend:8000;
}

server {
    listen 80;
    server_name www.gaggamagga.tk;

    location / {
        proxy_set_header Host $http_host;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Scheme $http_x_forwarded_scheme;
        proxy_set_header X-Request-ID $request_id;
        proxy_pass http://be_server;
    }

    location /ws/notification/ {
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection Upgrade;
        proxy_pass http://ws_server;
    }

    location /static/ {
       alias /static/;
    }

    location /media/ {
       alias /media/;
    }
}

server {
  listen 80;
  server_name gaggamagga.tk;
  return 301 http://www.gaggamagga.tk$request_uri;		# 301 :redirection http://www.~ 로 리다이렉트 시킨다.
}
  • client_max_body_size 설정하기 (default 1MB)
    1. nginx 컨테이너에 진입하기 : sudo docker exec -it nginx /bin/sh
    2. vi etc/nginx/nginx.conf
    3. http 안에 client_max_body_size 100M; 작성 100M 말고 원하는 용량 아무렇게나 작성 가능
    4. nginx -t (필수 과정 아님. syntax 검사 과정)
    5. nginx -s reload

4. settings.py

ALLOWED_HOSTS = ['*',] # 백엔드 주소랑 프론트 주소 넣으면 됨
CORS_ALLOW_ALL_ORIGINS = True
CORS_ORIGIN_WHITELIST = ['https://www.gaggamagga.tk', ]

ASGI_APPLICATION = "gaggamagga.asgi.application"
WSGI_APPLICATION = 'gaggamagga.wsgi.application'

# Django Channels
CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "channels_redis.core.RedisChannelLayer",
        "CONFIG": {
            "hosts": [("redis", 6379)],
        },
    },
}

# Database
# https://docs.djangoproject.com/en/4.1/ref/settings/#databases
POSTGRES_DB = os.environ.get('POSTGRES_DB', '')
if POSTGRES_DB:
    DATABASES = {
        'default': {
            'ENGINE': 'django.db.backends.postgresql',
            'NAME': POSTGRES_DB,
            'USER': os.environ.get('POSTGRES_USER', ''),
            'PASSWORD': os.environ.get('POSTGRES_PASSWORD', ''),
            'HOST': os.environ.get('POSTGRES_HOST', ''),
            'PORT': os.environ.get('POSTGRES_PORT', ''),
        }
}

else:
    DATABASES = {
        'default': {
            'ENGINE': 'django.db.backends.sqlite3',
            'NAME': BASE_DIR / 'db.sqlite3',
            'TEST' : {
            'NAME' : BASE_DIR / "db.sqlite3",
            },
        }
    }

# CSRF settings
CSRF_TRUSTED_ORIGINS = CORS_ORIGIN_WHITELIST

Notification

1. websocket → 비동기

  - 동기(http) : 프론트에서 리퀘스트 -> 백엔드는 리스폰스를 통해 통신한다.

  - 비동기(웹소켓) : 프론트가 리퀘스트를 보내건 말건 백엔드가 지켜보고 있다. 

2. wsgi와 asgi

  • wsgi : django에서 기본적으로 제공
  • asgi : 기존의 http 프로토콜은 물론 websocket도 다룰 수 있도록 함.

3. asgi.py

import os

from django.core.asgi import get_asgi_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "gaggamagga.settings")
django_asgi_app = get_asgi_application()

from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter

from notification import routing

application = ProtocolTypeRouter(
    {
        "http": django_asgi_app,
        "websocket" : AuthMiddlewareStack(
            URLRouter(
                routing.websocket_urlpatterns
            )
        )
    }
)

비동기로 사용할 때(웹 소켓)에는 아래의 routing.py를 urls.py처럼 사용한다. 위의 코드에서 URLRouter에 routing.websocket_urlpatterns를 호출해서 사용한다.

 

4. routing.py

routing.py에서는 path가 아닌 re_path를 사용하는 것이 차이점이다.

from django.urls import re_path

from . import consumers

websocket_urlpatterns = [
    re_path(r"ws/notification/(?P<room_name>\w+)/$",consumers.NotificationConsumer.as_asgi()),
]

 

5. consumers.py -> views.py

파이썬 파일

from channels.generic.websocket import AsyncWebsocketConsumer
from channels.db import database_sync_to_async

import json

from .models import Notification

class NotificationConsumer(AsyncWebsocketConsumer):
    @database_sync_to_async
    def create_notification(self, message, author):
        return Notification.objects.create(content=message, user_id=author)
    
    # 소켓이 열렸을 때
    async def connect(self) :
        self.room_name = self.scope["url_route"]["kwargs"]["room_name"]
							# scope를 통해 django에 접근할 수 있다.
        await self.channel_layer.group_add(self.room_name, self.channel_name)		# 채널, 방 이름으로 추가
        await self.accept()		# 알림방에 입장을 허용
        
    async def disconnect(self, code):
        
        # 그룹 떠남
        await self.channel_layer.group_discard(self.room_name, self.channel_name)

    async def receive(self, text_data):
        text_data_json = json.loads(text_data)
        message = text_data_json["message"]
        author = text_data_json["author"]
        user_id = text_data_json["user_id"]
        
        event = {
            'type': 'send_message',
            'message': message,
            "author" : author,
            "user_id" : user_id
        }
        
        # DB 저장
        if int(author) != user_id :
            save_message = await self.create_notification(message=message, author=author)
            
        # 그룹에 메시지 보내기
        await self.channel_layer.group_send(self.room_name, event)         

    # 그룹에서 메시지 받기
    async def send_message(self, event):
        message = event["message"]
        author = event["author"]
        user_id = event["user_id"]

        # 웹소켓에 메시지 보내기
        await self.send(text_data=json.dumps({'message':message, 'author':author, "user_id":user_id}))

 

자바스크립트

// 알람 
const notificationSocket = new WebSocket(
    'wss://'
    + "www.back-gaggamagga.tk"       // 백엔드 주소
    + '/ws/notification/'            // routing.py의 주소
    + author_id                      // 알람을 받을 사람
    + '/'
);

notificationSocket.onmessage = async function (e) {
    const data = JSON.parse(e.data);
    const alarmBox = document.querySelector('.alarm')
    if (payload_parse.user_id == author_id) {
        const alarmContent = document.createElement('div')
        alarmContent.style.display = "flex"
        alarmContent.style.height = "10vh"
        alarmContent.innerHTML = data.message
        alarmBox.appendChild(alarmContent)

        const response = await fetch(`${backendBaseUrl}/notification/${payload_parse.user_id}/`, {
            headers: {
                "authorization": "Bearer " + localStorage.getItem("access")
            },
            method: 'GET'
        })
        .then(response => response.json())
        const notificationButton = document.createElement('button')
        const notificationButtonText = document.createTextNode('확인')
        notificationButton.appendChild(notificationButtonText)
        notificationButton.onclick = async function () {
            await fetch(`${backendBaseUrl}/notification/alarm/${response[0].id}/`, {
                headers: {
                    'content-type': 'application/json',
                    "authorization": "Bearer " + localStorage.getItem("access")
                },
                method: 'PUT',
                body: ''
            })
            alarmBox.innerHTML = ""
            getNotification()
        }
        alarmContent.appendChild(notificationButton)
    }
};

notificationSocket.onclose = function (e) {
    console.error('소켓이 닫혔어요 ㅜㅜ');
};

function alarm() {
    if (payload_parse.user_id != author_id) {
        const message = `<img src="https://cdn-icons-png.flaticon.com/512/1827/1827422.png" class="modal-icon"><a style="cursor:pointer;margin:auto; text-decoration:none;" href="review_detail.html?id=${review_id}&place=${place_id}&author=${author_id}">
        <p class="alarm-content">후기에 덧글이 달렸습니다.</p></a>`
        notificationSocket.onopen = () => notificationSocket.send(JSON.stringify({
            'message': message,
            "author": author_id,
            "user_id": payload_parse.user_id
        }
        ))
    }
}

 

 

 

 

 

 

번외

1. urls.py

from django.urls import path

from . import views

urlpatterns = [
    # Notification
    path('<int:user_id>/', views.NotificationView.as_view(), name='notification'),
    path('alarm/<int:notification_id>/', views.NotificationDetailView.as_view(), name="notification_detail"),
]

2. views.py

from rest_framework.views import APIView
from rest_framework.generics import get_object_or_404
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import IsAuthenticated

from django.db.models import Q

from drf_yasg.utils import swagger_auto_schema

from .models import Notification
from .serializers import NotificationSerializer, NotificationDetailSerializer


class NotificationView(APIView):
    permission_classes = [IsAuthenticated]

    # 읽지 않은 해당 알람 리스트
    @swagger_auto_schema(
        operation_summary="읽지 않은 해당 알람 리스트",
        responses={200: "성공", 201: "인증 에러", 500: "서버 에러"},
    )
    def get(self, request, user_id):
        notifiactions = Notification.objects.filter(Q(user_id=user_id) & Q(is_seen=False)).order_by("-created_at")
        serializer = NotificationSerializer(notifiactions, many=True)
        return Response(serializer.data, status=status.HTTP_200_OK)


class NotificationDetailView(APIView):
    permission_classes = [IsAuthenticated]

    # 알람 읽음 처리
    @swagger_auto_schema(
        operation_summary="알람 읽음 처리",
        responses={200: "성공", 400: "입력값 에러", 401: "인증에러", 403: "접근 권한 없음 ", 404: "찾을 수 없음", 500: "서버 에러"},
    )
    def put(self, request, notification_id):
        notification = get_object_or_404(Notification, id=notification_id)
        if request.user == notification.user:
            serializer = NotificationDetailSerializer(notification, data=request.data, partial=True)
            if serializer.is_valid():
                serializer.save(is_seen=True)
                return Response({"message": "읽음 처리 완료"}, status=status.HTTP_200_OK)
            return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
        return Response({"message": "접근 권한 없음"}, status=status.HTTP_403_FORBIDDEN)

 

class NotificationConsumer(AsyncWebsocketConsumer):
    @database_sync_to_async
    def create_notification(self, message, author):
        return Notification.objects.create(content=message, user_id=author)

 

window.onload = () => {
    getNotification() //알람
}


notificationSocket.onmessage = async function (e) {
    const data = JSON.parse(e.data);
    const alarmBox = document.querySelector('.alarm')
    if (payload_parse.user_id == author_id) {
        const alarmContent = document.createElement('div')
        alarmContent.style.display = "flex"
        alarmContent.style.height = "10vh"
        alarmContent.innerHTML = data.message
        alarmBox.appendChild(alarmContent)

        const response = await fetch(`${backendBaseUrl}/notification/${payload_parse.user_id}/`, {
            headers: {
                "authorization": "Bearer " + localStorage.getItem("access")
            },
            method: 'GET'
        })
        .then(response => response.json())
        const notificationButton = document.createElement('button')
        const notificationButtonText = document.createTextNode('확인')
        notificationButton.appendChild(notificationButtonText)
        notificationButton.onclick = async function () {
            await fetch(`${backendBaseUrl}/notification/alarm/${response[0].id}/`, {
                headers: {
                    'content-type': 'application/json',
                    "authorization": "Bearer " + localStorage.getItem("access")
                },
                method: 'PUT',
                body: ''
            })
            alarmBox.innerHTML = ""
            getNotification()
        }
        alarmContent.appendChild(notificationButton)
    }
};

async function getNotification() {

    const response = await fetch(`${backendBaseUrl}/notification/${payload_parse.user_id}/`, {
        headers: {
            "authorization": "Bearer " + localStorage.getItem("access")
        },
        method: 'GET'
    })
        .then(response => response.json())
    response.forEach(notification => {
        const alarmBox = document.querySelector('.alarm')


        let alarmContent = document.createElement('div')
        alarmContent.setAttribute("id", `alarm${notification.id}`)
        alarmContent.innerHTML = notification.content
        alarmContent.style.display = "flex"
        alarmContent.style.height = "10vh"
        alarmBox.appendChild(alarmContent)

        const notificationButton = document.createElement('button')
        const notificationButtonText = document.createTextNode('확인')
        notificationButton.appendChild(notificationButtonText)
        notificationButton.onclick = async function () {
            await fetch(`${backendBaseUrl}/notification/alarm/${notification.id}/`, {
                headers: {
                    'content-type': 'application/json',
                    "authorization": "Bearer " + localStorage.getItem("access")
                },
                method: 'PUT',
                body: ''
            })
            alarmBox.innerHTML = ""
            getNotification()

        }

        alarmContent.appendChild(notificationButton)
    })
}

 

반응형

댓글