■ 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 : 데이터 베이스의 테이블과 동일하다. 단지 검색에 특화됐을 뿐.
모든 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이 작용하지 못하므로 정확도를 줄이는 게 좋다.
예시
권장 해결법
models.py
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.)
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)
- nginx 컨테이너에 진입하기 : sudo docker exec -it nginx /bin/sh
- vi etc/nginx/nginx.conf
- http 안에 client_max_body_size 100M; 작성 100M 말고 원하는 용량 아무렇게나 작성 가능
- nginx -t (필수 과정 아님. syntax 검사 과정)
- 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)
})
}
'DEV > Web 개발' 카테고리의 다른 글
Web 개발 :: 12월 다섯째주 WIL18 (0) | 2022.12.29 |
---|---|
Web개발 :: 프로젝트 정리 및 회고 _TIL82 (0) | 2022.12.29 |
Web개발 :: Code Review _ Review CRUD 기능 _TIL80 (1) | 2022.12.28 |
Web개발 :: Code Review _ Place 추천 기능 _TIL79 (0) | 2022.12.28 |
Web개발 :: Code Review _ User 관리 기능 _TIL78 (0) | 2022.12.28 |
댓글