Основная цель проектов – зарабатывать деньги. Проект над которым мне довелось работать, не стал исключением.
Я разработчик компании Колёса Крыша Маркет и сегодняшний пост будет посвящен тому, как мы дифференцировали цены на платные услуги на нашем “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).