Spring Data JPA: доводим напильником

в 8:12, , рубрики: java, spring data jpa

Приветствую, это вторая заметка о Spring Data JPA. Первая часть была целиком посвящена подводным граблям, а также советам бывалых. В этой части мы поговорим о том, как заточить фреймворк под свои нужды. Все описанные примеры доступны здесь.

Графы

Начнём, пожалуй, с несложной и вместе с тем распространённой задачи: при загрузке некой сущности необходимо выборочно загружать её "дочку". Рассмотрим несложный пример:

@Entity
public class Child {
  @Id
  private Long id;

  @JoinColumn(name = "parent_id")
  @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
  private Parent parent;
}

@Entity
public class Parent {

  @Id
  private Long id;
}

Дочерняя сущность в нашем примере ленивая: мы не хотим подгружать лишние данные (и присоединять ещё одну таблицу в SQL-запросе) при получении Child. Но в некоторых случаях в нашем приложении мы точно знаем, что нам понадобится и ребёнок, и его родитель. Если оставить сущность ленивой — получим 2 отдельных запроса. Если применить быструю загрузку убрав FetchType.LAZY — то обе сущности будут всегда загружаться при первом же запросе (а мы этого не хотим).

Хорошее решение предоставляет из коробки JPQL — это ключевое слово fetch:

public interface ChildRepository extends JpaRepository<Child, Long> {
  @Query("select c from Child c join fetch c.parent where c.id = :id")
  Child findByIdFetchParent(@Param("id") Long id);
}

Этот запрос прост и понятен, но у него есть недостатки:

  • мы фактически продублировали логику JpaRepository::findById добавив явную подгрузку
  • каждый запрос, описанный с помощью @Query проверяется при запуске приложения, что требует синтаксического разбора запроса, проверки аргументов и т. д. (см. org.springframework.data.jpa.repository.query.SimpleJpaQuery::validateQuery). Всё это работа, требующая времени и памяти
  • использование подобного подхода в большом проекте с десятками репозиториев и переплетённых между собой сущностей (порой с десятком"дочек") приведёт к комбинаторному взрыву.

На помощь нам приходят графы:

@Entity
@NamedEntityGraphs(value = {
  @NamedEntityGraph(
    name = Child.PARENT,
    attributeNodes = @NamedAttributeNode("parent")
  )
})
public class Child {
  public static final String PARENT = "Child[parent]";

  @Id
  private Long id;

  @JoinColumn(name = "parent_id")
  @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
  private Parent parent;
}

Сам по себе граф описать несложно, трудности начинаются при его использовании. Spring Data JPA на своей странице предлагает делать это так (применительно к нашему случаю):

public interface GroupRepository extends JpaRepository<GroupInfo, String> {

  @EntityGraph(value = Child.PARENT)
  @Query("select c from Child c where c.id = :id")
  Child findByIdFetchParent(@Param("id") Long id);

}

Здесь мы видим всё те же проблемы (разве что писанный запрос стал чуть проще). Покончить с ними одним махом можно с помощью тонкой настройки. Создадим свой интерфейс, который будем использовать для построения репозиториев вместо коробочного JpaRepository:

@NoRepositoryBean
public interface BaseJpaRepository<T, ID extends Serializable> 
         extends JpaRepository<T, ID> {

  T findById(ID id, String graphName);

}

Теперь реализация:

public class BaseJpaRepositoryImpl<T, ID extends Serializable> 
     extends SimpleJpaRepository<T, ID> 
  implements BaseJpaRepository<T, ID> {

  private final JpaEntityInformation<T, ?> entityInfo;
  private final EntityManager entityManager;

  public BaseJpaRepositoryImpl(JpaEntityInformation<T, ?> ei, EntityManager em) {
    super(ei, em);
    this.entityInfo = ei;
    this.entityManager = em;
  }

  @Override
  public T findById(ID id, String graphName) {
    Assert.notNull(id, "The given id must not be null!"); //сохраняем поведение

    EntityGraph<?> graph = entityManager.getEntityGraph(graphName);
    Map<String, Object> hints = singletonMap(QueryHints.HINT_LOADGRAPH, graph);

    return entityManager.find(getDomainClass(), id, hints);
}

Теперь обяжем Спринг использовать BaseJpaRepositoryImpl в качестве основы для всех репозиториев нашего приложения:

@EnableJpaRepositories(repositoryBaseClass = BaseJpaRepositoryImpl.class)
public class AppConfig {
}

Теперь наш метод будет доступен из всех репозиториев, унаследованных от нашего BaseJpaRepository.

Данный подход имеет один недостаток, который может подложить очень жирную свинью.

Попробуйте додуматься самостоятельно

Проблема в том, что Хибернейт (по крайней мере во время написания статьи) не сопоставляет имена графов и собственно сами графы. Из-за этого возможна ошибка времени выполнения, когда мы выполняем что-то вроде

Optional<MyEntity> entity = repository.findById(id, NON_EXISTING_GRAPH);

Проверить работоспособность решения можно с помощью теста:

@Sql("/ChildRepositoryGraphTest.sql")
public class ChildRepositoryGraphTest extends TestBase {
  private final Long childId = 1L;

  @Test
  public void testGraph_expectFieldInitialized() {
    Child child1 = childRepository.findOne(childId, Child.PARENT);
    boolean initialized = Hibernate.isInitialized(child1.getParent());
    assertTrue(initialized);
  }

  @Test
  public void testGraph_expectFieldNotInitialized() {
    Child child1 = childRepository
      .findById(childId)
      .orElseThrow(NullPointerException::new);
    boolean initialized = Hibernate.isInitialized(child1.getParent());
    assertFalse(initialized);
  }
}

Когда деревья были большими

А мы были маленькими и неопытными, то часто приходилось видеть вот такой код:

public List<DailyRecord> findBetweenDates(Date from, Date to) {
  StringBuilder query = new StringBuilder("from Record ");
  if (from != null) {
    query.append(" where date >=").append(format(from)).append(" ");
  }
  if (to != null) {
    if (from == null) {
      query.append(" where date <= " + format(to) + " ");
    } else {
      query.append(" and date <= " + format(to) + " ");
    }
  }
  return em.createQuery(query.toString(), DailyRecord.class).getResultList();
}

Этот код собирает запрос по кусочкам. Недостатки такого подхода очевидны:

  • многое приходится делать руками
  • там где ручная работа — там и ошибки
  • нет подсветки синтаксиса (опечатки всплывают уже при выполнении)
  • очень сложно расширять и поддерживать код

Чуть позже появилась Criteria API, что позволило немного ужать вышеописанный код:

public List<DailyRecord> findBetweenDates(Date from, Date to) {
  Criteria criteria = em
          .unwrap(Session.class)
          .createCriteria(DailyRecord.class);

  if (from != null) {
    criteria.add(Expression.ge("date", from));
  }
  if (to != null) {
    criteria.add(Expression.le("date", to));
  }

  return criteria.list();
}

Использовании критериев имеет ряд преимуществ:

  • возможность использования метамодели, вместо "зашитых" значений вроде "date"
  • некоторые ошибки построения запроса оказываются ошибками компиляции т. е. обнаруживаются уже при написании
  • код получается короче и вразумительнее, чем при тупом склеивании строк

Есть и недостатки:

  • код достаточно сложен для понимания
  • чтобы научится писать подобные запросы нужно набивать руку (хорошо помню дичайшую боль, когда впервые пришлось столкнуться с исправлением ошибок в подобных запросах, состоящих порой из 100-150 строк, имеющих ветвление и т. д.)
  • сложный запрос получается достаточно громоздким (50 строк далеко не предел)

Хочется разрабатывать легко и с удовольствием, поэтому оба указанных способа мне не нравятся.

Обратимся к уже рассмотренной нами сущности:

@Entity
public class Child {
  @Id
  private Long id;

  @JoinColumn(name = "parent_id")
  @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
  private Parent parent;

  //...

  @OneToMany(mappedBy = "owner", cascade = CascadeType.ALL)
  @LazyCollection(value = LazyCollectionOption.EXTRA)
  private List<Toy> toys = new ArrayList<>();
}

Хочется иметь возможность загружать сущность в разных режимах (и их сочетаниях):

  • загружать (или нет) родителя
  • загружать (или нет) игрушки
  • упорядочивать детей по возрасту

Если решать эту задачу в лоб, т. е. путём написания множества запросов, соответствующих выбранному режиму загрузки, то очень быстро это приведёт к комбинаторному взрыву:

  @Query("select c from Child c join fetch c.parent order by c.age")
  List<Child> findWithParentOrderByAge();

  @Query("select c from Child c join fetch c.toys order by c.age")
  List<Child> findWithToysOrderByAge();

  @Query("select c from Child c join fetch c.parent join fetch c.toys")
  List<Child> findWithParentAndToys();

  //...

Существует простой и элегантный способ решения этой задачи: сочетание SQL/HQL и движков шаблонов. На моих проектах использовался "Фримаркер", хотя можно использовать и другие решения, ("Тимлиф", "Мусташ" и т.п.).

Приступим к созданию. Перво-наперво нам необходимо описать запрос в файле, которые получает расширение *.hql.ftl или *.sql.ftl (если используется "чистый" SQL):

#* @vtlvariable name="fetchParent" type="java.lang.Boolean" *#
#* @vtlvariable name="fetchToys"   type="java.lang.Boolean" *#
#* @vtlvariable name="orderByAge"  type="java.lang.Boolean" *#

select child 
  from Child child
#if($fetchParent)
  left join fetch child.parent
#end
#if($fetchToys)
  left join fetch child.toys
#end

#if($orderByAge)
  order by child.age
#end

Теперь потребуется обработчик:

@Component
@RequiredArgsConstructor
public class TemplateParser {
  private final Configuration configuration;

  @SneakyThrows
  public String prepareQuery(String templateName, Map<String, Object> params){
    Template template = configuration.getTemplate(templateName);
    return FreeMarkerTemplateUtils.processTemplateIntoString(template, params);
  }
}

Ничего сложного. Приступаем к репозиторию. Очевидно, что интерфейс наследующий JpaRepository нам не подходит. Вместо этого воспользуемся возможностью создания собственных репозиториев:

public interface ChildRepositoryCustom {    
  List<Child> findAll(boolean fetchParent, boolean fetchToys, boolean order);
}

@RequiredArgsConstructor
public class ChildRepositoryImpl 
     extends BaseDao 
  implements ChildRepositoryCustom {

  private final TemplateParser templateParser;

  @Override
  public List<Child> findAll(boolean fetchParent, boolean fetchToys, boolean order) {
    Map<String, Object> params = new HashMap<>();
    params.put("fetchParent", fetchParent);
    params.put("fetchToys", fetchToys);
    params.put("orderByAge", orderByAge);

    String query = templateParser.prepareQuery(BASE_CHILD_TEMPLATE.name, params);

    return em.createQuery(query, Child.class).getResultList();
  }

  @RequiredArgsConstructor
  enum RepositoryTemplates {
    BASE_CHILD_TEMPLATE("BaseChildTemplate.hql.ftl");

    public final String name;
  }
}

Чтобы метод findUsingTemplate стал доступен из ChildRepository нужно сделать вот так:

public interface ChildRepository 
     extends BaseJpaRepository<Child, Long>, ChildRepositoryCustom {
  //...
}

Важная особенность, связанная с наименованием

Спринг свяжет наш класс и интерфейсы воедино только при условии правильного наименования:

  • ChildRepository
  • ChildRepositoryCustom
  • ChildRepositoryImpl

Помните об этом, т. к. в случае ошибки в имени будет брошено невразумительное исключение, из которого невозможно понять причину ошибки.

Теперь используя этот подход можно решать более сложные задачи. Предположим, нам необходимо делать выборку на основании выбранных пользователем признаков. Иными словами, если пользователь не указал даты "от" и "до", то фильтрации по времени не будет. Если указана только дата "от" или только дата "до", то фильтрация будет односторонней. Если же указаны обе даты, то в выборку попадут только записи между указанными датами:

@Getter
@RequiredArgsConstructor
public class RequestDto {
  private final LocalDate from;
  private final LocalDate to;

  public boolean hasDateFrom() {
    return from != null;
  }

  public boolean hasDateTo() {
    return to != null;
  }
}

@Override
public List<Child> findAll(ChildRequest request) {
  Map<String, Object> params = singletonMap("request", request);

  String query = templateParser.prepareQuery(TEMPLATE.name, params);

  return em.createQuery(query, Child.class).getResultList();
}

Теперь шаблон:

<#-- @ftlvariable name="request" type="полное.имя.пакета.RequestDto" -->

select child
from Child child
<#if request.hasDateFrom() && request.hasDateTo()>
  where child.birthDate >= :dateFrom
    and child.birthDate <= :dateTo
<#elseif request.hasDateFrom()>
  where child.birthDate >= :dateFrom
<#elseif request.hasDateTo()>
  where child.birthDate <= :dateTo
</#if>

Оракл и nvl

Рассмотрим сущность:

@Entity
public class DailyRecord {
 @Id
 private Long id;

 @Column
 private String currency;

 @Column(name = "record_rate")
 private BigDecimal rate;

 @Column(name = "fixed_rate")
 private BigDecimal fxRate;

 @Setter(value = AccessLevel.PRIVATE)
 @Formula("select avg(r.record_rate) from daily_record r where r.currency = currency")
 private BigDecimal avgRate;
}

Эта сущность используется в запросе (СУБД, как мы помним, у нас Оракл):

@Query("select nvl(record.fxRate, record.avgRate) " +
        " from DailyRecord record " +
        "where record.currency = :currency")
BigDecimal findRateByCurrency(@Param("currency") String currency);

Это рабочий, годный запрос. Но с ним есть одна небольшая проблема, на которую наверняка укажут знатоки SQL. Дело в том, что nvl в Оракле не является ленивым. Иными словами, когда мы вызовем метод findRateByCurrency в журнал запросов будет выведено

select nvl(
  dr.fixed_rate,
  select avg(r.record_rate) from daily_record r where r.currency = dr.currency
)
from daily_record dr
where dr.currency = ?

Даже если значение dr.fixed_rate присутствует, СУБД всё равно вычисляет значение, возвращаемое вторым выражением в nvl, что в нашем случае

select avg(r.record_rate)
  from daily_record r
where r.currency = dr.currency)

Читатель наверняка уже знает, как увернуться от ненужного утяжеления запроса: конечно же, это ключевое слово coalesce, которое выгодно отличается от nvl своей ленивостью, а также умением принимать на вход более 2 выражений. Поправим наш запрос:

@Query("select coalesce(record.fxRate, record.avgRate) " +
        " from DailyRecord record " +
        "where record.currency = :currency")
BigDecimal findRateByCurrency(@Param("currency") String currency);

И тут, что называется, внезапно:

select nvl(dr.fixed_rate,
           select
           avg(r.record_rate)
           from
           daily_record r
           where
           r.currency = dr.currency)
from daily_record dr
where dr.currency = ?

Запрос остался тем же. Всё потому, что оракловый диалект из коробки превращает coalesce в цепочку nvl.

Замечание

Если нужно воспроизвести это поведение, то удалите вторую строку в конструкторе класса CustomOracleDialect и запустите тест DailyRecordRepositoryTest::findRateByCurrency

Чтобы увернуться от этого нужно создать свой диалект и использовать его в приложении:

public class CustomOracleDialect extends Oracle12cDialect {

    public CustomOracleDialect() {
        super();
        registerFunction("coalesce", new StandardSQLFunction("coalesce"));
    }
}

Да, вот так просто. Теперь подвяжем созданный диалект к приложению:

spring:
  jpa:
    database-platform: com.luxoft.logeek.config.CustomOracleDialect

Другой (устаревший) способ:

spring:
  jpa:
    properties:
      hibernate.dialect: com.luxoft.logeek.config.CustomOracleDialect

Повторное выполнение запрос даёт заветный коалеск:

select coalesce(dr.fixed_rate,
                select
                avg(r.record_rate)
                from
                daily_record r
                where
                r.currency = dr.currency)
from daily_record dr
where dr.currency = ?

Оракл и постраничные запросы

Вообще допиливание диалекта даёт богатые возможности для манипуляций запросами. Часто при разработке приложение и веб-мордой встречается задача постраничной выгрузки данных. Иными словами в БД у нас лежит несколько сотен тысяч записей, но отображаются они пачками по 10/50/100 записей на странице. Спринг Дата из коробки даёт разработчику подобный функционал:

@Query("select new com.luxoft.logeek.data.BriefChildData(" +
  "c.id, " +
  "c.age " +
  ") from Child c " +
  " join c.parent p " +
  "where p.name = 'папа'")
Page<BriefChildData> browse(Pageable pageable);

Этот подход имеет существенный недостаток, а именно выполнение двух запросов, первый из которых достаёт данные, а второй — определяет их полное количество в БД (это нужно для того, чтобы отобразить в объекте Page общее количество данных). В нашем случае вызов этого метода даёт такие запросы (журналирование с помощью p6spy):

select *
from (
       select c.id,
              c.age 
       from child c
       inner join parent p 
               on c.parent_id = p.id
       where p.name = 'папа'
    )
where rownum <= 3;

select count(c.id)
  from child c
 inner join parent p 
         on c.parent_id = p.id
where p.name = 'папа'

Если запрос тяжеловесный (много таблиц присоединяется по неиндексируемой колонке, просто много присоединений, сложное условие выборки и т. п.) то это может стать проблемой. Но так как у нас Оракл, то с помощью псевдоколнки rownum можно обойтись одним запросом.

Для этого нужно допилить наш диалект, и описать функцию, использующуюся для подсчёта всех записей:

public class CustomOracleDialect extends Oracle12cDialect {

  public CustomOracleDialect() {
    super();
    registerFunction("coalesce", new StandardSQLFunction("coalesce"));
    registerFunction("total_count", new TotalCountFunc());
  }
}

public class TotalCountFunc implements SQLFunction {

  @Override
  public boolean hasArguments() {
    return true;
  }

  @Override
  public boolean hasParenthesesIfNoArguments() {
    return true;
  }

  @Override
  public Type getReturnType(Type type, Mapping mapping) {
    return StandardBasicTypes.LONG;
  }

  @Override
  public String render(Type type, List arguments, SessionFactoryImplementor factory) {
    if (arguments.size() != 1) {
      throw new IllegalArgumentException("Only 1 argument acceptable");
    }

    return " count(" + arguments.get(0) + ") over () ";
  }
}

Теперь напишем новый запрос (в классе ChildRepositoryImpl):

@Override
public Page<BriefChildData> browseWithTotalCount(Pageable pageable) {
  String query =
    "select " +
      " c.id as id," +
      " c.age as age, " +
      " total_count(c.id) as totalCount" +
      " from Child c " +
      "join c.parent p " +
      "where p.name = 'папа'";

  List<BriefChildData> list = em.unwrap(Session.class)
    .createQuery(query)
    .setFirstResult((int) pageable.getOffset())
    .setMaxResults(pageable.getPageSize())
    .setResultTransformer(Transformers.aliasToBean(BriefChildData.class))
    .getResultList();

  if (list.isEmpty()) {
    return new PageImpl(Collections.emptyList());
  }

  long totalCount = list.get(0).getTotalCount();

  return new PageImpl<>(list, pageable, totalCount);
}

При вызове этого кода выполнится один запрос

select *
from (select c.id,
             c.age,
             count(c.id) over ()         -- <-----
      from child c
           inner join parent p 
                   on c.parent_id = p.id
      where p.name = 'папа')
where rownum <= 3

С помощью выражение count(c.id) over () можно получить общее количество данных и достать его из дата-класса для передачи в конструктор PageImpl. Есть способ сделать это более элегантно, без добавления ещё одного поля в дата-класс, считайте это домашним заданием :) Протестировать решение можно с помощью теста ProjectionVsDataTest.

Подводные камни кастомизации

У нас есть крутой проект с Ораклом и Спринг Датой. Перед нами поставлена задача улучшить производительность подобного кода:

List<Long> ids = getIds();

ids.stream()
  .map(repository::findById)
  .filter(Optional::isPresent)
  .map(Optional::get)
  .forEach(this::sendToSchool);

Недостаток лежит на поверхности: количество запросов к БД равно количеству уникальных ключей. Известен и способ преодоления этого затруднения:

List<Long> ids = getIds();

repository
  .findAllById(ids)
  .forEach(this::sendToSchool);

Преимущество множественной выборки очевидно: если раньше у нас было множество похожих запросов вида

select p.* from Pupil p where p.id = 1
select p.* from Pupil p where p.id = 2
select p.* from Pupil p where p.id = 3

то теперь они схлопнулись до одного

select p.*  from Pupil p where p.id in (1, 2, 3, ... )

Вроде бы полегчало и стало хорошо. Проект растёт, развивается, данные множатся и однажды наступает неизбежное:

Аки гром среди ясного неба

ERROR - ORA-01795: maximum number of expressions in a list is 1000

Нужно снова искать выход (не к старой же версии возвращаться). Раз "Оракл" не позволяет скармливать ему более 1000 ключей, то можно разделить весь набор данных на равные доли размером не более 1000 и выполнять кратное количество запросов:

List<List<Long>> idChunks = c.g.c.c.Lists.partition(ids, 1000); //*
idChunks.forEach(idChunk ->
  repository.findAllById(idChunk).forEach(this::sendToSchool)
);

//* c.g.c.c.Lists = com.google.common.collect.Lists

Этот способ работает, но слегка (слегка ли?) попахивает: при возникновении подобных сложностей в других местах придётся городить этот же огород. Попробуем решить задачу более элегантно, а именно путём допиливания BaseJpaRepositoryImpl. Простейший способ сделать это — перенести описанную выше логику внутрь, скрыв её от пользователя:

@Override
public List<T> findAllById(Iterable<ID> ids) {
  Assert.notNull(ids, "The given Iterable of Id's must not be null!");

  Set<ID> idsCopy = Sets.newHashSet(ids);

  if (idsCopy.size() <= OracleConstants.MAX_IN_COUNT) {
    return super.findAllById(ids);
  }

  return findAll(idsCopy);
}

private List<T> findAll(Collection<ID> ids) {
  List<List<ID>> idChunks = Lists.partition(new ArrayList<>(ids), 1000);
  return idChunks
    .stream()
    .map(this::findAllById)
    .flatMap(List::stream)
    .collect(Collectors.toList());
}

Стало лучше: во-первых, мы очистили рабочий код от инфраструктурных наслоений, а во-вторых расширили область действия нашего решения на все репозитории проекта, расширяющие BaseJpaRepository. Есть и недостатки. Главный из них — несколько запросов вместо одного, а также (проистекает из главного) — необходимость фильтровать ключи, ведь если этого не сделать, то в разных idChunks может оказаться один и тот же ключ. Это в свою очередь означает, что одна и та же сущность попадёт в список дважды, и, соответственно, дважды будет обработана. Этого нам не нужно, поэтому вот другое, более мудрёное решение:

@Override
public List<T> findAllById(Iterable<ID> ids) {
  Assert.notNull(ids, "The given Iterable of Id's must not be null!");

  ArrayList<ID> idsCopy = Lists.newArrayList(ids);

  if (idsCopy.size() <= OracleConstants.MAX_IN_COUNT) {
    return super.findAllById(ids);
  }

  return findAll(idsCopy);
}

private List<T> findAll(ArrayList<ID> ids) {
  CriteriaBuilder cb = entityManager.getCriteriaBuilder();
  CriteriaQuery<T> query = cb.createQuery(getDomainClass());

  Root<T> from = query.from(getDomainClass());

  Predicate predicate = toPredicate(cb, ids, from);
  query = query.select(from).where(predicate);

  return entityManager.createQuery(query).getResultList();
}

private Predicate toPredicate(CriteriaBuilder cb, ArrayList<ID> ids, Root<T> root) {
  List<List<ID>> chunks = Lists.partition(ids, OracleConstants.MAX_IN_COUNT);

  SingularAttribute<? super T, ?> id = entityInfo.getIdAttribute();

  Predicate[] predicates = chunks.stream()
    .map(chunk -> root.get(id).in(chunk))
    .toArray(Predicate[]::new);
  return cb.or(predicates);
}

Здесь используется Criteria API, что даёт возможность построить один итоговый запрос вида

select p.*  
  from Pupil p 
 where p.id in (1, 2, ... , 1000) 
    or p.id in (1001, ... , 2000)
    or p.id in (2001, ... , 3000)

Тут есть тонкость: подобный запрос может выполнятся дольше обычного из-за громоздкости условия, поэтому первый способ может (иногда) оказаться более предпочтительным.

На этом всё, надеюсь примеры были вам полезны и пригодятся в повседневной разработке. Замечания и дополнения приветствуются.

Автор: tsypanov

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js