일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 | 31 |
- 하이브리드앱
- Adapter 패턴
- Actor
- 엔터프라이즈 블록체인
- 스위프트
- akka 강좌
- play2 강좌
- 이더리움
- 파이썬 머신러닝
- 스칼라 동시성
- CORDA
- Akka
- 플레이프레임워크
- 그라파나
- 파이썬 데이터분석
- 파이썬 동시성
- 파이썬 강좌
- hyperledger fabric
- Hyperledger fabric gossip protocol
- Play2
- 블록체인
- 주키퍼
- 스칼라
- 스칼라 강좌
- Play2 로 웹 개발
- 안드로이드 웹뷰
- 하이퍼레저 패브릭
- 파이썬
- play 강좌
- Golang
- Today
- Total
HAMA 블로그
Flask,VueJS,RethinkDB 로 파일 저장 서비스 만들기 - (1) [번역] 본문
Flask,VueJS,RethinkDB 로 파일 저장 서비스 만들기 - (1) [번역]
[하마] 이승현 (wowlsh93@gmail.com) 2017. 7. 27. 10:16Flask,VueJS 와 RethinkDB 를 사용하여 파일 저장 서비스 구축하기 - (1)
소개
간단한 파일 저장 서비스를 작성하는 방법에 대해 소개 해 보려 합니다. 프런트 엔드를 처리하기 위해서는 VueJS를 사용할 것이고, 백엔드를 위해서는 Flask, 데이터 저장을 위해 RethinkDB를 사용할 것입니다. 진행 하는 도중에 여러 기능들에 대해 계속 소개 할 것 이므로 참고하시구요.
이 연재의 첫 번째 파트에서는 응용 프로그램의 백엔드 구축에 중점을 둘 것입니다.
우리가 만들려는 API 는 무엇?
다음과 같은 API를 만들 것 입니다. 우리가 만들려는 파일 저장 서비스를 사용하여 사용자는 다음과 같은 기능을 수행 할 수 있게 됩니다.
계정 만들기
로그인
폴더 및 하위 폴더 만들기 및 관리
파일을 폴더에 업로드
파일 속성보기
파일 편집 및 삭제
API의 경우 다음과 같은 엔드포인트로 이루어져 있습니다. POST,GET,PUT,DELETE 으로 구분 되었습니다.
POST /api/v1/auth/login
- 사용자의 로그인에 사용됨.POST /api/v1/auth/register
- 사용자를 등록하기 위해 사용됨GET /api/v1/user/<user_id>/files/
- user_id 를 가진 사용자에 대한 파일들을 리스팅하기 위해 사용됩니다. POST /api/v1/user/<user_id>/files/
- user_id 를 가진 사용자에 대한 새로운 파일들을 만들 때 사용됩니다.GET /api/v1/user/<user_id>/files/<file_id>
- file_id 에 해당하는 파일을 얻기 위해 사용됩니다.PUT /api/v1/user/<user_id>/files/<file_id>
- file_id 에 해당하는 파일을 편집하기 위해 사용됩니다.DELETE /api/v1/user/<user_id>/files/<file_id>
- file_id 에 해당하는 파일을 삭제하기 위해 사용됩니다.이제 실제 프로젝트를 위한 과정에 대해 알아 봅시다.
Setup 하기
먼저 프로젝트를 위한 디렉토리를 구성 해 봅시다. 아래는 Flask 웹어플리케이션에서 권장되는 구조입니다.
-- /api
-- /controllers
-- /utils
-- models.py
-- __init__.py
-- /templates
-- /static
-- /lib
-- /js
-- /css
-- /img
-- index.html
-- config.py
-- run.py
API와 관련 한모듈과 패키지들의 경우 /api 디렉토리에 저장되며, 아래로는 모델을 처리하는 models.py 모듈이 있고 라우팅 목적으로 대부분 사용되는 컨트롤러는 /controllers 패키지에 모듈로 저장됩니다.
/api/__init__.py 에는 REST API 를 위한 라우트 및 앱 생성 함수가 추가됩니다. 이 파일 안의 create_app () 함수를 통하여 다른 구성으로 여러 개의 app 인스턴스를 만들 수 있습니다. (유연성 증대) 이는 응용 프로그램에 대한 테스트를 작성 할 때 특히 유용합니다.
자! __init__.py 를 보시죠.
from flask import Flask, Blueprint
from flask_restful import Api
from config import config
def create_app(env):
app = Flask(__name__)
app.config.from_object(config[env])
api_bp = Blueprint('api', __name__)
api = Api(api_bp)
# Code for adding Flask RESTful resources goes here
app.register_blueprint(api_bp, url_prefix="/api/v1")
return app
여기에서는 env 매개 변수를 사용하는 create_app () 함수를 만들었습니다. 실질적으로 env는 개발, 프로덕션 및 테스트 중 하나 여야 합니다. 즉 env에 제공된 값을 통하여 특정 구성을 로드합니다. 이 구성 정보는 config.py 안에서사전 (key,value) 구조로 저장 되 있습니다.
이어서 config.py 를 보시죠.
class Config(object):
DEBUG = True
TESTING = False
DATABASE_NAME = "papers"
class DevelopmentConfig(Config):
SECRET_KEY = "S0m3S3cr3tK3y"
config = {
'development': DevelopmentConfig,
'testing': DevelopmentConfig,
'production': DevelopmentConfig
}
현재는 Flask에 디버그 모드로 실행 할 지 여부를 알려주는 DEBUG 매개 변수와 같은 몇 가지 구성 매개 변수 만 지정했습니다. 추가적으로는 모델에서 참조 해야할 DATABASE_NAME 매개 변수와 JWT 토큰 생성을위한 SECRET_KEY 매개 변수도 지정했구요. 현재는 모든 환경에서 동일한 설정을 사용하고 있게 됩니다.
__init__.py 를 보면, 사용자에게 영향을 주지 않고 핵심 기능의 구현을 변경해야 한다고 생각 한 경우에는 Flask Blueprint를 사용하여 API를 버전화하고 Flask-RESTful API 객체인 api를 초기화 합니다. 잠시 후 이 객체를 사용하여 새 API 엔드포인트를 앱에 추가하는 방법을 보여 드릴 것 입니다.
다음으로 서버를 실행하기 위한 코드를 루트 디렉토리의 run.py에 넣습니다. Flask-Script를 사용하여 응용 프로그램에 대한 추가 CLI 명령을 만듭니다.
from flask_script import Manager
from api import create_app
app = create_app('development')
manager = Manager(app)
@manager.command
def migrate():
# Migration script
pass
if __name__ == '__main__':
manager.run()
여기서는 flask_script.Manager 클래스를 사용하여 서버 실행을 추상화하고 CLI에 대한 새 명령을 추가 할 수 있게 했습니다. migrate()는 우리 모델에 필요한 모든 테이블을 자동으로 생성하는 데 사용 될 수 있습니다. 이것은 현재 연습을 위한 단순화 된 해결책이며, 여기까지 모두 정상 상태라면 오류가 없어야 합니다.
이제 커맨드라인에서 대망의 ~~!!!!
python run.py runserver
기본 포트 5000으로 서버를 실행 해 보시길 바랍니다.
사용자 모델
다음은 모델에 대한 코드를 추가해 보겠습니다. 이 애플리케이션의 경우 두 가지 모델이 필요하지만 튜토리얼의 이 단계에서는 사용자 모델만 작성합니다. 먼저 RethinkDB 데이터베이스에 대한 연결 개체를 만듭니다.
import rethinkdb as r
from flask import current_app
conn = r.connect(db="papers")
class RethinkDBModel(object):
pass
어플리케이션 config 에서 데이터베이스 이름을 참조했습니다. 플라스크에는 현재 실행중인 응용 프로그램 인스턴스에 대한 참조를 보유하는 current_app 변수가 있습니다.
빈 RethinkDBModel 클래스를 작성한 이유가 무엇이냐고 묻는다면.. 음, 모델 클래스를 통해 공유하고 싶은 몇 가지 사항이 있을 수 있습니다. 이 클래스는 크로스 모델 공유가 필요한 경우를 위한 것입니다.
우리의 User 클래스는 이 빈 기본 클래스에서 상속 받아서 만들어질 예정이며 User 에는 컨트롤러에서 데이터베이스와 상호 작용하는 데 사용할 수 있는 몇 가지 기능이 들어 있게 됩니다.
일단 create () 함수로 시작해 봅니다. 이 함수는 DB테이블에 사용자 문서를 생성해야 할 때 호출됩니다.
class User(RethinkDBModel):
_table = 'users'
@classmethod
def create(cls, **kwargs):
fullname = kwargs.get('fullname')
email = kwargs.get('email')
password = kwargs.get('password')
password_conf = kwargs.get('password_conf')
if password != password_conf:
raise ValidationError("Password and Confirm password need to be the same value")
password = cls.hash_password(password)
doc = {
'fullname': fullname,
'email': email,
'password': password,
'date_created': datetime.now(r.make_timezone('+01:00')),
'date_modified': datetime.now(r.make_timezone('+01:00'))
}
r.table(cls._table).insert(doc).run(conn)
여기에서는 classmethod 데코레이터를 사용하고 있습니다. 이 데코레이터를 사용하면 메소드 본문 내에서 클래스 인스턴스에 액세스 할 수 있습니다. (역주: 파이썬에서는 this 를 명시적으로 self 라고 말하며, self 는 객체를 지칭하는 것이라면 여기서의 cls 는 해당 클래스를 지칭합니다. 참고로 파이썬에서 classmethod 와 staticmethod 는 다르며 단순하게는 상속구조가 있다면 classmethod 를 사용하시고, 유틸리티성이라면 staticmethod 를 사용하시면 됩니다.) 클래스 인스턴스를 사용하여 메서드 내에서 _table 속성에 액세스합니다. _table은 해당 모델의 테이블 이름을 저장합니다.
또한 여기에는 password 및 password_conf 필드가 동일한 지 확인하는 코드가 추가 되었습니다. 일치 하지 않는 경우 ValidationError가 발생하며 예외는 /api/utils/errors.py 모듈에 저장됩니다.
class ValidationError(Exception):
pass
디버깅 하기 쉽게 하기 위해 이름 붙혀진 예외를 사용하고 있구요.
datetime.now (r.make_timezone( '+ 01:00')) 를 어떻게 사용했는지 잘 보세요. 시간대 없이 datetime.now()를 사용할 때 직면 할 문제가 있는데요. RethinkDB 에서는 문서의 날짜 필드에 표준 시간대 정보를 설정해야합니다. 파이썬 함수는 now() 함수에 대한 매개 변수로 지정하지 않으면 그것을 기본적으로 우리에게 제공하지 않습니다. (역주: datetime.now() 는 현재 타임존에 기반한 값을 리턴함. utcnow() 로 호출하면 UTC 기반, 여기서 우리나라의 경우 +09를 해야할거 같은 느낌이....? 그냥 utcnow 사용하면 될거 같은데 -.-a)
r.make_timezone ( '+ 01:00')을 사용하여 datetime.now () 함수에 사용할 수 있는 timezone 객체를 만들 수 있습니다. 모든 것이 잘되고 예외가 발생하지 않으면, r.table (table_name)이 리턴하는 테이블 오브젝트에 대해 insert () 메소드를 호출 합니다. 이 메소드는 데이터가 들어있는 사전 데이터를 받으며, 선택된 테이블에 새 문서로 저장 될 것입니다.
클래스 메소드인 hash_password () 를 호출했구요. 이 방법은 passlib 패키지의 hash.pbkdf2_sha256 모듈을 사용하여 암호를 안전하게 해싱하여 사용합니다. 그 외에도 암호를 확인하는 방법을 만들어야 합니다.
from passlib.hash import pbkdf2_sha256
class User(RethinkDBModel):
_table = 'users'
@classmethod
def create(cls, **kwargs):
fullname = kwargs.get('fullname')
email = kwargs.get('email')
password = kwargs.get('password')
password_conf = kwargs.get('password_conf')
if password != password_conf:
raise ValidationError("Password and Confirm password need to be the same value")
password = cls.hash_password(password)
doc = {
'fullname': fullname,
'email': email,
'password': password,
'date_created': datetime.now(r.make_timezone('+01:00')),
'date_modified': datetime.now(r.make_timezone('+01:00'))
}
r.table(cls._table).insert(doc).run(conn)
@staticmethod
def hash_password(password):
return pbkdf2_sha256.encrypt(password, rounds=200000, salt_size=16)
@staticmethod
def verify_password(password, _hash):
return pbkdf2_sha256.verify(password, _hash)
pbkdf2_sha256.encrypt () 메소드는 rounds
와salt_size
를 적용하여 패스워드를 해싱합니다. 라이브러리 작동 방법에 대한 자세한 내용은 여기 here 를 참조하십시오. PBKDF2를 사용하기로 결정한 것은 아래와 같습니다.
Security-wise, PBKDF2 is currently one of the leading key derivation functions, and has no known security issues.
-- Quotes from the passlib documentation
verify_password 메소드는 암호 문자열과 해시를 사용하여 호출됩니다. 암호가 유효하면 true 또는 false를 리턴합니다.
이제 validate () 함수로 이동합니다. 이 함수는 메일 주소와 암호를 사용하여 로그인 메서드에서 호출됩니다. 이 함수는 메일 필드를 인덱스로 사용하여 문서가 존재하는지 확인 한 다음 제출된 암호와 DB에 존재 하는 암호를 비교합니다. 또한 토큰 기반 인증을 위해 JWT (JSON Web Token)를 사용 하며, 사용자가 유효한 정보를 제공할 경우 토큰을 생성하게 됩니다.
아래 정도면 models.py에 대한 로직 추가는 어느 정도 완료 되었다고 볼 수 있겠네요.
import os
import rethinkdb as r
from jose import jwt
from datetime import datetime
from passlib.hash import pbkdf2_sha256
from flask import current_app
from api.utils.errors import ValidationError
conn = r.connect(db="papers")
class RethinkDBModel(object):
pass
class User(RethinkDBModel):
_table = 'users'
@classmethod
def create(cls, **kwargs):
fullname = kwargs.get('fullname')
email = kwargs.get('email')
password = kwargs.get('password')
password_conf = kwargs.get('password_conf')
if password != password_conf:
raise ValidationError("Password and Confirm password need to be the same value")
password = cls.hash_password(password)
doc = {
'fullname': fullname,
'email': email,
'password': password,
'date_created': datetime.now(r.make_timezone('+01:00')),
'date_modified': datetime.now(r.make_timezone('+01:00'))
}
r.table(cls._table).insert(doc).run(conn)
@classmethod
def validate(cls, email, password):
docs = list(r.table(cls._table).filter({'email': email}).run(conn))
if not len(docs):
raise ValidationError("Could not find the e-mail address you specified")
_hash = docs[0]['password']
if cls.verify_password(password, _hash):
try:
token = jwt.encode({'id': docs[0]['id']}, current_app.config['SECRET_KEY'], algorithm='HS256')
return token
except JWTError:
raise ValidationError("There was a problem while trying to create a JWT token.")
else:
raise ValidationError("The password you inputted was incorrect.")
@staticmethod
def hash_password(password):
return pbkdf2_sha256.encrypt(password, rounds=200000, salt_size=16)
@staticmethod
def verify_password(password, _hash):
return pbkdf2_sha256.verify(password, _hash)
validate () 메서드에서 주의해야 할 몇 가지~
먼저 filter() 함수가 테이블 객체에서 사용되었습니다. 이 명령은 테이블을 검색하는 데 사전을 사용합니다. 이 함수는 경우에 따라 술어(역주: predicate 로 보통 TRUE, FALSE 를 리턴 해주는 함수) 를 사용할 수도 있습니다. 이 술어는 람다 함수가 될 수 있으며 파이썬 필터 함수 또는 인수로 함수를 사용하는 다른 함수와 비슷하게 모양새가 됩니다. 이 함수는 쿼리에서 반환 한 모든 문서에 액세스하는 데 사용할 수 있는 커서를 반환하며 커서는 반복 가능하므로 for ... in 루프를 사용하여 커서 객체를 반복 할 수 있습니다. 이 경우, 우리는 반복 가능한 객체를 파이썬 리스트 함수를 사용하여 리스트로 변환하도록 선택했습니다.
예상대로, 기본적으로 두 가지 작업을 수행합니다. 메일 주소가 존재하고 있음을 확인 한후에 해당 암호가 정확한지를 확인하는거죠. 첫 번째 부분에서는 기본적으로 컬렉션을 계산하는데 이 값이 비어 있으면 오류가 발생합니다. 두 번째 부분에서는 verify_password () 함수를 호출하여 데이터베이스에서 해시와 함께 제공된 비밀번호를 비교합니다. 일치하지 않으면 예외가 발생합니다.
또한 jwt.encode()를 사용하여 JWT 토큰을 만들고 이를 컨트롤러에 반환하는 방법도 확인 해 주세요. 이 방법은 매우 간단하며 여기 here 설명서를 볼 수 있습니다.
컨트롤러로 넘어 갑시다. 우리는 이 모델에서 뚱뚱한 모델과 슬림한 컨트롤러를 갖는 원칙에 따르려고 했습니다. 대부분의 논리는 모델에 있기 때문입니다. 이렇게 되면 컨트롤러는 API 최종 사용자에게 라우팅 및 오류보고에만 초점을 맞출 수 있게 됩니다.
인증 컨트롤러
인증 컨트롤러의 경우 Flask RESTful 리소스의 하위 클래스를 추가해야 합니다. 단순히 flask_restful.Resource 클래스의 하위 클래스로 만들어 지게 됩니다. 하위 클래스에는 해당 HTTP 요청에 매핑되는 메서드가 자리 잡게 됩니다. 예를 들어 GET 액션을 구현하고자 한다면 Resource 서브 클래스에 get()메소드를 생성 하는 거죠. url 과 이것에 대한 매핑은 api.add_resource() 메소드를 통해 구축되며 조금 있다 살펴 볼 예정입니다.
간단히 두 가지 클래스를 추가해 보겠습니다. 하나는 login에 대한 POST 작업을 하고, 하나는 register 에 관한 것입니다. 이것들은 /api/controllers/auth.py 파일에 작성 됩니다.
from flask_restful import Resource
class AuthLogin(Resource):
def post(self):
pass
class AuthRegister(Resource):
def post(self):
pass
다음으로 /api/__init__.py 파일에서 해당 경로를 만들고 해당 클래스를 참조합니다.
from flask import Flask, Blueprint
from flask_restful import Api
from api.controllers import auth
from config import config
def create_app(env):
app = Flask(__name__)
app.config.from_object(config[env])
api_bp = Blueprint('api', __name__)
api = Api(api_bp)
api.add_resource(auth.AuthLogin, '/auth/login')
api.add_resource(auth.AuthRegister, '/auth/register')
app.register_blueprint(api_bp, url_prefix="/api/v1")
return app
로그인에 관련된 중요 컨트롤러 로직을 추가하기 위해 /api/controllers/auth.py 파일로 다시 돌아 가 봅시다.
여기에 필요한 로직은 일반적으로 인증 시스템에서 수행하는 것과 유사합니다.
from flask_restful import reqparse, abort, Resource
from api.models import User
from api.utils.errors import ValidationError
class AuthLogin(Resource):
def post(self):
parser = reqparse.RequestParser()
parser.add_argument('email', type=str, help='You need to enter your e-mail address', required=True)
parser.add_argument('password', type=str, help='You need to enter your password', required=True)
args = parser.parse_args()
email = args.get('email')
password = args.get('password')
try:
token = User.validate(email, password)
return {'token': token}
except ValidationError as e:
abort(400, message='There was an error while trying to log you in -> {}'.format(e.message))
class AuthRegister(Resource):
def post(self):
parser = reqparse.RequestParser()
parser.add_argument('fullname', type=str, help='You need to enter your full name', required=True)
parser.add_argument('email', type=str, help='You need to enter your e-mail address', required=True)
parser.add_argument('password', type=str, help='You need to enter your chosen password', required=True)
parser.add_argument('password_conf', type=str, help='You need to enter the confirm password field', required=True)
args = parser.parse_args()
email = args.get('email')
password = args.get('password')
password_conf = args.get('password_conf')
fullname = args.get('fullname')
try:
User.create(
email=email,
password=password,
password_conf=password_conf,
fullname=fullname
)
return {'message': 'Successfully created your account.'}
except ValidationError as e:
abort(400, message='There was an error while trying to create your account -> {}'.format(e.message))
앞에서 언급했듯이 대부분의 로직 및 데이터베이스 상호 작용이 모델에 있기 때문에 컨트롤러 논리는 비교적 간단합니다.
AuthLogin 클래스의 post() 함수는 reqparse를 사용하여 필드의 유효성을 검사 한 다음 User.validate ()를 호출하여 전송 된 정보의 유효성을 검증하고 토큰. 오류가 발생하면 이를 포착하여 오류 메시지로 응답하는 역할을 합니다.
마찬가지로 AuthRegister의 경우 사용자로부터 정보를 수집하고 모델의 create () 함수를 호출합니다. 메일 주소, 암호, 암호 확인 및 전체 이름 필드에 대한 값을 User.create () 함수에 전달합니다. 이 함수 또한 뭔가 잘못된 것이 있으면 오류를 발생시킵니다.
python run.py runserver를 사용하여 서버를 실행하여 테스트 해보세요. 여기서 만든 두 개의 엔드포인트에 액세스 할 수 있어야 하며 잘 작동할 것입니다.
여기까지 인증에 관련된 작업을 했으며 이제 이 어플리케이션의 목적인 파일을 위한 모델을 만들어 봅시다.
파일과 폴더 모델
우리는 User 모델로 했던 것과 비슷하게 파일 및 폴더 작업을 위한 간단한 모델을 만들 것입니다. File 모델의 자식으로 Folder 모델을 생성 하는 것을 확인 하세요.
File model
파일들은 파일 시스템에 편평하게 저장 됩니다. 모든 사용자는 그들의 파일이 저장되는 폴더를 갖게 되지만, 데이터 의 구조는 논리적이며 데이터베이스에 저장됩니다. 이렇게 하면 파일 시스템에 최소한의 쓰기 작업 만 수행 할 수 있게 됩니다. 이렇게 하기 위해 우리는 향후의 프로젝트를 위해 당신에게 유용 할 몇 가지 쿨한 기술을 사용 할 것입니다.
/api/models.py
에 File과 Folder 라는 클래스 모델을 만듭니다.
class File(RethinkDBModel):
_table = 'files'
class Folder(File):
pass
File 모델을 위한 create() 메소드를 생성함으로써 시작해보죠. 이 메소드는 파일을 만드는 데 사용되는 /users/ <user_id>/files/<file_id> 엔드 포인트에 대한 POST 요청을 할 때 호출됩니다.
@classmethod
def create(cls, **kwargs):
name = kwargs.get('name')
size = kwargs.get('size')
uri = kwargs.get('uri')
parent = kwargs.get('parent')
creator = kwargs.get('creator')
# Direct parent ID
parent_id = '0' if parent is None else parent['id']
doc = {
'name': name,
'size': size,
'uri': uri,
'parent_id': parent_id,
'creator': creator,
'is_folder': False,
'status': True,
'date_created': datetime.now(r.make_timezone('+01:00')),
'date_modified': datetime.now(r.make_timezone('+01:00'))
}
res = r.table(cls._table).insert(doc).run(conn)
doc['id'] = res['generated_keys'][0]
if parent is not None:
Folder.add_object(parent, doc['id'])
return doc
먼저 이름, 크기, 파일 URI, 작성자 등과 같은 키워드 인수 사전에서 필요한 모든 정보를 수집합니다. 우리는 부모라고 부르는 매개 변수를 수집했습니다. 이 필드는 이 파일을 저장할 폴더의 ID를 가르킵니다. 파일을 루트 폴더에 저장하려면 이 매개 변수를 전달하지 않도록 선택할 수도 있습니다. 계속 진행하면서 복잡한 중첩 폴더 구조를 만드는 방법을 이해해 봅시다.
여기서 부모가 None 일 경우 parent_id 필드를 0 이 되는 것에 주목하십시오. 이것은 부모가 없는 파일이 생성되는 경우에 처리되며 이것은 0의 ID를 가진 루트 폴더에 파일을 저장하고 있다고 가정합니다.
그래서 우리는 파일에 대한 모든 정보를 사전에 수집하고 insert () 함수를 호출하여 데이터베이스에 저장합니다. 삽입 함수 호출에서 리턴 된 사전에는 새로 생성 된 문서의 ID가 들어 있습니다. 사전을 삽입 한 후 ID 정보를 사전에 채워 사용자에게 반환 할 수 있습니다.
이 메소드의 마지막 3 행에서 부모가 None인지 확인하기 위해 검사를 추가했습니다. 이 파일 관리자 구현에는 폴더가 있으므로 폴더에 파일을 만들 때마다 새로 만든 각 객체를 폴더에 논리적으로 추가해야합니다. 우리는 객체 ID를 저장하려는 폴더의 해당 레코드에 있는 객체 목록에 추가하여 이를 수행합니다. 우리는 add_object라는 Folder 모델에서 생성 할 메소드를 호출하여 이를 수행합니다.
다음으로 우리는 기본 RethinkDBModel 클래스로 돌아가서 자식 클래스에서 재정의 할 수도 있고 하지 않을 수도 있는 유용한 메소드를 만듭니다.
class RethinkDBModel(object):
@classmethod
def find(cls, id):
return r.table(cls._table).get(id).run(conn)
@classmethod
def filter(cls, predicate):
return list(r.table(cls._table).filter(predicate).run(conn))
@classmethod
def update(cls, id, fields):
status = r.table(cls._table).get(id).update(fields).run(conn)
if status['errors']:
raise DatabaseProcessError("Could not complete the update action")
return True
@classmethod
def delete(cls, id):
status = r.table(cls._table).get(id).delete().run(conn)
if status['errors']:
raise DatabaseProcessError("Could not complete the delete action")
return True
여기에서는 RethinkDB get (), filter (), update () 및 delete () 함수에 대한 래퍼 메서드를 만들었습니다. 이러면 하위 클래스는 복잡한 상호 작용을 위해 이러한 함수를 그대로 활용 할 수 있습니다.
파일 모델에서 다음에 만들 메소드는 폴더간에 파일을 이동하는 데 사용할 함수입니다.
@classmethod
def move(cls, obj, to):
previous_folder_id = obj['parent_id']
previous_folder = Folder.find(previous_folder_id)
Folder.remove_object(previous_folder, obj['id'])
Folder.add_object(to, obj['id'])
이 로직은 매우 간단합니다. 이 메소드는 파일 obj를 폴더로 이동할 때 호출됩니다.
먼저 파일의 현재 상위 디렉토리에 대한 현재 폴더 ID를 가져옵니다. 폴더 모델 찾기 함수를 호출하여 폴더 객체를 previous_folder 로 얻은 다음 우리는 두 가지 작업을 수행 합니다. previous_folder에서 이동 시킬 파일 객체를 제거하고 새 폴더에 추가합니다. Folder 클래스의 remove_object () 및 add_object () 메서드를 호출하여 이 작업을 수행 할 수 있습니다.
이제 데이터베이스 작성, 편집, 삭제 등과 같은 파일에 대한 기본 상호 작용을 수행 할 수 있게 되었습니다. 다음으로 우리는 파일에 대한 작업과 매우 유사한 폴더 모델에 대한 로직을 봅시다.
Folder model
@classmethod
def create(cls, **kwargs):
name = kwargs.get('name')
parent = kwargs.get('parent')
creator = kwargs.get('creator')
# Direct parent ID
parent_id = '0' if parent is None else parent['id']
doc = {
'name': name,
'parent_id': parent_id,
'creator': creator,
'is_folder': True,
'last_index': 0,
'status': True,
'objects': None,
'date_created': datetime.now(r.make_timezone('+01:00')),
'date_modified': datetime.now(r.make_timezone('+01:00'))
}
res = r.table(cls._table).insert(doc).run(conn)
doc['id'] = res['generated_keys'][0]
if parent is not None:
cls.add_object(parent, doc['id'], True)
cls.tag_folder(parent, doc['id'])
return doc
@classmethod
def tag_folder(cls, parent, id):
tag = id if parent is None else '{}#{}'.format(parent['tag'], parent['last_index'])
cls.update(id, {'tag': tag})
여기에 있는 create() 메소드는 몇 가지를 제외하곤는 파일과 매우 유사합니다. 가장 먼저 알아야 할 것은 폴더를 만들 때 단지 폴더의 이름과 작성자만 있으면 된다는 것입니다. 우리는 부모 폴더를 결정하기 위해 비슷한 논리를 사용했으며, 결국 add_object () 메서드를 통해 부모 폴더에 폴더를 추가했습니다.
여기서 is_folder 필드는 기본적으로 폴더의 경우 True로, 파일의 경우 False로 설정됩니다.
tag_folder () 는 폴더 이동과 관련하여 나중에 이 작업이 필요할 것입니다. 요약하면 폴더는 파일 트리의 위치에 따라 태그가 지정됩니다. 인덱스는 트리에서의 레벨을 기반으로 합니다. 루트 레벨에 저장된 폴더에는 <id> 태그가 있습니다. 여기서 id는 폴더의 ID입니다. 해당 폴더 아래에 저장된 폴더의 ID는 <id> -n이며, 여기서 n은 계속 증가하는 정수입니다. 결과적으로 중첩 된 폴더는 동일한 패턴을 따르고 <id> -nm 등의 ID를 갖습니다. 폴더를 더 추가 할 때 n이 변경되며 기본값으로 0 인 각 폴더의 last_index 필드에 필요한 데이터가 저장됩니다. 폴더를 이 폴더에 추가하면 last_index 값이 증가합니다. tag_folder () 메서드는 이 모든 것을 처리합니다.
insert () 함수를 호출하여 생성 한 모든 데이터를 데이터베이스에 저장합니다.
다음으로 폴더의 목록 정보를 표시하는 기능을 포함하도록 File 클래스의 find 메서드를 재정의합니다. 나중에 프런트 엔드에 유용합니다.
@classmethod
def find(cls, id, listing=False):
file_ref = r.table(cls._table).get(id).run(conn)
if file_ref is not None:
if file_ref['is_folder'] and listing and file_ref['objects'] is not None:
file_ref['objects'] = list(r.table(cls._table).get_all(r.args(file_ref['objects'])).run(conn))
return file_ref
다음 3 가지 조건을 만족하는 폴더 목록을 보여줍니다.
목록이 True로 설정됩니다. 이 변수를 사용하여 폴더에 포함 된 파일에 대한 정보가 실제로 필요한지 여부를 알 수 있습니다.
file_ref 객체는 실제로 폴더입니다. 문서의 is_folder 필드를 확인하여 이를 확인합니다.
폴더 문서의 개체 목록 내에 개체가 있습니다.
이러한 모든 조건이 충족되면 파일 테이블에서 get_all 메서드를 호출하여 중첩 된 모든 객체를 가져옵니다. 이 메서드는 여러 키를 받아들이고 해당 키가 있는 모든 개체를 반환합니다. 우리는 r.args 메서드를 사용하여 객체 목록을 get_all 메서드의 여러 인수로 변환합니다. 문서의 객체 필드를 반환 된 목록으로 대체합니다. 이 목록에는 각 중첩 된 파일 / 폴더의 세부 정보가 들어 있습니다.
다음으로 이동하여 폴더의 이동 방법을 만듭니다. 이는 태그 작업을 위한 로직을 포함하며 이전에 작성한 파일의 이동 방법과 매우 유사합니다.
@classmethod
def move(cls, obj, to):
if to is not None:
parent_tag = to['tag']
child_tag = obj['tag']
parent_sections = parent_tag.split("#")
child_sections = child_tag.split("#")
if len(parent_sections) > len(child_sections):
matches = re.match(child_tag, parent_tag)
if matches is not None:
raise Exception("You can't move this object to the specified folder")
previous_folder_id = obj['parent_id']
previous_folder = cls.find(previous_folder_id)
cls.remove_object(previous_folder, obj['id'])
if to is not None:
cls.add_object(to, obj['id'], True)
여기서는 먼저 이동하려는 폴더가 실제로 지정되었고 None이 아님을 확인합니다. 이것은 여기에 가정이 지정되지 않은 경우 실제로 이 폴더를 루트 폴더로 이동한다는 가정 때문입니다.
우리는 이동하려는 폴더의 태그를 얻으며 이동하려는 폴더의 태그를 가져오고 태그의 섹션 수를 비교합니다. 이것은 우리가 파일 트리에서 폴더의 레벨을 아는 방법입니다. 이동이 그리 간단하지 않은 경우가 하나뿐인데. 즉, 하위 섹션보다 상위 섹션이 있는 경우입니다. (이 경우 부모는 이 폴더를 이동하려는 폴더를 나타냅니다.) 우리는 폴더를 그 이상의 레벨에 있는 폴더로 이동할 수 있지만, parent_sections가 child_sections 이상인 경우,이 폴더를 이동하려는 폴더가 자체 폴더에 중첩되어 있을 가능성이 있음을 알고 있습니다. 앞에서 언급했듯이 폴더 구조는 순전히 논리적이며 오류가 없는지 확인해야하기 때문에 매우 주의해야 합니다.
이동하려는 폴더가 파일 트리에서 이동하는 폴더 아래에있는 경우 이전 폴더가 후자에 중첩되어 있지 않은지 확인해야합니다. 이것은 단순히 우리가 움직이는 폴더의 child_tag을 보장함으로써 이루어질 수 있으며, parent_tag 문자열을 시작하지 않습니다. 우리는 이것을 구현하기 위해 regex를 사용하고 이런 일이 발생하면 예외를 발생시킵니다.
조금 만 더 집중해 봅시다.거의 끝났습니다!
마지막으로는 앞서 언급 한 add_object () 및 remove_object () 메소드를 작성해 봅시다.
@classmethod
def remove_object(cls, folder, object_id):
update_fields = folder['objects'] or []
while object_id in update_fields:
update_fields.remove(object_id)
cls.update(folder['id'], {'objects': update_fields})
@classmethod
def add_object(cls, folder, object_id, is_folder=False):
p = {}
update_fields = folder['objects'] or []
update_fields.append(object_id)
if is_folder:
p['last_index'] = folder['last_index'] + 1
p['objects'] = update_fields
cls.update(folder['id'], p)
앞서 언급했듯이 폴더 개체의 개체 목록을 수정하여 추가 및 제거 작업을 수행합니다. 하위 폴더를 추가 할 때 폴더의 last_index 변수를 업데이트하는 제약 조건을 적용합니다.
이제 모델을 완성합니다. 컨트롤러로 이동!
파일 컨트롤러
파일 컨트롤러는 파일과 폴더 작업에 사용되므로 이전 컨트롤러보다 약간 더 많은 로직이 있습니다. /api/controllers/files.py 모듈에 컨트롤러에 대한 상용구를 만드는 것으로 시작합니다.
import os
from flask import request, g
from flask_restful import reqparse, abort, Resource
from werkzeug import secure_filename
from api.models import File
BASE_DIR = os.path.abspath(
os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
)
class CreateList(Resource):
def get(self, user_id):
pass
def post(self, user_id):
pass
class ViewEditDelete(Resource):
def get(self, user_id, file_id):
pass
def put(self, user_id, file_id):
pass
def delete(self, user_id, file_id):
pass
이름에서 알 수 있듯이 CreateList 클래스는 로그인 한 사용자의 파일을 만들고 나열하는 데 사용됩니다. ViewEditDelete 클래스는 이름에서 알 수 있듯이 파일보기, 편집 및 삭제에 사용됩니다. 클래스에서 사용하는 메소드는 적절한 HTTP 동작과 일치합니다.
우리는 Resource 클래스의 메소드에서 사용 할 데코레이터를 만들어서 구현을 시작합니다. 이를 /api/utils/decorators.py 모듈로 분리하려고 합니다.
from jose import jwt
from jose.exceptions import JWTError
from functools import wraps
from flask import current_app, request, g
from flask_restful import abort
from api.models import User, File
def login_required(f):
'''
This decorator checks the header to ensure a valid token is set
'''
@wraps(f)
def func(*args, **kwargs):
try:
if 'authorization' not in request.headers:
abort(404, message="You need to be logged in to access this resource")
token = request.headers.get('authorization')
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
user_id = payload['id']
g.user = User.find(user_id)
if g.user is None:
abort(404, message="The user id is invalid")
return f(*args, **kwargs)
except JWTError as e:
abort(400, message="There was a problem while trying to parse your token -> {}".format(e.message))
return func
def validate_user(f):
'''
This decorate ensures that the user logged in is the actually the same user we're operating on
'''
@wraps(f)
def func(*args, **kwargs):
user_id = kwargs.get('user_id')
if user_id != g.user['id']:
abort(404, message="You do not have permission to the resource you are trying to access")
return f(*args, **kwargs)
return func
def belongs_to_user(f):
'''
This decorator ensures that the file we're trying to access actually belongs to us
'''
@wraps(f)
def func(*args, **kwargs):
file_id = kwargs.get('file_id')
user_id = kwargs.get('user_id')
file = File.find(file_id, True)
if not file or file['creator'] != user_id:
abort(404, message="The file you are trying to access was not found")
g.file = file
return f(*args, **kwargs)
return func
login_required 데코레이터는 메소드의 기능에 액세스하기 전에 사용자가 실제로 로그인했는지 확인하는 데 사용됩니다. 우리는이 장식자를 사용하여 유효성을 보장하기 위해 토큰을 디코딩하여 특정 끝점을 보호합니다. id 필드가 토큰에 저장되고 해당 사용자 객체를 검색하려고 시도합니다. 또한 메소드 정의 내에서 액세스 할 수 있도록이 객체를 g.user에 저장합니다.
마찬가지로 다른 사용자가 다른 사용자의 ID로 레이블 된 URL 패턴에 액세스 할 수 없도록하는 validate_user 데코레이터를 만듭니다. 이 유효성은 순수하게 URL의 정보를 기반으로합니다.
마지막으로 belongs_to_user 데코레이터는 파일을 만든 사용자 만 파일에 액세스 할 수 있도록합니다. 이 데코레이터는 파일 문서의 작성자 필드를 제공된 user_id와 대조하여 실제로 검사합니다.
다음은 새 파일 작성 및 파일 목록보기입니다.
class CreateList(Resource):
@login_required
@validate_user
@marshal_with(file_array_serializer)
def get(self, user_id):
try:
return File.filter({'creator': user_id, 'parent_id': '0'})
except Exception as e:
abort(500, message="There was an error while trying to get your files --> {}".format(e.message))
@login_required
@validate_user
@marshal_with(file_serializer)
def post(self, user_id):
try:
parser = reqparse.RequestParser()
parser.add_argument('name', type=str, help="This should be the folder name if creating a folder")
parser.add_argument('parent_id', type=str, help='This should be the parent folder id')
parser.add_argument('is_folder', type=bool, help="This indicates whether you are trying to create a folder or not")
args = parser.parse_args()
name = args.get('name', None)
parent_id = args.get('parent_id', None)
is_folder = args.get('is_folder', False)
parent = None
# Are we adding this to a parent folder?
if parent_id is not None:
parent = File.find(parent_id)
if parent is None:
raise Exception("This folder does not exist")
if not parent['is_folder']:
raise Exception("Select a valid folder to upload to")
# Are we creating a folder?
if is_folder:
if name is None:
raise Exception("You need to specify a name for this folder")
return Folder.create(
name=name,
parent=parent,
is_folder=is_folder,
creator=user_id
)
else:
files = request.files['file']
if files and is_allowed(files.filename):
_dir = os.path.join(BASE_DIR, 'upload/{}/'.format(user_id))
if not os.path.isdir(_dir):
os.mkdir(_dir)
filename = secure_filename(files.filename)
to_path = os.path.join(_dir, filename)
files.save(to_path)
fileuri = os.path.join('upload/{}/'.format(user_id), filename)
filesize = os.path.getsize(to_path)
return File.create(
name=filename,
uri=fileuri,
size=filesize,
parent=parent,
creator=user_id
)
raise Exception("You did not supply a valid file in your request")
except Exception as e:
abort(500, message="There was an error while processing your request --> {}".format(e.message))
목록 작성 방법은 매우 간단합니다. 특정 사용자가 작성하고 루트 디렉토리에 저장 한 모든 파일에 대해 표를 필터링하는 것뿐입니다. 이 끝점에 대해이 데이터를 반환하고 오류가 있으면 예외를 throw합니다.
생성 작업을 위해 우리는 많은 일을합니다. 이 자습서에서는 파일과 폴더가 동일한 끝점을 사용하여 만들어지는 것으로 가정합니다. 파일의 경우 폴더에 업로드하는 경우 parent_id와 함께 파일을 제공해야합니다. 폴더의 경우 이름과 parent_id 값이 필요합니다. 다른 폴더에서이 이름을 다시 생성해야합니다. 폴더의 경우 폴더를 만들도록 지정하라는 요청과 함께 is_folder 필드를 보내야합니다.
이 파일을 폴더에 저장하려면 폴더가 존재하고 유효한 폴더인지 확인해야합니다. 또한 폴더를 만들면 이름 필드를 제공해야합니다.
파일을 만들 때 앞서 언급 한 것처럼 다른 사용자를 위해 특별히 명명 된 폴더에 파일을 업로드합니다. 여기서는 서로 다른 사용자 파일 디렉토리에 / upload / <user_id> 패턴을 사용하고 있습니다. 또한 파일 정보를 사용하여 테이블에 저장할 문서를 채 웁니다.
파일과 폴더 생성을위한 각각의 메소드 인 File.create ()와 Folder.create ()를 호출하여 결론을 맺습니다.
Flask-RESTful과 함께 사용할 수있는 marshal_with 데코레이터를 사용했음을 주목하십시오. 이 데코레이터는 응답 객체의 형식을 지정하고 반환 할 다른 필드 이름과 유형을 나타내는 데 사용됩니다. 아래 file_array_serializer 및 file_serializer의 정의를 참조하십시오.
file_array_serializer = {
'id': fields.String,
'name': fields.String,
'size': fields.Integer,
'uri': fields.String,
'is_folder': fields.Boolean,
'parent_id': fields.String,
'creator': fields.String,
'date_created': fields.DateTime(dt_format= 'rfc822'),
'date_modified': fields.DateTime(dt_format='rfc822'),
}
file_serializer = {
'id': fields.String,
'name': fields.String,
'size': fields.Integer,
'uri': fields.String,
'is_folder': fields.Boolean,
'objects': fields.Nested(file_array_serializer, default=[]),
'parent_id': fields.String,
'creator': fields.String,
'date_created': fields.DateTime(dt_format='rfc822'),
'date_modified': fields.DateTime(dt_format='rfc822'),
}
이것은 /api/controllers/files.py 모듈 또는 별도의 /api/utils/serializers.py 모듈의 맨 위에 추가 할 수 있습니다.
두 serializer의 차이점은 파일 serializer가 응답에 objects 배열을 포함한다는 것입니다. 우리는 객체 응답에 file_serializer를 사용하는 동안 list 응답에 file_array_serializer를 사용합니다.
또한 is_allowed ()라는 함수를 사용하여 업로드하는 모든 파일을 지원할 수 있도록했습니다. 허용 된 모든 확장자 목록을 포함하는 ALLOWED_EXTENSIONS라는 목록을 만들었습니다.
ALLOWED_EXTENSIONS = set(['txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif'])
def is_allowed(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1] in ALLOWED_EXTENSIONS
마지막으로 ViewEditDelete의 리소스 클래스에 /api/controllers/files.py 모듈을 추가하여 결론을 맺습니다.
class ViewEditDelete(Resource):
@login_required
@validate_user
@belongs_to_user
@marshal_with(file_serializer)
def get(self, user_id, file_id):
try:
should_download = request.args.get('download', False)
if should_download == 'true':
parts = os.path.split(g.file['uri'])
return send_from_directory(directory=parts[0], filename=parts[1])
return g.file
except Exception as e:
abort(500, message="There was an while processing your request --> {}".format(e.message))
@login_required
@validate_user
@belongs_to_user
@marshal_with(file_serializer)
def put(self, user_id, file_id):
try:
update_fields = {}
parser = reqparse.RequestParser()
parser.add_argument('name', type=str, help="New name for the file/folder")
parser.add_argument('parent_id', type=str, help="New parent folder for the file/folder")
args = parser.parse_args()
name = args.get('name', None)
parent_id = args.get('parent_id', None)
if name is not None:
update_fields['name'] = name
if parent_id is not None and g.file['parent_id'] != parent_id:
if parent_id != '0'
folder_access = Folder.filter({'id': parent_id, 'creator': user_id})
if not folder_access:
abort(404, message="You don't have access to the folder you're trying to move this object to")
if g.file['is_folder']:
update_fields['tag'] = g.file['id'] if parent_id == '0' else '{}#{}'.format(folder_access['tag'], folder['last_index'])
Folder.move(g.file, folder_access)
else:
File.move(g.file, folder_access)
update_fields['parent_id'] = parent_id
if g.file['is_folder']:
Folder.update(file_id, update_fields)
else:
File.update(file_id, update_fields)
return File.find(file_id)
except Exception as e:
abort(500, message="There was an while processing your request --> {}".format(e.message))
@login_required
@validate_user
@belongs_to_user
def delete(self, user_id, file_id):
try:
hard_delete = request.args.get('hard_delete', False)
if not g.file['is_folder']:
if hard_delete == 'true':
os.remove(g.file['uri'])
File.delete(file_id)
else:
File.update(file_id, {'status': False})
else:
if hard_delete == 'true':
folders = Folder.filter(lambda folder: folder['tag'].startswith(g.file['tag']))
for folder in folders:
files = File.filter({'parent_id': folder['id'], 'is_folder': False })
File.delete_where({'parent_id': folder['id'], 'is_folder': False })
for f in files:
os.remove(f['uri'])
else:
File.update(file_id, {'status': False})
File.update_where({'parent_id': file_id}, {'status': False})
return "File has been deleted successfully", 204
except:
abort(500, message="There was an error while processing your request --> {}".format(e.message))
id를 기반으로 하나의 파일 또는 폴더 객체를 반환하는 get () 메서드를 만들었습니다. 폴더의 경우 목록 정보가 포함됩니다. belongs_to_user 데코레이터를 보면 이것이 어떻게 수행되는지 볼 수 있습니다. 파일의 경우 파일을 다운로드하려는 경우 should_download라는 쿼리 매개 변수를 true로 설정했습니다.
put () 메서드는 파일 및 폴더 정보를 업데이트합니다. 여기에는 파일 및 폴더 이동도 포함됩니다. 파일 이동은 파일 / 폴더의 parent_id 필드를 업데이트하여 실행됩니다. 두 가지에 대한 논리는 파일 및 폴더 모델의 move () 메서드에서 다룹니다.
delete () 메서드는 또한 하드 삭제를 수행할지 여부를 지정하는 쿼리 매개 변수입니다. 하드 h 제의 경우, 레코드가 데이터베이스에서 제거되고 파일 시스템에서 파일이 h 제됩니다. 소프트 삭제의 경우 파일 상태 필드를 false로만 업데이트합니다.
테이블에서 필터링 된 집합을 삭제하고 업데이트하기 위해 RethinkDBModel 클래스에서 update_where () 및 delete_where ()라는 새 메서드를 만들었습니다.
@classmethod
def update_where(cls, predicate, fields):
status = r.table(cls._table).filter(predicate).update(fields).run(conn)
if status['errors']:
raise DatabaseProcessError("Could not complete the update action")
return True
@classmethod
def delete_where(cls, predicate):
status = r.table(cls._table).filter(predicate).delete().run(conn)
if status['errors']:
raise DatabaseProcessError("Could not complete the delete action")
return True
그리고 그게 다입니다!!! 파일 저장 API가 끝났습니다. API를 실행하여 실제로 작동하는지 확인하십시오.
다음 튜토리얼에서는 VueJS를 사용하여 파일 저장소 API에 대한 프론트 엔드 개발에 대해 다룰 것입니다.
여기서 here . 프로젝트의 코드베이스를 확인할 수 있습니다.
*외부패키지리스트*
aniso8601==1.2.0
click==6.7
ecdsa==0.13
Flask==0.12
Flask-Cache==0.13.1
Flask-RESTful==0.3.5
Flask-Script==2.0.5
future==0.16.0
itsdangerous==0.24
Jinja2==2.9.5
MarkupSafe==0.23
passlib==1.7.1
pycrypto==2.6.1
python-dateutil==2.6.0
python-jose==1.3.2
pytz==2016.10
rethinkdb==2.3.0.post6
six==1.10.0
Werkzeug==0.11.15
wheel==0.24.0
안타깝게도 프런트 엔드를 다루는 파트2 는 아직 안나왔습니다.
'Flask' 카테고리의 다른 글
Flask 웹어플리케이션 구축하기 (0) | 2017.06.28 |
---|---|
[Flask 문서번역] Setuptools 을 이용한 디플로이 (0) | 2017.06.28 |
Flask 와 AngularJS 1.x 웹개발 세팅하기 (번역) (0) | 2017.03.15 |
Flask, Redis, Retrofit을 이용한 Android 로그인 서비스 구현하기 (0) | 2017.03.15 |