Before you start
Make sure that you can compile and run core tests and read a basic example Writing your first HTTP server.
Step by step guide
Consider the case when you need to write an HTTP handler that greets a user. However, the localized strings are stored at some remote HTTP server, and those localizations rarely change. To reduce the network traffic and improve response times it is profitable to bulk retrieve the translations from remote and cache them in a local cache.
In this sample we show how to write and test a cache along with creating HTTP requests. If you wish to cache data from database prefer using cache specializations for DB.
Remote HTTP server description
For the purpose of this there is some HTTP server that has HTTP handler http://localhost:8090/v1/translations
.
Handler returns all the available translations as JSON on GET:
bash
curl http://localhost:8090/v1/translations -s | jq
{
"content": {
"hello": {
"ru": "Привет",
"en": "Hello"
},
"wellcome": {
"ru": "Добро пожаловать",
"en": "Wellcome"
}
},
"update_time": "2021-11-01T12:00:00Z"
}
In case of query parameter "last_update" the handler returns only the changed translations since requested time:
bash
$ curl http://localhost:8090/v1/translations?last_update=2021-11-01T12:00:00Z -s | jq
{
"content": {
"hello": {
"ru": "Приветище"
}
},
"update_time": "2021-12-01T12:00:00Z"
}
We are planning to cache those translations in a std::unordered_map:
namespace samples::http_cache {
struct KeyLang {
std::string key;
std::string language;
};
struct KeyLangEq {
bool operator()(const KeyLang& x, const KeyLang& y) const noexcept;
};
struct KeyLangHash {
bool operator()(const KeyLang& x) const noexcept;
};
using KeyLangToTranslation = std::unordered_map<KeyLang, std::string, KeyLangHash, KeyLangEq>;
}
Cache component
Our cache component should have the following fields:
namespace samples::http_cache {
public:
static constexpr std::string_view kName = "cache-http-translations";
~HttpCachedTranslations() override;
void Update(
[[maybe_unused]] const std::chrono::system_clock::time_point& last_update,
[[maybe_unused]] const std::chrono::system_clock::time_point& now,
) override;
private:
const std::string translations_url_;
std::string last_update_remote_;
void MergeAndSetData(
KeyLangToTranslation&& content,
);
};
}
To create a non LRU cache cache you have to derive from components::CachingComponentBase, call CacheUpdateTrait::StartPeriodicUpdates() at the component constructor and CacheUpdateTrait::StopPeriodicUpdates() at the destructor:
HttpCachedTranslations::HttpCachedTranslations(
)
: CachingComponentBase(config, context),
http_client_(context.FindComponent<
components::HttpClient>().GetHttpClient()),
translations_url_(config[
"translations-url"].As<
std::string>()) {
CacheUpdateTrait::StartPeriodicUpdates();
}
HttpCachedTranslations::~HttpCachedTranslations() { CacheUpdateTrait::StopPeriodicUpdates(); }
The constructor initializes component fields with data from static configuration and with references to clients.
Update
Depending on cache configuration settings the overloaded Update function is periodically called with different options:
void HttpCachedTranslations::Update(
[[maybe_unused]] const std::chrono::system_clock::time_point& last_update,
[[maybe_unused]] const std::chrono::system_clock::time_point& now,
) {
switch (type) {
json = GetAllData();
break;
json = GetUpdatedData();
break;
default:
}
if (json.IsEmpty()) {
return;
}
KeyLangToTranslation content;
const auto snapshot = Get();
content = *snapshot;
}
MergeAndSetData(std::move(content), json, stats_scope);
}
In this sample full and incremental data retrieval is implemented in GetAllData() and GetUpdatedData() respectively.
Both functions return data in the same format and we parse both responses using MergeAndSetData
:
void HttpCachedTranslations::MergeAndSetData(
KeyLangToTranslation&& content,
) {
for (const auto& [key, value] : Items(json["content"])) {
for (const auto& [lang, text] : Items(value)) {
content.insert_or_assign(KeyLang{key, lang}, text.As<std::string>());
}
}
auto update_time = json["update_time"].As<std::string>();
const auto size = content.size();
Set(std::move(content));
last_update_remote_ = std::move(update_time);
}
At the end of the MergeAndSetData function the components::CachingComponentBase::Set() invocation stores data as a new cache.
Update time from remote stored into last_update_remote_
. Clocks on different hosts are usually out of sync, so it is better to store the remote time if possible, rather than using a local times from last_update
and now
input parameters.
Data retrieval
To make an HTTP request call clients::http::Client::CreateRequest() to get an instance of clients::http::Request builder. Work with builder is quite straightforward:
auto response = http_client_.CreateRequest()
.get(translations_url_)
.retry(2)
.timeout(std::chrono::milliseconds{500})
.perform();
response->raise_for_status();
}
HTTP requests for incremental update differ only in URL and query parameter last_update
:
const auto url =
http::MakeUrl(translations_url_, {{
"last_update", last_update_remote_}});
auto response = http_client_.CreateRequest().get(url).retry(2).timeout(std::chrono::milliseconds{500}).perform();
response->raise_for_status();
}
Static configuration
To configure the new cache component provide its own options, options from components::CachingComponentBase:
# yaml
components_manager:
components: # Configuring components that were registered via component_list
cache-http-translations:
translations-url: 'mockserver/v1/translations' # Some other microservice listens on this URL
update-types: full-and-incremental
full-update-interval: 1h
update-interval: 15m
Options for dependent components components::HttpClient, components::TestsuiteSupport and support handler server::handlers::TestsControl should be provided:
http-client:
fs-task-processor: fs-task-processor
dns-client: # Asynchronous DNS component
fs-task-processor: fs-task-processor
testsuite-support:
tests-control:
# Some options from server::handlers::HttpHandlerBase
path: /tests/{action}
method: POST
task_processor: main-task-processor
server:
# ...
Cache component usage
Now the cache could be used just as any other component. For example, a handler could get a reference to the cache and use it in HandleRequestThrow
:
public:
static constexpr std::string_view kName = "handler-greet-user";
: HttpHandlerBase(config, context), cache_(context.FindComponent<HttpCachedTranslations>()) {}
const override {
const auto cache_snapshot = cache_.Get();
using samples::http_cache::KeyLang;
const auto& hello = cache_snapshot->at(KeyLang{"hello", "ru"});
const auto& welcome = cache_snapshot->at(KeyLang{"welcome", "ru"});
return fmt::format(
"{}, {}! {}", hello, request.
GetArg(
"username"), welcome);
}
private:
samples::http_cache::HttpCachedTranslations& cache_;
};
Note that the cache is concurrency safe as all the components.
int main()
Finally, we add our component to the components::MinimalServerComponentList()
, and start the server with static configuration kStaticConfig
.
int main(int argc, char* argv[]) {
.
Append<samples::http_cache::HttpCachedTranslations>()
.Append<samples::http_cache::GreetUser>()
.Append<server::handlers::TestsControl>()
.Append<components::HttpClient>();
}
Build and Run
To build the sample, execute the following build steps at the userver root directory:
mkdir build_release
cd build_release
cmake -DCMAKE_BUILD_TYPE=Release ..
make userver-samples-http_caching
Note that you need a running translations service with bulk handlers to start the service. You could use the mongo service for that purpose.
The sample could be started by running make start-userver-samples-http_caching
. The command would invoke testsuite start target that sets proper paths in the configuration files and starts the service.
To start the service manually run ./samples/http_caching/userver-samples-http_caching -c </path/to/static_config.yaml>
(do not forget to prepare the configuration files!).
Now you can send a request to your server from another terminal:
bash
$ curl -X POST http://localhost:8089/samples/greet?username=Dear+Developer -i
HTTP/1.1 200 OK
Date: Thu, 09 Dec 2021 17:01:44 UTC
Content-Type: text/html; charset=utf-8
X-YaRequestId: 94193f99ebf94eb58252139f2e9dace4
X-YaSpanId: 4e17e02dfa7b8322
X-YaTraceId: 306d2d54fd0543c09376a5c4bb120a88
Server: userver/2.0 (20211209085954; rv:d05d059a3)
Connection: keep-alive
Content-Length: 61
Привет, Dear Developer! Добро пожаловать
Auto testing
The server::handlers::TestsControl and components::TestsuiteSupport components among other things provide necessary control over the caches. You can stop/start periodic updates or even force updates of caches by HTTP requests to the server::handlers::TestsControl handler.
For example the following JSON forces incremental update of the cache-http-translations
cache:
{"invalidate_caches": {
"update_type": "incremental",
"names": ["cache-http-translations"]
}}
Fortunately, the testsuite API provides all the required functionality via simpler to use Python functions.
Functional testing
Functional tests for the service could be implemented using the testsuite. To do that you have to:
- Mock the translations service data:
@pytest.fixture(name='translations')
def _translations():
return {
'hello': {'en': 'hello', 'ru': 'Привет'},
'welcome': {'ru': 'Добро пожаловать', 'en': 'Welcome'},
}
- Mock the translations service API:
@pytest.fixture(autouse=True)
def mock_translations(mockserver, translations, mocked_time):
@mockserver.json_handler('/v1/translations')
def mock(request):
return {
'content': translations,
'update_time': utils.timestring(mocked_time.now()),
}
return mock
- Import the pytest_userver.plugins.core plugin and teach testsuite how to patch the service config to use the mocked URL:
pytest_plugins = ['pytest_userver.plugins.core']
USERVER_CONFIG_HOOKS = ['userver_config_translations']
@pytest.fixture(scope='session')
def userver_config_translations(mockserver_info):
def do_patch(config_yaml, config_vars):
components = config_yaml['components_manager']['components']
components['cache-http-translations']['translations-url'] = (
mockserver_info.url('v1/translations')
)
return do_patch
- Write the test:
async def test_http_caching(service_client, translations, mocked_time):
response = await service_client.post(
'/samples/greet', params={'username': 'дорогой разработчик'},
)
assert response.status == 200
assert 'text/plain' in response.headers['Content-Type']
assert response.text == 'Привет, дорогой разработчик! Добро пожаловать'
translations['hello']['ru'] = 'Приветище'
mocked_time.sleep(10)
await service_client.invalidate_caches()
response = await service_client.post(
'/samples/greet', params={'username': 'дорогой разработчик'},
)
assert response.status == 200
assert 'text/plain' in response.headers['Content-Type']
assert response.text == 'Приветище, дорогой разработчик! Добро пожаловать'
Full sources
See the full example: