Spring AI · Spring AI ilə ilk addımlar · Dərs 3

İlk Spring AI tətbiqinin qurulması

Spring AI ilə tanış ol, yeni Spring layihəsi yarat və kurs boyunca inkişaf etdirəcəyin tətbiqin əsasını qur.

İlk Spring AI tətbiqinin qurulması

Əvvəlki dərsdəki study-coach qovluğunda davam et. Mövcud faylları saxla. POST /api/study/questions endpoint-ini əlavə edəcək, provayderə müraciət etmədən test edəcək, sonra isə ayrıca real yoxlamanı izah edəcəyik.

1. Bir provayder inteqrasiyası əlavə et

Bu asılılığı mövcud POM-un əsas <dependencies> elementinə, web və validation starter-lərin yanına əlavə et. Onu <dependencyManagement> daxilinə yerləşdirmə. Versiyasını mövcud Spring AI 1.1.8 BOM müəyyən edir:

xml
<dependency>
  <groupId>org.springframework.ai</groupId>
  <artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>

Bu dərsdə parametrləri spring.ai.openai ilə başlayan OpenAI inteqrasiyası istifadə edilir. Bu starter spring-ai-starter-model-openai-sdk ilə eyni deyil; onların konfiqurasiya prefikslərini qarışdırma. src/main/resources/application.yml faylını bununla əvəz et:

yaml
server:
  address: 127.0.0.1
  port: 8080
  error:
    include-message: never
    include-binding-errors: never
    include-stacktrace: never
spring:
  application:
    name: study-coach
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      chat:
        options:
          model: ${OPENAI_MODEL}
    retry:
      max-attempts: 1

Bütöv tətbiqi başlatmazdan əvvəl OPENAI_API_KEY və OPENAI_MODEL dəyişənlərini IDE-nin lokal mühit konfiqurasiyası və ya mövcud məxfi məlumat idarəetmə mexanizmin vasitəsilə ver. Tələb olunan API icazəsinə malik provayder açarı və hesabında əlçatan olan model istifadə et. Model bu inteqrasiyanın istifadə etdiyi Chat Completions API-ni dəstəkləməlidir. Seçim zamanı provayderin cari model sənədlərinə bax. Açarları mənbə fayllarına, prompt-lara, tarixçədə saxlanan shell əmrlərinə, ekran görüntülərinə və ya test nümunələrinə yazma. Bu quruluş .env faylını avtomatik oxumur.

Dəyişənlər üçün ehtiyat dəyər yoxdur. Uğurlu real çağırışdan əvvəl çatışmayan konfiqurasiyanı düzəlt; production üçün saxta giriş açarı uydurma. Modellərin əlçatanlığı dəyişdiyindən model adı açıq verilir. max-attempts: 1 Spring AI retry template üçün cəmi bir cəhd təyin edir, yəni ilk cəhddən sonra təkrar çağırış əlavə etmir. Bu parametr HTTP timeout və ya sorğunun ümumi vaxt həddini təyin etmir. OpenAI inteqrasiyasının konfiqurasiyası

2. Model sorğusunu servisdə hazırla

src/main/java/academy/study/StudyService.java faylını saxla:

Java
package academy.study;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.stereotype.Service;

@Service
public class StudyService {
    private final ChatClient chatClient;

    public StudyService(ChatClient.Builder builder) {
        this.chatClient = builder.defaultSystem("""
                You are a study coach for Java backend learners.
                Answer in the language of the question.
                Give a short explanation and one small example.
                If you are unsure, say so. Do not claim to have read course files.
                """).build();
    }

    public String answer(String question) {
        return chatClient.prompt()
                .messages(new UserMessage(question))
                .call()
                .content();
    }
}

Bu model starter-i ilə Boot-un avtomatik konfiqurasiyası ChatClient.Builder təqdim edir. Servis standart system təlimatları ilə bir client yaradır. Hər answer çağırışı yeni prompt hazırlayır və sualı UserMessage kimi ötürür. Sual şablon deyil, mesaj mətni kimi qəbul olunur, ona görə sualın daxilindəki Java fiqurlu mötərizələri qorunur.

call().content() sinxron cavab mətnini alır. O, tokenləri HTTP müştərisinə axın şəklində göndərmir. Servis söhbət tarixçəsini saxlamır və kurs fayllarını oxumur. Modeldən əmin olmadığını bildirməyi istəmək faydalı təlimatdır, amma dürüstlüyə və faktların düzgünlüyünə zəmanət vermir. ChatClient mesajları və çağırışları

3. HTTP müqaviləsini controller-də saxla

src/main/java/academy/study/StudyController.java faylını saxla:

Java
package academy.study;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

@RestController
@RequestMapping("/api/study")
public class StudyController {
    private final StudyService studyService;

    public StudyController(StudyService studyService) {
        this.studyService = studyService;
    }

    @PostMapping("/questions")
    public Answer ask(@Valid @RequestBody Question request) {
        String answer = studyService.answer(request.question().strip());
        if (answer == null || answer.isBlank()) {
            throw new ResponseStatusException(HttpStatus.BAD_GATEWAY);
        }
        return new Answer(answer);
    }

    public record Question(@NotBlank @Size(max = 2000) String question) {}
    public record Answer(String answer) {}
}

@Valid controller servisi çağırmazdan əvvəl record-un Bean Validation məhdudiyyətlərini tətbiq edir. @NotBlank null, boş və yalnız boşluq simvollarından ibarət dəyərləri rədd edir; @Size Java sətir uzunluğunu 2 000 UTF-16 kod vahidi ilə məhdudlaşdırır. Uzunluq strip() çağırışından əvvəl yoxlanır, yəni başlanğıc və sondakı boşluqlar da limitə daxildir. Səhv JSON da model çağırışından əvvəl rədd edilir.

Controller obyekt qaytarır; Spring onu JSON-a çevirir. HTTP cavabının formatını qurmağı modeldən istəmirik. Modelin null və ya boş nəticəsi HTTP 502-yə çevrilir. Digər tutulmayan provayder xətaları bu ilkin tətbiqdə adətən HTTP 500 ilə nəticələnir. Xəta parametrləri Boot-un standart xəta cavabında mesajı, binding-error və stack-trace detallarını gizlədir. Onlar bütün logları təmizləmir. Hələ production üçün xəta cavabları, bütöv sorğu ölçüsünün limiti və giriş nəzarəti hazırlanmamışdır.

4. Model sərhədini açarsız test et

src/test/java/academy/study/StudyServiceTest.java faylını saxla:

Java
package academy.study;

import java.util.List;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.MessageType;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

class StudyServiceTest {
    @Test
    void passesMessagesToModelAndReturnsText() {
        var model = mock(ChatModel.class);
        when(model.call(any(Prompt.class))).thenReturn(new ChatResponse(
                List.of(new Generation(new AssistantMessage("A class defines a type.")))));
        var service = new StudyService(ChatClient.builder(model));
        String question = "What does class Sample {} mean?";

        assertEquals("A class defines a type.", service.answer(question));
        var prompt = ArgumentCaptor.forClass(Prompt.class);
        verify(model).call(prompt.capture());
        var messages = prompt.getValue().getInstructions();
        assertEquals(2, messages.size());
        assertEquals(MessageType.SYSTEM, messages.get(0).getMessageType());
        assertEquals(MessageType.USER, messages.get(1).getMessageType());
        assertEquals(question, messages.get(1).getText());
    }
}

Real ChatClient mock edilmiş ChatModel ilə işləyir. Test mesaj rollarını, sualdakı mötərizələrin dəyişmədiyini və qaytarılan mətni yoxlayır. Modelin biliyini ölçmür. src/test/java/academy/study/StudyControllerTest.java faylını saxla:

Java
package academy.study;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

class StudyControllerTest {
    private StudyService service;
    private MockMvc mvc;

    @BeforeEach
    void setUp() {
        service = mock(StudyService.class);
        mvc = MockMvcBuilders.standaloneSetup(new StudyController(service)).build();
    }

    @Test
    void returnsAnswerForValidQuestion() throws Exception {
        when(service.answer("What is Java?")).thenReturn("A programming language.");
        mvc.perform(post("/api/study/questions")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"question\":\"  What is Java?  \"}"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.answer").value("A programming language."));
        verify(service).answer("What is Java?");
    }

    @Test
    void rejectsInvalidQuestionsBeforeCallingService() throws Exception {
        for (String body : new String[] {
                "{}", "{\"question\":null}", "{\"question\":\"   \"}",
                "{\"question\":\"" + "x".repeat(2001) + "\"}" }) {
            mvc.perform(post("/api/study/questions")
                            .contentType(MediaType.APPLICATION_JSON).content(body))
                    .andExpect(status().isBadRequest());
        }
        verifyNoInteractions(service);
    }

    @Test
    void acceptsQuestionAtLengthLimit() throws Exception {
        String question = "x".repeat(2000);
        when(service.answer(question)).thenReturn("Example answer.");
        mvc.perform(post("/api/study/questions")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"question\":\"" + question + "\"}"))
                .andExpect(status().isOk());
    }

    @Test
    void rejectsMissingModelText() throws Exception {
        when(service.answer("Explain Java")).thenReturn(null, "   ");
        for (int attempt = 0; attempt < 2; attempt++) {
            mvc.perform(post("/api/study/questions")
                            .contentType(MediaType.APPLICATION_JSON)
                            .content("{\"question\":\"Explain Java\"}"))
                    .andExpect(status().isBadGateway());
        }
    }
}

Bu testlər uğurlu cavabı, kənar boşluqların silinməsini, olmayan/null/boş/həddən uzun girişi, dəqiq uzunluq sərhədini və model mətninin olmamasını yoxlayır. Rədd edilən giriş servisə çatmamalıdır. Bütöv tətbiq kontekstini başlatmadıqları üçün nə bu testlərə, nə də əvvəlki status testinə provayder açarı lazımdır.

5. Build ilə real yoxlamanı ayır

Əvvəl build-i işə sal. İkinci əmri yalnız seçdiyin lokal mexanizmlə iki mühit dəyişənini verdikdən sonra icra et:

sh
mvn clean verify
java -jar target/study-coach-0.0.1-SNAPSHOT.jar

İndi cəmi altı test metodu keçməlidir: bir status testi, bir servis testi və dörd controller testi. Maven asılılıqları endirmək üçün şəbəkədən istifadə edə bilər, amma bu testlər model provayderini çağırmır. Tətbiqin uğurla başlaması da provayderin hesabı və ya seçilmiş modeli qəbul etdiyini sübut etmir.

İkinci terminaldan məxfi məlumat olmayan bir məşq sualı göndər:

sh
curl --fail-with-body http://127.0.0.1:8080/api/study/questions \
  -H 'Content-Type: application/json' \
  --data '{"question":"What is the difference between an interface and a class in Java?"}'

Uğurlu cavabda boş olmayan answer sətri olur. Onu əvvəlcədən müəyyən edilmiş cümlə ilə müqayisə etmə: ifadə və keyfiyyət dəyişə bilər. Bu real sorğu sualı və system təlimatlarını provayderə göndərir, istifadəyə görə xərc yarada bilər. Sonra etibarsız girişi yoxla:

sh
curl -i http://127.0.0.1:8080/api/study/questions \
  -H 'Content-Type: application/json' \
  --data '{"question":"   "}'

Bu sorğu servisi çağırmadan HTTP 400 qaytarmalıdır. curl -i cavab başlıqlarını da göstərdiyindən statusu görə bilərsən. İşin bitəndə serveri Ctrl+C ilə dayandır.

6. Xətanı araşdır və növbəti addıma hazırlaş

Tapşırıq: provayder üçün mühit dəyişənləri olmadan testləri işə sal, sonra yalnız icazəli hesabı konfiqurasiya etmisənsə, real yoxlama apar. Statusu və cavabın suala uyğun olub-olmadığını qeyd et. Məxfi məlumatları və həssas prompt-ların tam mətnini qeydə alma. Daxilində {} olan Java sualı sına və onun modelə niyə dəyişmədən çatdığını izah et.

Cavabın yoxlanması: provayderə çıxışdan asılı olmayaraq offline testlər keçməlidir. Real çağırışda xəta çatışmayan konfiqurasiya, etibarsız açar, modelə giriş, istifadə limiti və ya bağlantı ilə əlaqəli ola bilər. Açarları və sorğu başlıqlarını paylaşmadan xəta kateqoriyasını lokal olaraq araşdır. Məşqdə hər provayder xətası ayrıca müştəri statusuna çevrilmir. Səbəbi başa düşmədən uğursuz ödənişli sorğunu təkrar-təkrar göndərmə.

Java kompilyasiyası, paketləmə və altı offline test bu implementasiyanı xarici hesab olmadan yoxlaya bilər. Onlar ödənişli real cavabı, modelin hazırda əlçatanlığını və cavab keyfiyyətini təsdiqləmir. Növbəti modulda eyni servisə konkret qiymətləndirmə meyarları əlavə edəcəyik. Layihəni və testləri saxla; onları əlaqəsiz chat nümunəsi ilə əvəz etmə.