Добро пожаловать в пост о реализации виртуального потока Java в приложении весенней загрузки. Этот пост не о деталях виртуального потока. Мы просто создадим приложение весенней загрузки, которое использует виртуальный поток на сервере.

В этом примере мы будем подключаться к внешнему источнику для получения данных. Это может быть база данных, кеш, очередь и т. д. В этом посте речь пойдет о Redis.

Начнем с пом. xml-файл.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.0.0-M4</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.farukbozan.medium</groupId>
    <artifactId>virtual-thread-web</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>virtual-thread-web</name>
    <description>virtual-thread-web</description>
    <properties>
        <maven.compiler.source>19</maven.compiler.source>
        <maven.compiler.target>19</maven.compiler.target>
        <java.version>19</java.version>
        <tomcat.version>10.1.0-M17</tomcat.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
            <type>jar</type>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <source>19</source>
                    <target>19</target>
                    <compilerArgs>
                        <arg>--enable-preview</arg>
                    </compilerArgs>
                </configuration>
            </plugin>
        </plugins>
    </build>

    <repositories>
        <repository>
            <id>spring-libs-milestone</id>
            <url>https://repo.spring.io/libs-milestone</url>
        </repository>
    </repositories>

    <pluginRepositories>
        <pluginRepository>
            <id>spring-libs-milestone</id>
            <url>https://repo.spring.io/libs-milestone</url>
        </pluginRepository>
    </pluginRepositories>

</project>

Как вы видите выше, мы используем некоторые репозитории и вехи родительского pom. Это трюк, как мы можем использовать виртуальный поток. Текущие выпуски Spring не поддерживают виртуальный поток Java.

Теперь мы создаем файл application.yaml.

server:
  tomcat:
    threads:
      max: 1

spring:
  redis:
    cluster:
      nodes: redis-host-address
    timeout: 3000
    socket-timeout: 1500

Мы используем трюк, чтобы ясно увидеть преимущества виртуальных потоков. Счетчик потоков сервера tomcat установлен на 1. Это означает, что наш сервер может принимать только один запрос одновременно из-за одного потока.

Теперь настала очередь реализовать модели, которые хранятся в Redis.

package com.farukbozan.medium.virtualthreadweb.model;

public class LocationItemCacheModel {

    private Long id;

    private String name;

    private String slug;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getSlug() {
        return slug;
    }

    public void setSlug(String slug) {
        this.slug = slug;
    }
}
package com.farukbozan.medium.virtualthreadweb.model;

public class LocationInfoCacheModel {

    private LocationItemCacheModel city;

    private LocationItemCacheModel district;

    private LocationItemCacheModel locality;

    private LocationItemCacheModel town;

    public LocationItemCacheModel getCity() {
        return city;
    }

    public void setCity(LocationItemCacheModel city) {
        this.city = city;
    }

    public LocationItemCacheModel getDistrict() {
        return district;
    }

    public void setDistrict(LocationItemCacheModel district) {
        this.district = district;
    }

    public LocationItemCacheModel getLocality() {
        return locality;
    }

    public void setLocality(LocationItemCacheModel locality) {
        this.locality = locality;
    }

    public LocationItemCacheModel getTown() {
        return town;
    }

    public void setTown(LocationItemCacheModel town) {
        this.town = town;
    }
}

Создание службы чтения-записи кэша. Вы можете изменить тело услуги, как вы хотите.

package com.farukbozan.medium.virtualthreadweb.service;

import com.farukbozan.medium.virtualthreadweb.model.LocationInfoCacheModel;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

import java.util.Optional;

@Service
public class LocationCacheAdapter {

    @Cacheable(cacheNames = "cache-name", key = "{#townId}", unless = "#result = null")
    public Optional<LocationInfoCacheModel> getLocationInfo(Long townId) {

        return Optional.ofNullable(null);

    }
}

Класс контроллера для запуска службы и кеша Redis.

package com.farukbozan.medium.virtualthreadweb.controller;

import com.farukbozan.medium.virtualthreadweb.model.LocationInfoCacheModel;
import com.farukbozan.medium.virtualthreadweb.service.LocationCacheAdapter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Optional;
import java.util.logging.Logger;

@RestController
@RequestMapping("/virtual-thread")
public class VirtualThreadController {

    @Autowired
    private LocationCacheAdapter locationCacheAdapter;

    @GetMapping
    public Optional<LocationInfoCacheModel> getLocationInfo() throws InterruptedException {
        var locationInfo = locationCacheAdapter.getLocationInfo(10046L);
        Thread.sleep(2000);
        Logger.getLogger(VirtualThreadController.class.getName()).info("Cache read");
        return locationInfo;
    }
}

Мы используем механизм сна потока для имитации задержки чтения-записи соединения Redis. 2000 мс — это очень долго, но мы можем более четко увидеть преимущества виртуального потока.

Наконец, классы конфигурации. Во-первых, это конфигурация Redis.

package com.farukbozan.medium.virtualthreadweb.configuration;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.cache.RedisCacheWriter;
import org.springframework.data.redis.connection.RedisClusterConfiguration;
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.text.SimpleDateFormat;
import java.util.List;
import java.util.Objects;

@Configuration
@EnableCaching
public class RedisConfiguration {

    @Value(value = "${spring.redis.cluster.nodes}")
    private String clusterNode;

    @Bean
    public JedisConnectionFactory jedisConnectionFactory() {
        return new JedisConnectionFactory(new RedisClusterConfiguration(List.of(clusterNode)));

    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate() {

        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(jedisConnectionFactory());

        var objectMapper = createObjectMapper();

        var jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
        jackson2JsonRedisSerializer.setObjectMapper(objectMapper);

        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
        redisTemplate.afterPropertiesSet();

        return redisTemplate;

    }

    @Bean("cache-manager")
    public RedisCacheManager redisCacheManager(RedisTemplate<String, Object> redisTemplate) {
        var redisCacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(Objects.requireNonNull(redisTemplate.getConnectionFactory()));
        var redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
                                                             .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(
                                                                     redisTemplate.getValueSerializer()));

        return new RedisCacheManager(redisCacheWriter, redisCacheConfiguration);

    }

    private ObjectMapper createObjectMapper() {
        var objectMapper = new ObjectMapper();
        objectMapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
                                           ObjectMapper.DefaultTyping.NON_FINAL,
                                           JsonTypeInfo.As.WRAPPER_ARRAY);
        return objectMapper;

    }


}

Во-вторых, где все меняется. Если приведенная ниже конфигурация включена, наш сервер tomcat будет использовать способ виртуального потока. В противном случае классический способ потока Java.

package com.farukbozan.medium.virtualthreadweb.configuration;

import org.springframework.boot.web.embedded.tomcat.TomcatProtocolHandlerCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.AsyncTaskExecutor;
import org.springframework.core.task.support.TaskExecutorAdapter;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

//@Configuration
public class VirtualThreadConfig {

    @Bean
    public AsyncTaskExecutor applicationTaskExecutor() {
        // enable async servlet support
        ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
        return new TaskExecutorAdapter(executorService::execute);
    }

    @Bean
    public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {

        return protocolHandler -> {
            protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
        };
    }
}

И последнее, что нам нужно, это загрузка сервера. Мы можем использовать Apache JMeter для создания нагрузки на сервер. Наш сервер имеет один поток, поэтому он не может создать ответ, пока предыдущий запрос не будет завершен.

Когда

//@Configuration

изменился на

@Configuration

tomcat примет следующий запрос из-за объединения виртуального потока и Thread.sleep.

Наконец, я хочу поделиться образцом файла JMeter для создания нагрузочного теста.

<?xml version="1.0" encoding="UTF-8"?>
<jmeterTestPlan version="1.2" properties="5.0" jmeter="5.5">
  <hashTree>
    <TestPlan guiclass="TestPlanGui" testclass="TestPlan" testname="Test Plan" enabled="true">
      <stringProp name="TestPlan.comments"></stringProp>
      <boolProp name="TestPlan.functional_mode">false</boolProp>
      <boolProp name="TestPlan.tearDown_on_shutdown">true</boolProp>
      <boolProp name="TestPlan.serialize_threadgroups">false</boolProp>
      <elementProp name="TestPlan.user_defined_variables" elementType="Arguments" guiclass="ArgumentsPanel" testclass="Arguments" testname="User Defined Variables" enabled="true">
        <collectionProp name="Arguments.arguments"/>
      </elementProp>
      <stringProp name="TestPlan.user_define_classpath"></stringProp>
    </TestPlan>
    <hashTree>
      <ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="Thread Group" enabled="true">
        <stringProp name="ThreadGroup.on_sample_error">continue</stringProp>
        <elementProp name="ThreadGroup.main_controller" elementType="LoopController" guiclass="LoopControlPanel" testclass="LoopController" testname="Loop Controller" enabled="true">
          <boolProp name="LoopController.continue_forever">false</boolProp>
          <stringProp name="LoopController.loops">1</stringProp>
        </elementProp>
        <stringProp name="ThreadGroup.num_threads">5</stringProp>
        <stringProp name="ThreadGroup.ramp_time">1</stringProp>
        <boolProp name="ThreadGroup.scheduler">false</boolProp>
        <stringProp name="ThreadGroup.duration"></stringProp>
        <stringProp name="ThreadGroup.delay"></stringProp>
        <boolProp name="ThreadGroup.same_user_on_next_iteration">true</boolProp>
      </ThreadGroup>
      <hashTree>
        <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="HTTP Request" enabled="true">
          <elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" testname="User Defined Variables" enabled="true">
            <collectionProp name="Arguments.arguments"/>
          </elementProp>
          <stringProp name="HTTPSampler.domain">localhost</stringProp>
          <stringProp name="HTTPSampler.port">8080</stringProp>
          <stringProp name="HTTPSampler.protocol">http</stringProp>
          <stringProp name="HTTPSampler.contentEncoding"></stringProp>
          <stringProp name="HTTPSampler.path">/virtual-thread&quot;</stringProp>
          <stringProp name="HTTPSampler.method">GET</stringProp>
          <boolProp name="HTTPSampler.follow_redirects">true</boolProp>
          <boolProp name="HTTPSampler.auto_redirects">false</boolProp>
          <boolProp name="HTTPSampler.use_keepalive">true</boolProp>
          <boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
          <stringProp name="HTTPSampler.embedded_url_re"></stringProp>
          <stringProp name="HTTPSampler.connect_timeout"></stringProp>
          <stringProp name="HTTPSampler.response_timeout"></stringProp>
        </HTTPSamplerProxy>
        <hashTree/>
      </hashTree>
    </hashTree>
  </hashTree>
</jmeterTestPlan>

Вы можете создать файл с расширением jmx, скопировать вышеуказанное содержимое и запустить его на JMeter. А затем проверьте отметку времени в журналах, чтобы увидеть разницу. Вы можете изменить регистрацию в классе

VirtualThreadController

Это здорово. Теперь у нас есть загрузочное приложение Spring, которое использует виртуальный поток вместо классического.

Увидимся в следующем посте.