Search

[파이썬으로 살펴보는 아키텍처 패턴] 챕터 4 첫번째 유스 케이스: 플라스크 API와 서비스 계층

제목
Tag
작성일
2장의 저장소 패턴에서 다뤘던 할당 프로젝트로 돌아와서, 시스템의 유스 케이스를 정의하는 서비스 계층 패턴을 소개.
서비스 계층과 대화하는 플라스크 API를 추가해 도메인 모델에 대한 진입점 역할 부여
서비스 계층은 AbstractRepository에 의존하므로 프로덕션 코드는 SqlAlchemyRepository를 사용하지만, 테스트 할때는 FakeRepository를 사용해 unittest를 수행 할 수 있다

애플리케이션을 실세계와 연결하기

1.
플라스크를 사용해 allocate 도메인 서비스 앞에 API 엔드포인트 위치. 데이터베이스 세션과 저장소 연결. 이렇게 만든 시스템은 엔드투엔드 테스트와 빠르게 대충 만든 몇가지 SQL문을 활용해 테스트
2.
서비스 계층을 리팩터링해서 플라스크와 도메인 모델 사이에 유스케이스를 담는 추상화 역할을 할 수 있게 함. 몇가지 서비스 계층 테스트를 만들고 테스트들이 FakeRepository 어떻게 사용하는지 보여줌
3.
서비스 계층의 기능을 여러 유형의 파라미터로 실험, 원시 데이터 타입을 사용하면 서비스 계층의 클라이언트를 모델 계층으로부터 분리할 수 있다는 사실을 보여줌

첫번째 엔드투엔드 테스트

실제 API 엔드포인트와 실제 데이터베이스를 사용하는 엔드투엔드 테스트 작성
첫번째 API 테스트
@pytest.mark.usefixtures('restart_api') def test_api_returns_allocation(add_stock): sku, othersku = random_sku(), random_sku('other')# 1 earlybatch = random_batchref(1) laterbatch = random_batchref(2) otherbatch = random_batchref(3) add_stock([# 2 (laterbatch, sku, 100, '2011-01-02'), (earlybatch, sku, 100, '2011-01-01'), (otherbatch, othersku, 100, None), ]) data = {'orderid': random_orderid(), 'sku': sku, 'qty': 3} url = config.get_api_url()# 3 r = requests.post(f'{url}/allocate' , json=data) assert r.status code == 201 assert r.json()['batchref'] == earlybatch
Python
복사
1.
uuid 모듈을 사용해 난수 문자열을 만드는 함수
2.
SQL을 사용해 DB row insert 과정을 숨겨주는 도우미 픽스쳐
3.
config.py는 설정정보는 저장하는 모델

직접 구현하기

가장 뻔한 방법으로 모든 요소를 구현한 코드
from flask import Flask, request from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker import config import model import orm import repository import services orm.start_mappers() get_session = sessionmaker(bind=create_engine(config.get_postgres_uri())) app = Flask(__name__) @app.route("/allocate", methods=["POST"]) def allocate_endpoint(): session = get_session() repo = repository.SqlAlchemyRepository(session) line = model.OrderLine( request.json["orderid"], request.json["sku"], request.json["qty"], ) batchref = services.allocate(line, repo, session) return {"batchref": batchref}, 201
Python
복사
할당이 영속화됐는지 테스트
@pytest.mark.usefixtures("restart_api") def test_allocations_are_persisted(add_stock): sku = random_sku() batch1, batch2 = random_batchref(l), random_batchref(2) order1, order2 = random_orderid(l), random_orderid(2) add_stock( [ (laterbatch, sku, 100, "2011-01-02"), (earlybatch, sku, 100, "2011-01-01"), (otherbatch, othersku, 100, None), ] ) line1 = {’orderid’: order1, ’sku’: sku, ’qty’: 10} line2 = {’orderid’: order2, ’sku’: sku, ’qty’: 10} url = config.get_api_url() # 첫번째 주문은 배치 1에 있는 모든 재고를 소진 r = requests.post(f"{url}/allocate", json=line1) assert r.status_code == 201 assert r.json()['batchref'] == batch1 # 두번째 주문은 배치2로 가야함 r = requests.post(f"{url}/allocate", json=line2) assert r.status_code == 201 assert r.json()['batchref'] == batch2
Python
복사
위와 같은 경우 테스트하려면 데이터베이스 커밋을 추가 할 수 밖에 없음

데이터베이스 검사가 필요한 오류조건

오류 처리를 약간 추가한다고 가정 -> 도메인이 재고가 소진된 SKU에 대해 예외가 발생한다면? 존재하지 않는 SKU에 대해 예외가 발생한다면? 이런 경우 도메인이 알 수 없고 알아서도 안됨. 이런 로직은 데이터베이스 계층에서 구현해야 하는 데이터 무결성 검사에 속함
E2E 수준에서 이루어지는 추가 테스트
@pytest.mark.usefixtures('restart_api') def test_400_message_for_out_of_stock(add_stock):# 1 sku, smalL_batch, large_order = random_sku() , random_batchref() , random orderid() add_stock([ (smalL_batch, sku, 10,2011-01-01), ]) data = {’orderid': large_order, ’sku' : sku, ’qty’ : 20} url = config.get_api_url() r = requests.post(f'{url}/allocate', json=data) assert r.status code == 400 assert r.json()[’message’] == f’Out of stock for sku {sku}@pytest.mark.usefixtures('restart_api') def test_400_message_for_invalid_sku():# 2 unknown_sku, orderid = random_sku(), random_orderid() data = {’orderid’ : orderid, ’sku’ : unknown_sku, ’qty’ : 20} url = config.get_api_url() r = requests.post(f'{url}/allocate', json=data) assert r.status code == 400 assert r.json()['message'] == f'Invalid sku {unknown_sku}'
Python
복사
1.
첫번째 테스트는 재고보다 더 많은 단위를 할당하려고 시도
2.
두번째 테스트는 SKU가 존재하지 않는다
플라스크 앱 내부에 로직을 구현해 복잡해지기 시작하는 플라스크앱
@app.route("/allocate", methods=["POST"]) def allocate_endpoint(): session = get_session() repo = repository.SqlAlchemyRepository(session) line = model.OrderLine( request.json["orderid"], request.json["sku"], request.json["qty"], ) if not is_valid_sku(line.sku, batches): return jsonify({’message’: f’Invalid sku {line.sku}}), 400 try: batchref = services.allocate(line, repo, session) except (model.OutOfStock, services.InvalidSku) as e: return {"message": str(e)}, 400 return {"batchref": batchref}, 201
Python
복사

서비스 계층 소개와 서비스 계층 테스트용 FakeRepository 사용

플라스크 앱은 오케스트레이션일을 하는데, 저장소에서 여러가지를 가져온 후 데이터베이스 상태에 따라 입력을 검증하며 오류 처리 및 데이터베이스에 커밋하는 작업을 포함한다. 이런 작업 대부분은 웹 API 엔드포인트와는 관련이 없기 때문에, 오케스트레이션은 엔드투엔드 테스트에서 실제로 테스트해야 하는 대상이 아님
서비스 계층에서 가짜 저장소를 사용한 단위 테스트
class FakeRepository(repository.AbstractRepository): def __init__(self, batches): self._batches = set(batches) def add(self, batch): self._batches.add(batch) def get(self, reference): return next(b for b in self._batches if b.reference == reference) def list(self): return list(self._batches) class FakeSession: committed = False def commit(self): self.committed = True def test_returns_allocation(): line = model.OrderLine("o1", "COMPLICATED-LAMP", 10) batch = model.Batch("b1", "COMPLICATED-LAMP", 100, eta=None) repo = FakeRepository([batch]) result = services.allocate(line, repo, FakeSession()) assert result == "b1" def test_error_for_invalid_sku(): line = model.OrderLine("o1", "NONEXISTENTSKU", 10) batch = model.Batch("b1", "AREALSKU", 100, eta=None) repo = FakeRepository([batch]) with pytest.raises(services.InvalidSku, match="Invalid sku NONEXISTENTSKU"): services.allocate(line, repo, FakeSession()) def test_commits(): line = model.OrderLine("o1", "OMINOUS-MIRROR", 10) batch = model.Batch("b1", "OMINOUS-MIRROR", 100, eta=None) repo = FakeRepository([batch]) session = FakeSession() services.allocate(line, repo, session) assert session.committed is True
Python
복사
Fake 세션은 6장에서 더 멋진것으로 대치. 현재는 가짜 commit이 있으므로 E2E계층으로부터 세번째 테스트를 마이그레이션해 가져옴

전형적인 서비스 함수

기본적인 할당 서비스 (services.py)
class InvalidSku(Exception): pass def is_valid_sku(sku, batches): return sku in {b.sku for b in batches} def allocate(line: OrderLine, repo: AbstractRepository, session) -> str: batches = repo.list()# 1if not is_valid_sku(line.sku, batches):# 2raise InvalidSku(f"Invalid sku {line.sku}") batchref = model.allocate(line, batches)# 3 session.commit()# 4return batchref
Python
복사
1.
저장소에서 어떤 객체들을 가져옴
2.
현재 세계를 바탕으로 요청을 검사하거나 assertion으로 검증
3.
도메인 서비스 호출
4.
모든 단계가 정상으로 실행됐다면 변경한 상태를 저장하거나 업데이트
서비스 계층에 위임하는 플라스크 앱
@app.route("/allocate", methods=["POST"]) def allocate_endpoint(): session = get_session()# 1 repo = repository.SqlAlchemyRepository(session)# 1 line = model.OrderLine( request.json["orderid"], request.json["sku"], request.json["qty"],# 2 ) try: batchref = services.allocate(line, repo, session)# 2except (model.OutOfStock, services.InvalidSku) as e: return {"message": str(e)}, 400# 3return {"batchref": batchref}, 201# 3
Python
복사
1.
데이터베이스 세션과 저장소 객체들도 인스턴스화
2.
사용자 명령들을 웹요청에서 추출하고, 추출한 명령들을 도메인 서비스에 넘김
3.
적절한 상태코드가 있는 JSON 응답 반환
모든 오케스트레이션 로직은 유지 케이스/서비스 계층에 들어가고, 도메인 로직은 도메인에 그대로 남음
마지막으로 자신있게 E2E 테스트를 단 두가지 정상경로와 비정상 경로로 정리 할 수 있음
E2E는 정상과 비정상 경로만 테스트
@pytest.mark.usefixtures("restart_api") def test_happy_path_returns_201_and_allocated_batch(add_stock): sku, othersku = random_sku(), random_sku("other") earlybatch = random_batchref(1) laterbatch = random_batchref(2) otherbatch = random_batchref(3) add_stock( [ (laterbatch, sku, 100, "2011-01-02"), (earlybatch, sku, 100, "2011-01-01"), (otherbatch, othersku, 100, None), ] ) data = {"orderid": random_orderid(), "sku": sku, "qty": 3} url = config.get_api_url() r = requests.post(f"{url}/allocate", json=data) assert r.status_code == 201 assert r.json()["batchref"] == earlybatch @pytest.mark.usefixtures("restart_api") def test_unhappy_path_returns_400_and_error_message(): unknown_sku, orderid = random_sku(), random_orderid() data = {"orderid": orderid, "sku": unknown_sku, "qty": 20} url = config.get_api_url() r = requests.post(f"{url}/allocate", json=data) assert r.status_code == 400 assert r.json()["message"] == f"Invalid sku {unknown_sku}"
Python
복사

모든 요소에 폴더를 넣고 각 부분이 어떤 위치에 있는지 살펴보기

애플리케이션이 커짐에 따라 디렉터리 구조를 깔끔하게 다듬을 필요가 있다.
├── config.py ├── domain.py │ ├── __init__.py │ └── model.py ├── sevice_layer.py │ ├── __init__.py │ └── services.py ├── adapter.py │ ├── __init__.py │ ├── repository.py │ └── orm.py ├── entrypoints.py │ ├── __init__.py │ ├── flask_app.py └── tests.py
Plain Text
복사

서비스 계층의 장단점

장점

애플리케이션의 모든 유스케이스를 넣을 유일한 위치
정교한 도메인 로직을 API 뒤에 감춤
HTTP와 말하는 기능을 할당을 말하는 기능으로부터 깔끔하게 분리
저장소 패턴 및 FakeRepository와 조합하명 도메인 계층보다 더 높은 수준에서 테스트 작성 가능.

단점

앱이 순수하게 웹앱인 경우 컨트롤러/뷰 함수는 모든 유스케이스를 넣을 위치가 됨
서비스 계층도 또 다른 추상화 계층에 불과함
서비스 계층에 너무 많은 기능을 넣으면 빈약한 도메인 안티패턴이 생길 수 있음
풍부한 도메인 모델로 얻을 수 있는 이익 대부분은 단순히 컨트롤러에서 로직을 뽑아내 모델 계층으로 보내는 것만으로 얻을 수 있음 (대부분 얇은 컨트롤러와 두꺼운 모델로 충분)