Pull to refresh

Проектируем рейтинговое оценивание

Reading time5 min
Views1.2K
Часто требуется реализовать возможность рейтингового оценивания того или иного объекта (заметки, комментария, цитаты, фотограммы, видеоролика и т. д.) посетителями сайта. Как это запрографировать?

Прежде всего мы имеем объект оценивания и субъект оценивания. Последним могут быть, например, зарегистрированные пользователи, незарегистрированные пользователи (гости) и пр.

Для того, чтобы обеспечить слабую связанность конкретных сущностей предметной области, к которым мы привязываем возможность рейтингового голосования, с модулем, реализующим нашу задачу, мы выделяем отдельные классы для объекта (Rating_Object) и субъекта (Rating_Subject). Оба эти класса — конкретные и реализованы как active record. Чтобы иметь возможность привязывать всяческие статьи и фотограммы к экземплярам Rating_Object, мы предусматриваем интерфейс Rating_Ratable:

interface Rating_Ratable {
    /**
     * @return Rating_Object
     */
    public function asRatingObject();
}


Этот интерфейс теперь может быть реализован в классах Article и т. д., например, так:

class Article extends ActiveRecord implements Rating_Ratable {
    public function setTableDefinition() {
        $this->hasReferenceColumn("rating_object_id");
    }

    public function setUp() {
        $this->hasOne("Rating_Object as rating_object", array("local" => "rating_object_id", "foreign" => "id"));
    }

    public function asRatingObject() {
        return $this->rating_object;
    }
}


Аналогично поступаем с субъектами оценивания. Необходимо лишь, чтобы мы хранили идентификатор субъекта для каждой конкретной сущности, которая может голосовать. Поскольку практически всегда у нас есть active record для текущего посетителя сайта (гостя можно и нужно идентифицировать по комплексу сведений и, соответственно, заводить для него запись в базе данных, — если вы это ещё не делаете, самое время начать), достаточно добавить в соответствующий класс поле rating_subject_id.

Если в будущем у нас появится необходимость принимать голоса за рейтинг от сущностей, не являющихся посетителями сайта (например, брать данные из сторонних рейтингов), мы так же легко сможем привязать к каждому из экземпляров таких сущностей отдельный экземпляр Rating_Subject.

Итак, у нас есть те, кто могут оценивать, и то, что можно оценивать. Нужно их связать. Для этого вводим класс Rating_Vote, — active record с полями object_id, subject_id, opinion. Последнее поле представляет собой конкретный выбор на шкале рейтинга — конкретную оценку, которую этот субъект поставил этому объекту.

Здесь необходимо отметить, что спектры возможных оценок могут быть разными. Можно ограничиться двумя оценками «хорошо» и «плохо» (мой личный выбор), можно предлагать поставить от одной до пяти звёзд (а можно — от одной до десяти) и т. д. Естественно, конкретная шкала оценивания определяется для конкретного объекта: например, цитаты и комментарии оцениваем бинарно, а статьи и фотограммы — по шкале 1…5.

Вместе с тем необходимо определить и способ вычисления итогового рейтинга. Скажем, для бинарного выбора «плюс-минус» можно брать за итоговую оценку разницу между количеством плюсов или количеством минусов. Но можно — отношение количества плюсов к общему количеству голосов (мой личный выбор). Для шкалы из нескольких вариантов можно брать среднее арифметическое, среднее гармоническое, моду или медиану. Опять же, для конкретного объекта задаём конкретный способ.

Класс, определяющий, с одной стороны, шкалу оценок, а с другой стороны, метод вычисления итогового рейтинга, назовём стратегией оценивания. Введём интерфейс Rating_Strategy:

interface Rating_Strategy {
    /**
     * @return array of float
     */
    public function getRatingOptions();

    /**
     * @param Rating_Vote_Collection $votes
     * @return float
     */
    public function getAggregatedOpinion(Rating_Vote_Collection $votes);
}


Почему для отражения конкретной возможной оценки выбран float? Это позволит нам отразить как дискретные шкалы оценивания (сопоставив варианты целым числам, каковые суть подмножество вещественных), так и плавающую шкалу, если нам такая понадобится. Итоговая же оценка может быть дробной даже для целочисленной шкалы (например, оценивать по шкале от 1 до 10 и брать среднее арифметическое оценок). Выбор float даёт нам возможность покрыть практически все варианты. Поле opinion в классе Rating_Vote, соответственно, тоже float.

Класс Rating_Vote_Collection отражает, как нетрудно догадаться, коллекцию объектов Rating_Vote — то есть набор полученных голосов за конкретный объект. Зачем нужен этот класс, почему не обойтись просто массивом Rating_Vote? Голосов может быть очень много, несколько тысяч, — загружать их все из базы данных в оперативную память расходно, да и незачем. Почти всегда для получения итоговой оценки необходимо и достаточно иметь на руках сведения об агрегатном количестве голосов за каждый из вариантов. Поэтому в классе Rating_Vote_Collection мы сделаем соответствующий метод getOpinionCounts, который вернёт массив вида: [+1 → 100, −1 → 50] (100 «хорошо», 50 «плохо»). Но предусмотрим также lazy-загрузку на тот случай, если захотим реализовать нетривиальную стратегию (наделяющую, например, голоса весами в зависимости от кармы голосовавших).

Несколько примеров стратегий оценивания:

abstract class Rating_Strategy_Binary implements Rating_Strategy {
    const GOOD  = +1;
    const BAD   = -1;

    public function getRatingOptions() {
        return array(self::GOOD, self::BAD);
    }
}

class Rating_Strategy_Binary_Subtraction extends Rating_Strategy_Binary {
    public function getAggregatedOpinion(Rating_Vote_Collection $votes) {
        $counts = $votes->getOpinionCounts();
        $good   = isset($counts[self::GOOD]) ? $counts[self::GOOD] : 0;
        $bad    = isset($counts[self::BAD]) ? $counts[self::BAD] : 0;
        return $good - $bad;
    }
}

class Rating_Strategy_Binary_Rational extends Rating_Strategy_Binary {
    public function getAggregatedOpinion(Rating_Vote_Collection $votes) {
        $counts = $votes->getOpinionCounts();
        $good   = isset($counts[self::GOOD]) ? $counts[self::GOOD] : 0;
        $bad    = isset($counts[self::BAD]) ? $counts[self::BAD] : 0;
        $total  = $good + $bad;
        return ($total > 0) ? ($good / $total) : 0;
    }
}

abstract class Rating_Strategy_Range implements Rating_Strategy {
    private $min, $max;

    public function __construct($min, $max) {
        $this->min = $min;
        $this->max = $max;
    }

    public function getRatingOptions() {
        return range($this->min, $this->max);
    }
}

class Rating_Strategy_Range_Arithmetic extends Rating_Strategy_Range {
    public function getAggregatedOpinion(Rating_Vote_Collection $votes) {
        $counts = $votes->getOpinionCounts();
        $sum = 0;
        $total_count = 0;
        foreach ($counts as $value => $count) {
            $sum += $value * $count;
            $total_count += $count;
        }
        return ($total_count > 0) ? ($sum / $total_count) : 0;
    }
}


Осталось добавить в интерфейс Rating_Ratable метод getRatingStrategy:

interface Rating_Ratable {
    /**
     * @return Rating_Object
     */
    public function asRatingObject();

    /**
     * @return Rating_Strategy
     */
    public function getRatingStrategy();
}

class Article extends ActiveRecord implements Rating_Ratable {
    public function setTableDefinition() {
        $this->hasReferenceColumn("rating_object_id");
    }

    public function setUp() {
        $this->hasOne("Rating_Object as rating_object", array("local" => "rating_object_id", "foreign" => "id"));
    }

    public function asRatingObject() {
        return $this->rating_object;
    }

    public function getRatingStrategy() {
        return new Rating_Strategy_Range_Arithmetic(1, 5);
    }
}


А в классе Rating_Object предусмотреть методы для оперирования связанными Rating_Vote.

Теперь мы легко можем в нужных местах произвести добавление голоса или узнать текущий рейтинг:

$article->asRatingObject()->addVote($user->asRatingSubject(), 5);

echo $article->getRatingStrategy()->getAggregatedOpinion($article->asRatingObject()->getVotes());
Tags:
Hubs:
+30
Comments60

Articles