-
Notifications
You must be signed in to change notification settings - Fork 1
Technical details
Application is designed for high efficiency and minimal latency. As this is a hobby project I only wanted to use a single server with very limited resources to reduce the cost of it's maintenance while still providing a decent performance.
A lot of effort was taken into optimisation including:
- minimising number of database queries
- processing whatever possible in the background
- learning algorithm with fast questions serving
- efficient results caching
Automated tests are divided into 3 groups / namespaces:
- Functional -> tests of entire feature flows / user stories
- Integration -> tests involving multiple units and interactions between them
- Unit -> isolated tests of a single unit
SQLite database with :memory:
connection type is used when a test needs to use DB. This significantly improved testing performance by eliminating I/O latency.
It takes less than 2 seconds to run 520 tests in memory on a 8 core machine.
simple-memorizer-web $ make test
php artisan test --parallel
............................................................... 63 / 520 ( 12%)
............................................................... 126 / 520 ( 24%)
............................................................... 189 / 520 ( 36%)
............................................................... 252 / 520 ( 48%)
............................................................... 315 / 520 ( 60%)
............................................................... 378 / 520 ( 72%)
............................................................... 441 / 520 ( 84%)
............................................................... 504 / 520 ( 96%)
................ 520 / 520 (100%)
Time: 00:01.834, Memory: 24.00 MB
OK (520 tests, 2349 assertions)
In App\Structures
namespace there are structures and repositories.
Structures represent denormalised data fetched from the database - typically a result of query joining multiple tables. Unlike PHP arrays these have typed properties making them safer to use and more IDE friendly.
Repositories are executing SQL queries and returning typed results - as structures or collections of structures.
This approach is faster than using models because typically a single query can produce data required for the entire view significantly increasing loading speed. With Eloquent - even using eager loading - total execution time is slower and number of queries bigger.
Some repository interfaces have two concrete classes - one for authenticated and one for guest user.
This is because often the same interface can be used to fetch data for these two contexts.
An example is a list of available lessons displayed on the home page. For authenticated user it will contain lessons that user does not yet subscribe - already subscribed would be filtered out. Although for guest user all lessons will be displayed as guest user has no subscribed lessons.
Corresponding classes and methods would be:
GuestUserLessonRepository::fetchAvailableUserLessons(): Collection
AuthenticatedUserLessonRepository::fetchAvailableUserLessons(): Collection
Controller does not know which concrete version is used as it only has an interface injected. Actual binding is done in App\Providers\StructuresServiceProvider
- it assigns specific repositories to interfaces based on whether user is authenticated or not.
// UserLesson operations valid for all users
$this->app->bind(
AbstractUserLessonRepositoryInterface::class,
function () {
if (Auth::check()) {
return new AuthenticatedUserLessonRepository(Auth::user());
}
return new GuestUserLessonRepository();
}
);
Some interfaces also are either prefixed with AbstractUser
or AuthenticatedUser
. This is because some operations make sense only for authenticated users while other can also be done for guest users.
For instance AbstractUserLessonRepositoryInterface::fetchAvailableUserLessons(): Collection
will produce list of available lessons for guest or authenticated user - as described above, while AuthenticatedUserLessonRepositoryInterface::fetchOwnedUserLessons(): Collection
only makes sense for a member (guest does not own anything).
Application is heavily using Laravel events and listeners. Connection between these is configured in App\Providers\EventServiceProvider
. This approach has several advantages:
- listeners can be re-used for multiple events
- smaller classes, easier to follow simple responsibility principle
- less coupling between classes
- operations not needing synchronous processing are run in the background using message queue making request processing faster
Some data that could be computed on the fly is actually calculated in the background and cached in the database. The reason is to keep request latency as low as possible by limiting SQL queries scope.
Examples of such data include:
-
exercise_results.number_of_good_answers
,number_of_good_answers.number_of_good_answers_today
,number_of_good_answers.latest_good_answer
Computed and stored by App\Listeners\UpdateNumberOfGoodAnswersOfExercise
.
-
exercise_results.number_of_bad_answers
,number_of_good_answers.number_of_bad_answers_today
,number_of_good_answers.latest_bad_answer
Computed and stored by App\Listeners\UpdateNumberOfGoodAnswersOfExercise
.
exercise_results.percent_of_good_answers
Computed and stored by App\Listeners\UpdatePercentOfGoodAnswersOfExerciseResult
.
lessons.exercises_count
Computed and stored by App\Listeners\UpdateExercisesCountOfLesson
.
lessons.subscribers_count
Computed and stored by App\Listeners\UpdateSubscribersCountOfLesson
.
lessons.child_lessons_count
Computed and stored by App\Listeners\UpdateChildLessonsCountOfLesson
.
lesson_user.percent_of_good_answers
Computed and stored by App\Listeners\UpdatePercentOfGoodAnswersOfLesson
.