Недавно во время собеседования с известной организацией мне поставили задачу разработать программу сокращения URL-адресов. Вот точная постановка проблемы:
Создайте простую службу сокращения URL-адресов, которая будет принимать URL-адрес в качестве аргумента через REST API и в результате возвращать сокращенный URL-адрес.
URL-адрес и сокращенный URL-адрес должны храниться в памяти приложением.
а. [BONUS] Храните эти вещи не в памяти, а в текстовом файле.
Если я снова запрошу тот же URL-адрес, он должен дать мне тот же URL-адрес, что и раньше, вместо создания нового.
[BONUS] Поместите это приложение в образ Docker, написав файл Docker и предоставив ссылку на образ Docker вместе со ссылкой на исходный код.
Проблема выглядела проще, и я тоже набрал все бонусные баллы, но до сих пор не получил от них ответа (даже отзывов).
Вот мой подход:
- Поскольку они запросили несколько механизмов хранения, я создал фабрику для хранилищ ключ-значение и реализовал хранилища на основе памяти и файлов.
- Я мог придумать два подхода к сокращению URL-адресов: 1) Хеширование 2) База 62 счетчика. База 62 казалась правильным подходом, но они добавили требование, чтобы для одного и того же длинного URL-адреса возвращался такой же сокращенный URL-адрес. Поскольку Base 62 работает со счетчиком автоинкремента, выполнение этого требования без дополнительной памяти / хранилища невозможно. Поэтому я пошел с хешированием длинных URL-адресов (я знаю, что есть вероятность коллизий, я упомянул об этом как о компромиссе).
Вот мое базовое хранилище ключей и значений:
kvstore_base.py
from abc import ABC, abstractmethod
class KVStoreBase(ABC):
"""
Base key-value store class that provides abstract methods required for
any storage mechanism used to store shortened URLs.
"""
def __init__(self, store):
"""
Constructor.
:param store: Object of any storage mechanism.
"""
self.store = store
@abstractmethod
def __getitem__(self, key: str) -> str:
pass
@abstractmethod
def __setitem__(self, key: str, value: str) -> None:
pass
@abstractmethod
def __contains__(self, key: str) -> bool:
pass
file_store.py
import io
from .kvstore_base import KVStoreBase
class FileStore(KVStoreBase):
"""
Memory store, which uses 'dict' data structure to
store the key-value pairs.
"""
def __init__(self):
super().__init__(open("db.txt", "a+"))
def __getitem__(self, key: str) -> str:
"""
Get value for the given key from dict
:param key: Key for which the value is to be retrieved.
:return: value
"""
# iterate over file and search for given key.
try:
# move pointer to initial position in file
self.store.seek(0, io.SEEK_SET)
for line in self.store:
suffix, long_url = line.split()
if suffix == key:
return long_url
except Exception as err:
print(str(err))
return ""
def __setitem__(self, key: str, value: str) -> None:
"""
Set value for the given key into dict.
:param key: Key to be added.
:param value: Value corresponding to the key.
:return: None
"""
# move pointer to the end of the file for writing.
self.store.seek(0, io.SEEK_END)
if self.store.tell() != 0:
self.store.write(f"n{key} {value}")
else:
self.store.write(f"{key} {value}")
self.store.flush()
def __contains__(self, key: str) -> bool:
"""
Check whether the given key is present in the dict.
:param key: Key whose presence is to be checked.
:return: True if key is present, False otherwise.
"""
return True if self.__getitem__(key) else False
def __del__(self) -> None:
"""
Free file resource.
"""
self.store.close()
kvstore_factory.py
from .memory_store import MemoryStore
from .file_store import FileStore
from .kvstore_base import KVStoreBase
class Factory:
"""
KV Store factory to instantiate storage objects.
"""
kvstore_map: dict = {"MEMORY": MemoryStore, "FILE": FileStore}
@staticmethod
def get_instance(instance_type: str) -> KVStoreBase:
"""
Instantiate given KV store class dynamically.
:param instance_type: Type of KV store to be instantiated.
:return: KV store object.
"""
try:
return Factory.kvstore_map[instance_type]()
except KeyError:
raise Exception("Invalid instance requested.")
Я использовал FastAPI для создания конечных точек, вот два основных маршрута (один для сокращения URL-адреса и один для перенаправления):
маршруты / urls.py
import hashlib
from urllib.parse import urlparse
from fastapi import APIRouter, HTTPException, Request
from ..utils.store_connector import StoreConnector
from ..models.urls import Url
from starlette.responses import RedirectResponse
router = APIRouter()
store = StoreConnector().store
def _get_base_url(endpoint_url: str) -> str:
"""
Extract base url from any endpoint URL.
:param endpoint_url: Endpoint URL from which the base URL is to be extracted.
:return: Base URL
"""
return f"{urlparse(endpoint_url).scheme}://{urlparse(endpoint_url).hostname}:{urlparse(endpoint_url).port}"
@router.post("/shorten", tags=["URLs"])
async def shorten(url_obj: Url, request: Request) -> Url:
"""
Shorten the given long URL.
:param request: request object
:param url_obj: URL object
:return: shortened URL.
"""
suffix = hashlib.sha256(url_obj.url.encode("utf-8")).hexdigest()[:8]
if suffix not in store:
# store short-url-suffix: long-url into data store.
store[suffix] = url_obj.url
return Url(url=f"{_get_base_url(request.url_for('shorten'))}/{suffix}")
@router.get("/{suffix}", tags=["URLs"])
async def redirect(suffix: str) -> RedirectResponse:
"""
Redirect to long URL for the given URL ID.
:param suffix: URL ID for the corresponding long URL.
:return: Long URL.
"""
long_url = store[suffix]
if long_url:
# return permanent redirect so that browsers store this in their cache.
response = RedirectResponse(url=long_url, status_code=301)
return response
raise HTTPException(status_code=404, detail="Short URL not found.")
Я представил механизм хранения переменной среды docker и создал класс соединителя хранилища, который вызывает фабрику в зависимости от конфигурации пользователя.
store_connector.py
import os
import sys
from sys import stderr
from .singleton import Singleton
from ..kvstores.kvstore_factory import Factory
from ..kvstores.kvstore_base import KVStoreBase
class StoreConnector(metaclass=Singleton):
"""
Key Value store singleton class that can be used across all modules.
"""
def __init__(self):
try:
store_type = os.environ.get("STORE_TYPE", "MEMORY")
self._store = Factory.get_instance(store_type)
except KeyError as ex:
print(ex, file=stderr)
# one of the required environment variable is not set
print(
"One of the required environment variables is not set",
file=stderr,
)
sys.exit(1)
except Exception as ex:
print(ex, file=stderr)
sys.exit(1)
@property
def store(self) -> KVStoreBase:
return self._store
Вот моя структура каталогов:
url-shortener/
├── Dockerfile
├── LICENSE
├── README.md
├── requirements.txt
├── src
│ ├── __init__.py
│ ├── kvstores
│ │ ├── __init__.py
│ │ ├── file_store.py
│ │ ├── kvstore_base.py
│ │ ├── kvstore_factory.py
│ │ └── memory_store.py
│ ├── main.py
│ ├── models
│ │ ├── __init__.py
│ │ └── urls.py
│ ├── routes
│ │ ├── __init__.py
│ │ └── urls.py
│ └── utils
│ ├── singleton.py
│ └── store_connector.py
├── start.sh
└── tests
├── __init__.py
├── test_file_store.py
├── test_kvstore_factory.py
├── test_memory_store.py
├── test_routes.py
└── test_store.py
В инструкции подчеркнули следующие моменты:
- Читаемость кода
- Тесты — Модульные тесты определенно и многое другое, если вы можете подумать о
- Хорошая структура вашего кода и хорошо написанные имена файлов и переменных и т. Д.
Итак, чего из этого не хватает в моем коде?
1 ответ
На мой взгляд, ваш код выглядит красиво и читабельно; Я могу понять большую часть того, что происходит. Однако в некоторых моментах я не уверен:
(1) Ваша обработка исключений кажется мне немного странной
В filestore.py
, вы поймаете все исключения в своем __getitem__
метод. Как правило, это плохая практика — если вы ожидаете исключение определенного типа, вам следует перехватить только это конкретное исключение. Есть некоторые ситуации, когда вы можете захотеть перехватить и подавить все исключения, но они редки, и (по крайней мере, для меня) не очевидно, почему это одно из них. В строке документации нет ничего, что могло бы объяснить, почему вы ее ловите и просто распечатываете. Вместо того, чтобы ловить все ошибки в __getitem__
и возвращая пустую строку, если у вас не получилось, я бы поймал KeyError
с в routes/urls.py
Точно так же в kvstore_factory.py
, мне непонятно, почему ты поймала KeyError
исключение — особый вид исключения, информативный о том, что могло его вызвать — и заменить его общим Exception
. Если вы хотите добавить полезную дополнительную информацию в сообщение об ошибке, я бы сделал следующее:
# kvstore_factory.py
class Factory:
kvstore_map: dict = {"MEMORY": MemoryStore, "FILE": FileStore}
@staticmethod
def get_instance(instance_type: str) -> KVStoreBase:
try:
return Factory.kvstore_map[instance_type]()
except KeyError as err:
raise KeyError("Invalid instance requested.") from err
Или это:
# kvstore_factory.py
class InvalidInstanceRequest(KeyError):
"""
Raised when a user requests an instance
that does not exist in Factory.kvstore_map
"""
class Factory:
kvstore_map: dict = {"MEMORY": MemoryStore, "FILE": FileStore}
@staticmethod
def get_instance(instance_type: str) -> KVStoreBase:
try:
return Factory.kvstore_map[instance_type]()
except KeyError as err:
raise InvalidInstanceRequest("Invalid instance requested.") from err
Наконец, я не понимаю, почему в store_connector.py
, вы перехватываете исключения, а затем вызываете sys.exit
. Почему бы просто не вызвать исходное исключение заново, вот так? Я думаю, что это приведет к более удобочитаемой трассировке. Я бы вынул второй except
предложение полностью — если все, что вы делаете, это печатаете его, а затем вызываете sys.exit()
, с тем же успехом вы можете вообще не ловить его и позволить исключению завершить программу.
# store_connector.py
import os
from .singleton import Singleton
from ..kvstores.kvstore_factory import Factory
class StoreConnector(metaclass=Singleton):
def __init__(self):
try:
store_type = os.environ.get("STORE_TYPE", "MEMORY")
self._store = Factory.get_instance(store_type)
except KeyError as ex:
raise KeyError("One of the required environment variables is not set") from ex
(2) Два второстепенных момента
Во-первых, в kvstore_factory.py
, вы могли бы иметь гораздо более богатые подсказки по типу. Я также не совсем понимаю, почему вы используете staticmethod
, когда classmethod
казалось бы здесь более изящным.
# kvstore_factory.py
from typing import ClassVar
from .memory_store import MemoryStore
from .file_store import FileStore
from .kvstore_base import KVStoreBase
class Factory:
kvstore_map: ClassVar[dict[str, KVStoreBase]] = {"MEMORY": MemoryStore, "FILE": FileStore}
@classmethod
def get_instance(cls, instance_type: str) -> KVStoreBase:
try:
return cls.kvstore_map[instance_type]()
except KeyError as ex:
# exception handling as discussed above
# -- snip --
Во-вторых, вы можете упростить __contains__
метод в file_store.py
просто написав return bool(self[key])
(3) Я бы добавил логику в routes/urls.py
в другом месте
Точный способ хеширования URL-адресов кажется деталью реализации. Мне кажется странным, что эта логика в routes/urls.py
. Я мог бы переписать ваш код так:
kvstore_base.py:
# kvstore_base.py
import hashlib
from abc import ABC, abstractmethod
from collections.abc import MutableMapping
from functools import cache
@MutableMapping.register
class KVStoreBase(ABC):
def __init__(self, store):
self.store = store
def get_shortened_url(long_url: str) -> str:
shortened_url = self.shorten_url(long_url)
self[shortened_url] = long_url
return shortened_url
@staticmethod
@cache
def shorten_url(url: str) -> str:
return hashlib.sha256(url.encode("utf-8")).hexdigest()[:8]
@abstractmethod
def __getitem__(self, key: str) -> str: ...
@abstractmethod
def __setitem__(self, key: str, value: str) -> None: ...
@abstractmethod
def __contains__(self, key: str) -> bool: ...
маршруты / urls.py:
# routes/urls.py
from urllib.parse import urlparse
from fastapi import APIRouter, HTTPException, Request
from ..utils.store_connector import StoreConnector
from ..models.urls import Url
from starlette.responses import RedirectResponse
router = APIRouter()
store = StoreConnector().store
def _get_base_url(endpoint_url: str) -> str:
return f"{urlparse(endpoint_url).scheme}://{urlparse(endpoint_url).hostname}:{urlparse(endpoint_url).port}"
@router.post("/shorten", tags=["URLs"])
async def shorten(url_obj: Url, request: Request) -> Url:
suffix = store.get_shortened_url(url_obj.url)
return Url(url=f"{_get_base_url(request.url_for('shorten'))}/{suffix}")
# -- snip --
Альтернативный рефакторинг класса KVStore, если вы не верите, что детали хеширования принадлежат классу KVStore базового класса:
# kvstore_base.py
import hashlib
from abc import ABC, abstractmethod
from collections.abc import MutableMapping
from functools import cache
@MutableMapping.register
class KVStoreBase(ABC):
def __init__(self, store):
self.store = store
@abstractmethod
def __getitem__(self, key: str) -> str: ...
@abstractmethod
def __setitem__(self, key: str, value: str) -> None: ...
@abstractmethod
def __contains__(self, key: str) -> bool: ...
class URLStoreMixin:
def get_shortened_url(long_url: str) -> str:
shortened_url = self.shorten_url(long_url)
self[shortened_url] = long_url
return shortened_url
@staticmethod
@cache
def shorten_url(url: str) -> str:
return hashlib.sha256(url.encode("utf-8")).hexdigest()[:8]
Затем пусть ваши объекты MemoryStore и FileStore наследуются исключительно от KVStoreBase, как и раньше, но создайте классы MemoryURLStore и FileURLStore следующим образом:
class MemoryURLStore(MemoryStore, URLStoreMixin):
pass
class FileURLStore(FileStore, URLStoreMixin):
pass
Большой! Спасибо за подробный обзор. Мне очень понравились предложения по обработке исключений. Я не понимаю, почему вы храните длинные URL-адреса как ключевые, а короткие как значения? Когда пользователь вызывает API с использованием короткого URL-адреса, поиск длинного URL-адреса будет O (n) вместо O (1).
— Каушал28
Да, хороший момент — не думал об этом. Отредактировал мой ответ, чтобы исправить это!
— Алекс Вэйгуд
Спасибо. Вы правы в том, что хеширование — это деталь реализации / бизнес-логика приложения и может быть в другом месте. Но я не думаю, что магазин KV — подходящее место, потому что он должен знать только ключи и значения (развязку), чтобы его можно было использовать для других целей, кроме хранения URL-адресов. Но это только мои мысли.
— Каушал28
Конечно, я это вижу — возможно, мой
get_shortened_url
иshorten_url
методы были бы лучше как функции в глобальном пространстве имен, либо в routes / urls.py, либо в третьем файле. Я думаю, это зависит от того, как вы концептуализируете объекты магазина KV — видите ли вы их как объекты отображения. для конкретной цели, или как объекты обобщенного отображения это просто случайно используется для определенной цели.— Алекс Вэйгуд
К моему вопросу добавили альтернативный рефакторинг KVStore — было бы интересно услышать ваши мысли!
— Алекс Вэйгуд