Основная цель проектов – зарабатывать деньги. Проект над которым мне довелось работать, не стал исключением.
Я разработчик компании Колёса Крыша Маркет и сегодняшний пост будет посвящен тому, как мы дифференцировали цены на платные услуги на нашем “classified”.
Наша компания разрабатывает 3 продукта, каждый под 3 платформы – web, android и ios. Пользователи могут применять к объявлениям различные платные услуги, например, платное продление срока жизни объявления или размещение в блоке горячих предложений.
Когда меня привлекли к этому проекту, у меня в голове еще до начала обсуждения держалась мысль, что за дифференцированные цены?
Дифференцированная цена — цена формирование которой зависит от характеристик объявления (регион, марка, модель, год и т.д.).
Перед командой стояла задача увеличить средний чек. Было принято решение “запилить” фичу, содержащую в себе функционал о котором дальше и пойдет речь. Смысл фичи был в том, что через админ-панель мы сможем изменять цену на любую платную услугу, опираясь на разные параметры.
На момент начала разработки мы уже имели микросервис написанный на Go. Сайты и приложения общались с ним через клиент, отправляя POST-запросом объект объявления, а далее, получив в ответ цены на какие-либо платные услуги, рендерили их пользователю.
В процессе изучения микросервиса выяснилось, что цены, которые отдаются пользователю, были захардкожены, то есть цена для размещения объявления в горячих предложениях описывалась переменной “hot”: 300, а результат ответа выглядел примерно так:
{
status: "ok",
data: {
color-red: 45,
hot: 300,
paid-auto-re: 5,
re: 0,
s: 90,
unset-auto-re: 0,
up: 200
}
}
Было принято решение заняться важным рефакторингом и избавиться от хардкода в пользу метода, который бы отдавал дифференцированную цену на услугу.
Процесс разработки мы разделили на несколько этапов:
- Выбор формата хранения данных.
- Разработка админ-панели для регулирования цен.
- Метод отдачи дифференцированной цены.
Выбор формата хранения данных
Изначально, согласно ТЗ, менеджер продукта хотел иметь возможность устанавливать цену на платную услугу из административной панели и это было одной из преимущественно важных задач. Передо мной встал вопрос, в каком формате хранить данные.
Я принял решение забить базу данных “правилами”, то есть, если категория объявления “Авто” и регион “Алматы”, то применяем для объявления следующею цену. Осталось разобраться с базой данных.
Первое, что пришло на ум, это база данных MySQL, где будет храниться таблица с правилами для цен. Например:
Id | catId | regionId | coeff | serviceName |
---|---|---|---|---|
1 | 13 | 14 | 1.4 | hot |
Согласно ТЗ было необходимо устанавливать цену исходя из региона и категории. Но, зная, как все эти “хотелки” работают, подумал, что цену понадобится менять не только для категории и региона, а еще и для определенной модели авто или ещё по какому-либо признаку.
В общем, MySQL отпал как вариант, и выбор пал на MongoDB, которая бы обеспечила нам широчайшие возможности динамического масштабирования и умела работать с массивами данных, без которых правила были бы бесполезны.
В принципе, на момент разработки все наши объявления уже хранились в MongoDB. Скармливать правила для регулирования цен туда же было проще. На этом мы и остановились
Разработка админ панели для регулирования цен
Админ-панель вещь не сложная, у нее должен был быть стандартный функционал CRUD, то есть добавление, редактирование, удаление и отображение этих правил в удобном для чтения виде.
Написали всё это дело мы на phalcon, так как эта админ-панель была лишь частью основной админки для сайта, работающего на phalcon. Также написали функционал в API, который валидировал и сохранял наши правила в коллекцию MongoDB.
Json-объект объявления выглядел вот так:
Как мы здесь видим, есть некоторые уровни вложенности, то есть нужно не только сохранить данные с вложенностью, но и сделать поиск по этой вложенности в MongoDB. Для валидации данных мы ввели небольшую коллекцию, где хранили возможные поля для использования, чтобы не хранить всё подряд, что отправил пользователь в API.
Функционал отдачи дифференцированной цены
Последним этапом нашей разработки был этап написания кода, который умел бы работать со всеми этими правилами и возвращал бы на запрос ответ с дифференцированной ценой.
Как раньше:
Как сейчас:
А где же рефакторинг и где же дифференцированные цены? А вот.
Эти захардкоженные переменные в коде в итоге остались в качестве значений по-умолчанию.
Алгоритм отдачи цен раньше работал так:
- Формирование массива с ценами на услуги.
- Обработка исключении по типу для этого раздела. Если услуга бесплатная, то отдать 0.
- Отдача результата.
Сейчас все работает также, за исключением того, что перед возвратом ответа пользователю теперь идёт поход в метод дифференциации цены.
Первый подход к решению задачи, был следующим.
В момент формирования массива с ценами для каждого из его элементов, то есть услуги, был дополнительный поход в MongoDB.
Метод “getPriceForService” возвращал цену и принимал следующие аргументы:
- Объект объявления
- Название услуги
- Цена до обработки
Помните, выше я написал, что есть коллекция с возможными правилами регулирования цен, они здесь и понадобились.
Чтобы не проходить по каждому из полей объекта, была сделана выборка только из возможных правил. На следующем скрине, мы видим, процесс формирования запроса в MongoDB.
// Возвращает стоимость услуги для объявления
func getPriceForService(advert *Advert, serviceName string, basePrice int) (result int) {
var mongoResult map[string]interface{}
query := bson.M{}
query["serviceName"] = serviceName
query["$and"] = []bson.M{}
//Генерируем поисковый запрос в монго
for _, data := range getUsableValuesForPrice() {
var value, err = advert.GetValueString(data.Rule)
stringVal, err := getStringFromMixed(value)
if err == nil {
list := []string{"rules.", data.Rule}
var str bytes.Buffer
for _, l := range list {
str.WriteString(l)
}
if err == nil {
query["$and"] = append(query["$and"].([]bson.M), bson.M{"$or": []bson.M{bson.M{str.String(): stringVal}, bson.M{str.String(): nil}}})
} else {
checkErr(err)
}
}
}
//Получаем правила по нашему запросу
err := mongo.GetSession().
DB(mongo.GetDbName()).
C("COLLECTION_NAME").
Find(query).
Sort("-priority").
One(&mongoResult)
if err == nil {
stringResult, err := getStringFromMixed(mongoResult["coeff"])
checkErr(err)
coeff, err := strconv.ParseFloat(stringResult, 64)
if err == nil {
//Умножаем цену на полученный коэффицент
return int(math.Ceil(coeff * float64(basePrice)))
} else {
checkErr(err)
}
}
return basePrice
}
В конечном итоге мы получили запрос, который оставалось лишь выполнить и посчитать новую цену на услугу, используя полученный коэффициент.
Однако, после релиза этого кода в production, наша MongoDB умерла из-за того, что при одном запросе в микросервис он отдает результаты для всех услуг, а я вызываю метод при формировании массива для каждого элемента. То есть, я увеличил нагрузку на MongoDB в 7-8 раз и в поте лица занялся переписыванием своего кода.
Информация к сведению: Для работы с MongoDB, мы использовали mgo, которая позволяет с легкостью строить запросы в базу данных.
Тем же вечером, поизучав код заново, я решил вызывать этот функционал в самый последний момент, то есть перед тем, как отдать результаты клиенту. Я постучусь в этот же метод, только чуть-чуть переписанный. Переписанный метод стал принимать уже не название услуги, а список услуг с ценами готовый к отдаче.
// Возвращает стоимости услуг массивом
func getPriceForServices(advert *Advert, serviceList Services) (result Services) {
var mongoResult []map[string]interface{}
var services []string
for key, _ := range serviceList {
services = append(services, key)
}
query := bson.M{}
query["serviceName"] = bson.M{"$in": services}
query["$and"] = []bson.M{}
//Генерируем поисковый запрос в монго
for _, data := range getUsableValuesForPrice() {
var value, err = advert.GetValueString(data.Rule)
stringVal, err := getStringFromMixed(value)
if err == nil {
list := []string{"rules.", data.Rule}
var str bytes.Buffer
for _, l := range list {
str.WriteString(l)
}
if err == nil {
query["$and"] = append(query["$and"].([]bson.M), bson.M{"$or": []bson.M{bson.M{str.String(): stringVal}, bson.M{str.String(): nil}}})
} else {
checkErr(err)
}
}
}
//Получаем коэффиценты по нашему запросу
err := mongo.GetSession().
DB(mongo.GetDbName()).
C("Collection_name").
Find(query).
Select(bson.M{"serviceName": 1, "coeff": 1}).
Sort("priority").
All(&mongoResult)
checkErr(err)
//Собираем массив с ключ, значением
for _, element := range mongoResult {
coeff, err := getStringFromMixed(element["coeff"])
checkErr(err)
intCoeff, error := strconv.ParseFloat(coeff, 64)
checkErr(error)
serviceName, err := getStringFromMixed(element["serviceName"])
if val, ok := serviceList[serviceName]; ok {
price := int(math.Ceil(intCoeff * float64(val)))
serviceList[serviceName] = price
}
}
return serviceList
}
Как и раньше, получив запрос и выполнив его, мы получаем данные с правилами для каждой услуги и коэффициент на который нужно умножить старую цену.
Этот подход исключил все лишние походы в MongoDB, тем самым мы перестали нагружать нашу базу и получили дифференцированные цены :) (profit).
pawlo16
Вот это вот
логически ни как не связано с
Вообще не повод для NoSQL. В SQL задачи типа «поменять цену в зависимости от признака» решаются на раз при правильной схеме данных.
Для Nosql могут быть объективные причины, но вы не назвали не одной из них. Выбор mongodb не понятен.
Какие такие возможности масштабирования в вашем случае предоставляет mongodb, которых как вы считаете нет в postgresql?
«умела работать с массивами данных» — это что вообще?
Tumenbayev Автор
«Умела работать с массивами данных» — это для поиска, то есть мы привели все данные в один вид и по нему можем искать данные в коллекции через mgo, строя запрос по типу: 'data.region':1, 'data.cat_id':2 и т.д.
По поводу noSQL, есть такой момент как сохранения правил, то есть, если мы хотим создать правило по типу: регион: Московская обл, город: Москва, категория: авто-легковые, а таблица наша, не имеет к примеру поле city, то это правило никак не записать, монго даёт эту возможность.