Введение
Зарубежные торговые центры начинаются в Индии, и постепенно появятся некоторые требования из других стран. В настоящее время нам необходимо преобразовать существующие торговые центры, которые могут поддерживать торговые центры в нескольких странах. Будет много проблем, несколько языков и несколько стран, несколько часовых поясов, локализация и многое другое. В случае нескольких стран, как передать идентифицированную информацию о стране, слой за слоем, до последнего шага выполнения кода. Есть даже несколько многопоточных сценариев, с которыми нужно иметь дело.
2. Фоновая технология
2.1 ThreadLocal
Проще всего думать о ThreadLocal.После того, как запись распознает информацию о стране, она перебрасывается в ThreadLocal, чтобы последующие коды, redis, DB и т. д. можно было использовать при различении стран.
Вот краткое введение в ThreadLocal:
/**
* Sets the current thread's copy of this thread-local variable
* to the specified value. Most subclasses will have no need to
* override this method, relying solely on the {@link #initialValue}
* method to set the values of thread-locals.
*
* @param value the value to be stored in the current thread's copy of
* this thread-local.
*/
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
/**
* Returns the value in the current thread's copy of this
* thread-local variable. If the variable has no value for the
* current thread, it is first initialized to the value returned
* by an invocation of the {@link #initialValue} method.
*
* @return the current thread's value of this thread-local
*/
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
/**
* Get the map associated with a ThreadLocal. Overridden in
* InheritableThreadLocal.
*
* @param t the current thread
* @return the map
*/
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
/**
* Get the entry associated with key. This method
* itself handles only the fast path: a direct hit of existing
* key. It otherwise relays to getEntryAfterMiss. This is
* designed to maximize performance for direct hits, in part
* by making this method readily inlinable.
*
* @param key the thread local object
* @return the entry associated with key, or null if no such
*/
private Entry getEntry(ThreadLocal<?> key) {
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
if (e != null && e.get() == key)
return e;
else
return getEntryAfterMiss(key, i, e);
}
-
Каждый поток Thread имеет свои собственные threadLocals (ThreadLocalMap), которые содержат Entry со слабыми ссылками (ThreadLocal, Object).
-
Метод get сначала получает текущий поток через Thread.currentThread, затем получает threadLocals (ThreadLocalMap) потока, а затем получает значение, сохраненное текущим потоком, из Entry.
-
При установке значения измените значение, соответствующее Entry в threadLocals (ThreadLocalMap) текущего потока.
В реальном использовании, кроме синхронного метода, есть еще сценарии асинхронной обработки потока, в это время содержимое ThreadLocal нужно передать из родительского потока в дочерний, что делать?
Не волнуйтесь, в Java также есть InheritableThreadLocal, который поможет нам решить эту проблему.
2.2 InheritableThreadLoca
public class InheritableThreadLocal<T> extends ThreadLocal<T> {
/**
* Computes the child's initial value for this inheritable thread-local
* variable as a function of the parent's value at the time the child
* thread is created. This method is called from within the parent
* thread before the child is started.
* <p>
* This method merely returns its input argument, and should be overridden
* if a different behavior is desired.
*
* @param parentValue the parent thread's value
* @return the child thread's initial value
*/
protected T childValue(T parentValue) {
return parentValue;
}
/**
* Get the map associated with a ThreadLocal.
*
* @param t the current thread
*/
ThreadLocalMap getMap(Thread t) {
return t.inheritableThreadLocals;
}
/**
* Create the map associated with a ThreadLocal.
*
* @param t the current thread
* @param firstValue value for the initial entry of the table.
*/
void createMap(Thread t, T firstValue) {
t.inheritableThreadLocals = new ThreadLocalMap(this, firstValue);
}
}
-
java.lang.Thread#init(java.lang.ThreadGroup, java.lang.Runnable, java.lang.String, long, java.security.AccessControlContext, логическое значение)
if (inheritThreadLocals && parent.inheritableThreadLocals != null) this.inheritableThreadLocals = ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
-
InheritableThreadLocal оперирует переменной inheritableThreadLocals, а не переменной threadLocals, управляемой ThreadLocal.
-
При создании нового потока он проверит, имеет ли значение переменная parent.inheritableThreadLocals в родительском потоке значение null.Если оно не равно нулю, скопируйте копию данных parent.inheritableThreadLocals в this.inheritableThreadLocals дочернего потока.
-
Поскольку методы getMap(Thread) и CreateMap() переопределены для непосредственного управления inheritableThreadLocals, можно получить значение ThreadLocal родительского потока в дочернем потоке.
Теперь при использовании многопоточности все делается через пул потоков. Можно ли сейчас использовать InheritableThreadLocal? Не будет ли проблем? Давайте посмотрим на выполнение следующего кода:
-
test
static InheritableThreadLocal inheritableThreadLocal = new InheritableThreadLocal<>();
public static void main(String[] args) throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(1); inheritableThreadLocal.set("i am a inherit parent"); executorService.execute(new Runnable() { @Override public void run() { System.out.println(inheritableThreadLocal.get()); } }); TimeUnit.SECONDS.sleep(1); inheritableThreadLocal.set("i am a new inherit parent");// 设置新的值 executorService.execute(new Runnable() { @Override public void run() { System.out.println(inheritableThreadLocal.get()); } });
}
i am a inherit parent i am a inherit parent
public static void main(String[] args) throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(1); inheritableThreadLocal.set("i am a inherit parent"); executorService.execute(new Runnable() { @Override public void run() { System.out.println(inheritableThreadLocal.get()); inheritableThreadLocal.set("i am a old inherit parent");// 子线程中设置新的值 } }); TimeUnit.SECONDS.sleep(1); inheritableThreadLocal.set("i am a new inherit parent");// 主线程设置新的值 executorService.execute(new Runnable() { @Override public void run() { System.out.println(inheritableThreadLocal.get()); } });
}
i am a inherit parent i am a old inherit parent
Глядя на первый результат выполнения здесь, обнаруживается, что значение, установленное основным потоком во второй раз, не было изменено, или значение, установленное в первый раз, равно «я наследующий родитель». В чем причина?
Глядя на результат выполнения второго примера, обнаруживается, что значение «я старый наследующий родитель», установленное в первой задаче, печатается во второй задаче. Что является причиной этого?
Оглядываясь назад на приведенный выше исходный код, в случае пула потоков данные в inheritableThreadLocals будут скопированы из родительского потока, когда поток создается в первый раз, поэтому первая задача успешно получает набор «я». родительским потоком. наследовать родительский», когда выполняется вторая задача, поток первой задачи используется повторно, и операция inheritableThreadLocals в родительском потоке копирования не будет запущена, поэтому даже если в основном задано новое значение поток, это не вступит в силу. В то же время метод get() напрямую оперирует переменной inheritableThreadLocals, поэтому напрямую получает значение, заданное первой задачей.
Что делать, если я столкнулся с пулом потоков?
2.3 TransmittableThreadLocal
TransmittableThreadLocal (TTL) пригодится в это время. Это компонент с открытым исходным кодом Alibaba.Давайте посмотрим, как он решает проблему пулов потоков.Начнем с куска кода, изменим его на основе вышеизложенного и воспользуемся TransmittableThreadLocal.
static TransmittableThreadLocal<String> transmittableThreadLocal = new TransmittableThreadLocal<>();// 使用TransmittableThreadLocal
public static void main(String[] args) throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(1);
executorService = TtlExecutors.getTtlExecutorService(executorService); // 用TtlExecutors装饰线程池
transmittableThreadLocal.set("i am a transmittable parent");
executorService.execute(new Runnable() {
@Override
public void run() {
System.out.println(transmittableThreadLocal.get());
transmittableThreadLocal.set("i am a old transmittable parent");// 子线程设置新的值
}
});
System.out.println(transmittableThreadLocal.get());
TimeUnit.SECONDS.sleep(1);
transmittableThreadLocal.set("i am a new transmittable parent");// 主线程设置新的值
executorService.execute(new Runnable() {
@Override
public void run() {
System.out.println(transmittableThreadLocal.get());
}
});
}
i am a transmittable parent
i am a transmittable parent
i am a new transmittable parent
После выполнения кода обнаружено, что после использования TransmittableThreadLocalTtlExecutors.getTtlExecutorService(executorService) для оформления пула потоков каждый раз при вызове задачи данные TransmittableThreadLocal текущего основного потока будут копироваться в дочерний поток, а затем очищаться после выполнение завершено. В то же время модификация в дочернем потоке не вступает в силу, когда он возвращается в основной поток. Это гарантирует, что каждая задача выполняется, не мешая друг другу. Как это делается? Посмотрите на исходный код.
-
Исходный код TtlExecutors и TransmittableThreadLocal
private TtlRunnable(Runnable runnable, boolean releaseTtlValueReferenceAfterRun) { this.capturedRef = new AtomicReference(capture()); this.runnable = runnable; this.releaseTtlValueReferenceAfterRun = releaseTtlValueReferenceAfterRun; }
com.alibaba.ttl.TtlRunnable#run /**
- wrap method {@link Runnable#run()}.
*/ @Override публичный недействительный запуск () { Захваченный объект = захваченныйRef.get();// Получить ThreadLocalMap потока if (захвачено == null || releaseTtlValueReferenceAfterRun && !capturedRef.compareAndSet(захвачено, null)) { throw new IllegalStateException("Ссылка на значение TTL освобождается после запуска!"); }
Object backup = replay(captured);// 暂存当前子线程的ThreadLocalMap到backup try { runnable.run(); } finally { restore(backup);// 恢复线程执行时被改版的Threadlocal对应的值 }
}
com.alibaba.ttl.TransmittableThreadLocal.Transmitter#replay
/**
- Replay the captured {@link TransmittableThreadLocal} values from {@link #capture()},
- and return the backup {@link TransmittableThreadLocal} values in current thread before replay.
- @param captured captured {@link TransmittableThreadLocal} values from other thread from {@link #capture()}
- @return the backup {@link TransmittableThreadLocal} values before replay
- @see #capture()
- @since 2.3.0
*/ public static Object replay(Object captured) { @SuppressWarnings("unchecked") Map<TransmittableThreadLocal, Object> capturedMap = (Map, Object>) captured; Map<TransmittableThreadLocal, Object> backup = new HashMap, Object>();
for (Iterator<? extends Map.Entry<TransmittableThreadLocal<?>, ?>> iterator = holder.get().entrySet().iterator(); iterator.hasNext(); ) { Map.Entry<TransmittableThreadLocal<?>, ?> next = iterator.next(); TransmittableThreadLocal<?> threadLocal = next.getKey(); // backup backup.put(threadLocal, threadLocal.get()); // clear the TTL value only in captured // avoid extra TTL value in captured, when run task. if (!capturedMap.containsKey(threadLocal)) { iterator.remove(); threadLocal.superRemove(); } } // set value to captured TTL for (Map.Entry<TransmittableThreadLocal<?>, Object> entry : capturedMap.entrySet()) { @SuppressWarnings("unchecked") TransmittableThreadLocal<Object> threadLocal = (TransmittableThreadLocal<Object>) entry.getKey(); threadLocal.set(entry.getValue()); } // call beforeExecute callback doExecuteCallback(true); return backup;
}
com.alibaba.ttl.TransmittableThreadLocal.Transmitter#restore
/**
- Restore the backup {@link TransmittableThreadLocal} values from {@link Transmitter#replay(Object)}.
- @param backup the backup {@link TransmittableThreadLocal} values from {@link Transmitter#replay(Object)}
- @since 2.3.0
*/ public static void restore(Object backup) { @SuppressWarnings("unchecked") Map<TransmittableThreadLocal, Object> backupMap = (Map, Object>) backup; // call afterExecute callback doExecuteCallback(false);
for (Iterator<? extends Map.Entry<TransmittableThreadLocal<?>, ?>> iterator = holder.get().entrySet().iterator(); iterator.hasNext(); ) { Map.Entry<TransmittableThreadLocal<?>, ?> next = iterator.next(); TransmittableThreadLocal<?> threadLocal = next.getKey(); // clear the TTL value only in backup // avoid the extra value of backup after restore if (!backupMap.containsKey(threadLocal)) { iterator.remove(); threadLocal.superRemove(); } } // restore TTL value for (Map.Entry<TransmittableThreadLocal<?>, Object> entry : backupMap.entrySet()) { @SuppressWarnings("unchecked") TransmittableThreadLocal<Object> threadLocal = (TransmittableThreadLocal<Object>) entry.getKey(); threadLocal.set(entry.getValue()); }
}
Вы можете увидеть полную временную диаграмму всего процесса:
Хорошо, теперь, когда проблема решена, давайте посмотрим на фактическое использование. Существует два вида использования. Давайте рассмотрим первый, который включает HTTP-запросы, Dubbo-запросы и задания и использует изоляцию на уровне данных.
3. Практическое применение TTL в зарубежных торговых центрах
3.1 Независимо от базы данных, строка данных + SpringMVC
Пользовательский HTTP-запрос, сначала нам нужно проанализировать номер страны из URL-адреса или файла cookie, затем сохранить информацию о стране в TransmittableThreadLocal, прочитать данные о стране в перехватчике MyBatis, выполнить преобразование sql и, наконец, обработать указанные данные страны, мульти- многопоточный сценарий Затем используйте TtlExecutors для переноса исходного пользовательского пула потоков, чтобы обеспечить правильную передачу информации о стране при использовании пула потоков.
-
HTTP-запрос
public class ShopShardingHelperUtil {
private static TransmittableThreadLocal<String> countrySet = new TransmittableThreadLocal<>(); /** * 获取threadLocal中设置的国家标志 * @return */ public static String getCountry() { return countrySet.get(); } /** * 设置threadLocal中设置的国家 */ public static void setCountry (String country) { countrySet.set(country.toLowerCase()); } /** * 清除标志 */ public static void clear () { countrySet.remove(); }
}
/** Перехватчик всесторонне оценивает информацию о стране в файле cookie и URL-адресе и помещает ее в TransmittableThreadLocal **/ // устанавливаем флаг страны в треде Строка country = localeContext.getLocale().getCountry().toLowerCase();
ShopShardingHelperUtil.setCountry(country);
/** Пользовательский пул потоков, оберните исходный пользовательский пул потоков с помощью TtlExecutors **/ общедоступный статический исполнитель getExecutor() {
if (executor == null) { synchronized (TransmittableExecutor.class) { if (executor == null) { executor = TtlExecutors.getTtlExecutor(initExecutor());// 用TtlExecutors装饰Executor,结合TransmittableThreadLocal解决异步线程threadlocal传递问题 } } } return executor;
}
/** Там, где фактически используется пул потоков, просто вызовите его и выполните напрямую**/ TransmittableExecutor.getExecutor().execute(новый BatchExeRunnable(param1,param2));
/** Код перехватчика mybatis использует информацию о стране TransmittableThreadLocal, преобразует исходный sql, добавляет параметры страны и различает данные страны при добавлении, удалении, изменении и запросе sql **/ Перехват общественного объекта (вызов вызова) выдает Throwable {
StatementHandler statementHandler = (StatementHandler) invocation.getTarget(); BoundSql boundSql = statementHandler.getBoundSql(); String originalSql = boundSql.getSql(); Statement statement = (Statement) CCJSqlParserUtil.parse(originalSql); String threadCountry = ShopShardingHelperUtil.getCountry(); // 线程中的国家不为空才进行处理 if (StringUtils.isNotBlank(threadCountry)) { if (statement instanceof Select) { Select selectStatement = (Select) statement; VivoSelectVisitor vivoSelectVisitor = new VivoSelectVisitor(threadCountry); vivoSelectVisitor.init(selectStatement); } else if (statement instanceof Insert) { Insert insertStatement = (Insert) statement; VivoInsertVisitor vivoInsertVisitor = new VivoInsertVisitor(threadCountry); vivoInsertVisitor.init(insertStatement); } else if (statement instanceof Update) { Update updateStatement = (Update) statement; VivoUpdateVisitor vivoUpdateVisitor = new VivoUpdateVisitor(threadCountry); vivoUpdateVisitor.init(updateStatement); } else if (statement instanceof Delete) { Delete deleteStatement = (Delete) statement; VivoDeleteVisitor vivoDeleteVisitor = new VivoDeleteVisitor(threadCountry); vivoDeleteVisitor.init(deleteStatement); } Field boundSqlField = BoundSql.class.getDeclaredField("sql"); boundSqlField.setAccessible(true); boundSqlField.set(boundSql, statement.toString()); } else { logger.error("----------- intercept not-add-country sql.... ---------" + statement.toString()); } logger.info("----------- intercept query new sql.... ---------" + statement.toString()); // 调用方法,实际上就是拦截的方法 Object result = invocation.proceed(); return result;
}
Для интерфейса Dubbo и интерфейса HTTP, которые не могут определить информацию о стране, добавьте параметр информации о стране в разделе входных параметров и установите для информации о стране значение TransmittableThreadLocal через перехватчик или вручную.
Для задания на время, поскольку все страны должны быть выполнены, все страны будут пройдены и выполнены, что также можно решить с помощью простых аннотаций.
Преобразование этой версии, тест обнаружения точек в основном прошел, и автоматическая проверка скрипта также не представляет проблем, но из-за проблем с развитием бизнеса она в итоге не была запущена.
3.2 Подбиблиотека + SpringBoot
При последующем строительстве нового национального торгового центра план подтаблицы подбазы данных был скорректирован до независимой базы данных для каждой страны, а общая структура разработки была обновлена до SpringBoot. Мы обновили этот план. Общая идея та же, но детали реализации немного отличаются.
Асинхронность в SpringBoot обычно реализуется через аннотацию **@Async, которая упакована настраиваемым пулом потоков.При ее использовании информация о локали оценивается в HTTP-запросе для записи информации о стране и последующей операции вырезания БД завершена. **
Для интерфейса Dubbo и интерфейса HTTP, которые не могут определить информацию о стране, добавьте параметр информации о стране в разделе входных параметров и установите для информации о стране значение TransmittableThreadLocal через перехватчик или вручную.
@Bean public ThreadPoolTaskExecutor threadPoolTaskExecutor(){ return TtlThreadPoolExecutors.getAsyncExecutor(); } public class TtlThreadPoolExecutors { private static final String COMMON_BUSINESS = "COMMON_EXECUTOR"; public static final int QUEUE_CAPACITY = 20000; public static ExecutorService getExecutorService() { return TtlExecutorServiceMananger.getExecutorService(COMMON_BUSINESS); } public static ExecutorService getExecutorService(String threadGroupName) { return TtlExecutorServiceMananger.getExecutorService(threadGroupName); } public static ThreadPoolTaskExecutor getAsyncExecutor() { // 用TtlExecutors装饰Executor,结合TransmittableThreadLocal解决异步线程threadlocal传递问题 return getTtlThreadPoolTaskExecutor(initTaskExecutor()); } private static ThreadPoolTaskExecutor initTaskExecutor () { return initTaskExecutor(TtlThreadPoolFactory.DEFAULT_CORE_SIZE, TtlThreadPoolFactory.DEFAULT_POOL_SIZE, QUEUE_CAPACITY); } private static ThreadPoolTaskExecutor initTaskExecutor (int coreSize, int poolSize, int executorQueueCapacity) { ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor(); taskExecutor.setCorePoolSize(coreSize); taskExecutor.setMaxPoolSize(poolSize); taskExecutor.setQueueCapacity(executorQueueCapacity); taskExecutor.setKeepAliveSeconds(120); taskExecutor.setAllowCoreThreadTimeOut(true); taskExecutor.setThreadNamePrefix("TaskExecutor-ttl"); taskExecutor.initialize(); return taskExecutor; } private static ThreadPoolTaskExecutor getTtlThreadPoolTaskExecutor(ThreadPoolTaskExecutor executor) { if (null == executor || executor instanceof ThreadPoolTaskExecutorWrapper) { return executor; } return new ThreadPoolTaskExecutorWrapper(executor); } } /** * @ClassName : LocaleContextHolder * @Description : 本地化信息上下文holder */ public class LocalizationContextHolder { private static TransmittableThreadLocal<LocalizationContext> localizationContextHolder = new TransmittableThreadLocal<>(); private static LocalizationInfo defaultLocalizationInfo = new LocalizationInfo(); private LocalizationContextHolder(){} public static LocalizationContext getLocalizationContext() { return localizationContextHolder.get(); } public static void resetLocalizationContext () { localizationContextHolder.remove(); } public static void setLocalizationContext (LocalizationContext localizationContext) { if(localizationContext == null) { resetLocalizationContext(); } else { localizationContextHolder.set(localizationContext); } } public static void setLocalizationInfo (LocalizationInfo localizationInfo) { LocalizationContext localizationContext = getLocalizationContext(); String brand = (localizationContext instanceof BrandLocalizationContext ? ((BrandLocalizationContext) localizationContext).getBrand() : null); if(StringUtils.isNotEmpty(brand)) { localizationContext = new SimpleBrandLocalizationContext(localizationInfo, brand); } else if(localizationInfo != null) { localizationContext = new SimpleLocalizationContext(localizationInfo); } else { localizationContext = null; } setLocalizationContext(localizationContext); } public static void setDefaultLocalizationInfo(@Nullable LocalizationInfo localizationInfo) { LocalizationContextHolder.defaultLocalizationInfo = localizationInfo; } public static LocalizationInfo getLocalizationInfo () { LocalizationContext localizationContext = getLocalizationContext(); if(localizationContext != null) { LocalizationInfo localizationInfo = localizationContext.getLocalizationInfo(); if(localizationInfo != null) { return localizationInfo; } } return defaultLocalizationInfo; } public static String getCountry(){ return getLocalizationInfo().getCountry(); } public static String getTimezone(){ return getLocalizationInfo().getTimezone(); } public static String getBrand(){ return getBrand(getLocalizationContext()); } public static String getBrand(LocalizationContext localizationContext) { if(localizationContext == null) { return null; } if(localizationContext instanceof BrandLocalizationContext) { return ((BrandLocalizationContext) localizationContext).getBrand(); } throw new LocaleException("unsupported localizationContext type"); } } @Override public LocaleContext resolveLocaleContext(final HttpServletRequest request) { parseLocaleCookieIfNecessary(request); LocaleContext localeContext = new TimeZoneAwareLocaleContext() { @Override public Locale getLocale() { return (Locale) request.getAttribute(LOCALE_REQUEST_ATTRIBUTE_NAME); } @Override public TimeZone getTimeZone() { return (TimeZone) request.getAttribute(TIME_ZONE_REQUEST_ATTRIBUTE_NAME); } }; // 设置线程中的国家标志 setLocalizationInfo(request, localeContext.getLocale()); return localeContext; } private void setLocalizationInfo(HttpServletRequest request, Locale locale) { String country = locale!=null?locale.getCountry():null; String language = locale!=null?(locale.getLanguage() + "_" + locale.getVariant()):null; LocaleRequestMessage localeRequestMessage = localeRequestParser.parse(request); final String countryStr = country; final String languageStr = language; final String brandStr = localeRequestMessage.getBrand(); LocalizationContextHolder.setLocalizationContext(new BrandLocalizationContext() { @Override public String getBrand() { return brandStr; } @Override public LocalizationInfo getLocalizationInfo() { return LocalizationInfoAssembler.assemble(countryStr, languageStr); } }); }
Для задания на время, поскольку необходимо выполнить все страны, будут пройдены и выполнены все страны, что также можно решить с помощью простых аннотаций и АОП.
4. Резюме
С точки зрения развития бизнеса в этой статье объясняется, как перейти на InheritableThreadLocal через ThreadLocal в сложных бизнес-сценариях, а затем решить практические бизнес-задачи с помощью TransmittableThreadLocal. Поскольку зарубежный бизнес развивается в непрерывном исследовании, а технологии также развиваются в непрерывном исследовании, перед лицом этой сложной и изменчивой ситуации наша стратегия реагирования заключается в том, чтобы сначала провести интернационализацию, а затем локализацию, более глобальное может быть более локальным, Изоляция нескольких стран — это лишь самая основная отправная точка для интернационализации, и в будущем нам еще предстоит бросить вызов многим предприятиям и технологиям.
Автор: команда разработчиков торгового центра официального сайта vivo