├── .github └── FUNDING.yml ├── .gitattributes ├── documentation ├── project-diagram.jpeg └── project-diagram.excalidraw ├── mysql ├── master-proxysql-monitor-user.sql ├── master-replication.sql └── slave-replication.sql ├── proxysql-admin.sh ├── customer-api ├── src │ ├── main │ │ ├── java │ │ │ └── com │ │ │ │ └── ivanfranchin │ │ │ │ └── customerapi │ │ │ │ ├── customer │ │ │ │ ├── dto │ │ │ │ │ ├── UpdateCustomerRequest.java │ │ │ │ │ ├── CreateCustomerRequest.java │ │ │ │ │ └── CustomerResponse.java │ │ │ │ ├── CustomerRepository.java │ │ │ │ ├── exception │ │ │ │ │ └── CustomerNotFoundException.java │ │ │ │ ├── CustomerService.java │ │ │ │ ├── model │ │ │ │ │ └── Customer.java │ │ │ │ └── CustomerController.java │ │ │ │ ├── CustomerApiApplication.java │ │ │ │ └── config │ │ │ │ └── ErrorAttributesConfig.java │ │ └── resources │ │ │ ├── application.yml │ │ │ └── banner.txt │ └── test │ │ └── java │ │ └── com │ │ └── ivanfranchin │ │ └── customerapi │ │ └── CustomerApiApplicationTests.java └── pom.xml ├── check-replication-status.sh ├── shutdown-environment.sh ├── .gitignore ├── scripts └── my-functions.sh ├── .mvn └── wrapper │ └── maven-wrapper.properties ├── pom.xml ├── proxysql └── proxysql.cnf ├── init-environment.sh ├── mvnw.cmd ├── mvnw └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ivangfr 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /mvnw text eol=lf 2 | *.cmd text eol=crlf 3 | -------------------------------------------------------------------------------- /documentation/project-diagram.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivangfr/springboot-proxysql-mysql/HEAD/documentation/project-diagram.jpeg -------------------------------------------------------------------------------- /mysql/master-proxysql-monitor-user.sql: -------------------------------------------------------------------------------- 1 | CREATE USER 'monitor'@'%' IDENTIFIED BY 'monitor'; 2 | GRANT ALL PRIVILEGES ON *.* TO 'monitor'@'%'; 3 | FLUSH PRIVILEGES; -------------------------------------------------------------------------------- /proxysql-admin.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | docker exec -it -e MYSQL_PWD=radmin mysql-master bash -c 'mysql -hproxysql -P6032 -uradmin --prompt "ProxySQL Admin> "' -------------------------------------------------------------------------------- /mysql/master-replication.sql: -------------------------------------------------------------------------------- 1 | CREATE USER 'replication'@'%' IDENTIFIED BY 'replication'; 2 | GRANT REPLICATION SLAVE ON *.* TO 'replication'@'%'; 3 | FLUSH PRIVILEGES; 4 | -------------------------------------------------------------------------------- /mysql/slave-replication.sql: -------------------------------------------------------------------------------- 1 | CHANGE MASTER TO MASTER_HOST='mysql-master', MASTER_USER='replication', MASTER_PASSWORD='replication', MASTER_AUTO_POSITION = 1; 2 | START SLAVE; 3 | -------------------------------------------------------------------------------- /customer-api/src/main/java/com/ivanfranchin/customerapi/customer/dto/UpdateCustomerRequest.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.customerapi.customer.dto; 2 | 3 | public record UpdateCustomerRequest(String firstName, String lastName) { 4 | } 5 | -------------------------------------------------------------------------------- /customer-api/src/main/java/com/ivanfranchin/customerapi/customer/dto/CreateCustomerRequest.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.customerapi.customer.dto; 2 | 3 | import jakarta.validation.constraints.NotBlank; 4 | 5 | public record CreateCustomerRequest(@NotBlank String firstName, @NotBlank String lastName) { 6 | } 7 | -------------------------------------------------------------------------------- /customer-api/src/test/java/com/ivanfranchin/customerapi/CustomerApiApplicationTests.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.customerapi; 2 | 3 | import org.junit.jupiter.api.Disabled; 4 | import org.junit.jupiter.api.Test; 5 | import org.springframework.boot.test.context.SpringBootTest; 6 | 7 | @Disabled 8 | @SpringBootTest 9 | class CustomerApiApplicationTests { 10 | 11 | @Test 12 | void contextLoads() { 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /customer-api/src/main/java/com/ivanfranchin/customerapi/customer/CustomerRepository.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.customerapi.customer; 2 | 3 | import com.ivanfranchin.customerapi.customer.model.Customer; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | import org.springframework.stereotype.Repository; 6 | 7 | @Repository 8 | public interface CustomerRepository extends JpaRepository { 9 | } 10 | -------------------------------------------------------------------------------- /customer-api/src/main/java/com/ivanfranchin/customerapi/CustomerApiApplication.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.customerapi; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class CustomerApiApplication { 8 | 9 | public static void main(String[] args) { 10 | SpringApplication.run(CustomerApiApplication.class, args); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /customer-api/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | application: 3 | name: customer-api 4 | jpa: 5 | hibernate: 6 | ddl-auto: update 7 | datasource: 8 | url: jdbc:mysql://${DATASOURCE_HOST:localhost}:${DATASOURCE_PORT:6033}/customerdb?characterEncoding=UTF-8&serverTimezone=UTC 9 | username: admin 10 | password: admin 11 | 12 | logging: 13 | level: 14 | org.hibernate: 15 | SQL: DEBUG 16 | type.descriptor.sql.BasicBinder: TRACE 17 | -------------------------------------------------------------------------------- /customer-api/src/main/java/com/ivanfranchin/customerapi/customer/dto/CustomerResponse.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.customerapi.customer.dto; 2 | 3 | import com.ivanfranchin.customerapi.customer.model.Customer; 4 | 5 | public record CustomerResponse(Long id, String firstName, String lastName) { 6 | 7 | public static CustomerResponse from(Customer customer) { 8 | return new CustomerResponse(customer.getId(), customer.getFirstName(), customer.getLastName()); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /customer-api/src/main/resources/banner.txt: -------------------------------------------------------------------------------- 1 | _ _ 2 | ___ _ _ ___| |_ ___ _ __ ___ ___ _ __ __ _ _ __ (_) 3 | / __| | | / __| __/ _ \| '_ ` _ \ / _ \ '__|____ / _` | '_ \| | 4 | | (__| |_| \__ \ || (_) | | | | | | __/ | |_____| (_| | |_) | | 5 | \___|\__,_|___/\__\___/|_| |_| |_|\___|_| \__,_| .__/|_| 6 | |_| 7 | :: Spring Boot :: ${spring-boot.formatted-version} 8 | -------------------------------------------------------------------------------- /check-replication-status.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | echo 4 | echo "mysql-master" 5 | echo "------------" 6 | docker exec -i -e MYSQL_PWD=secret mysql-master mysql -uroot <<< "SHOW MASTER STATUS" 7 | echo 8 | 9 | echo "mysql-slave-1" 10 | echo "-------------" 11 | docker exec -i -e MYSQL_PWD=secret mysql-slave-1 mysql -uroot <<< "SHOW SLAVE STATUS \G" 12 | echo 13 | 14 | echo "mysql-slave-2" 15 | echo "-------------" 16 | docker exec -i -e MYSQL_PWD=secret mysql-slave-2 mysql -uroot <<< "SHOW SLAVE STATUS \G" 17 | echo -------------------------------------------------------------------------------- /shutdown-environment.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | echo 4 | echo "Starting the environment shutdown" 5 | echo "=================================" 6 | 7 | echo 8 | echo "Removing containers" 9 | echo "-------------------" 10 | docker rm -fv mysql-master mysql-slave-1 mysql-slave-2 proxysql 11 | 12 | echo 13 | echo "Removing network" 14 | echo "----------------" 15 | docker network rm springboot-proxysql-mysql 16 | 17 | echo 18 | echo "Environment shutdown successfully" 19 | echo "=================================" 20 | echo -------------------------------------------------------------------------------- /customer-api/src/main/java/com/ivanfranchin/customerapi/customer/exception/CustomerNotFoundException.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.customerapi.customer.exception; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.web.bind.annotation.ResponseStatus; 5 | 6 | @ResponseStatus(HttpStatus.NOT_FOUND) 7 | public class CustomerNotFoundException extends RuntimeException { 8 | 9 | public CustomerNotFoundException(Long id) { 10 | super(String.format("Customer with id '%s' not found", id)); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | !.mvn/wrapper/maven-wrapper.jar 3 | !**/src/main/**/target/ 4 | !**/src/test/**/target/ 5 | 6 | ### STS ### 7 | .apt_generated 8 | .classpath 9 | .factorypath 10 | .project 11 | .settings 12 | .springBeans 13 | .sts4-cache 14 | 15 | ### IntelliJ IDEA ### 16 | .idea 17 | *.iws 18 | *.iml 19 | *.ipr 20 | 21 | ### NetBeans ### 22 | /nbproject/private/ 23 | /nbbuild/ 24 | /dist/ 25 | /nbdist/ 26 | /.nb-gradle/ 27 | build/ 28 | !**/src/main/**/build/ 29 | !**/src/test/**/build/ 30 | 31 | ### VS Code ### 32 | .vscode/ 33 | 34 | ### MAC OS ### 35 | *.DS_Store 36 | -------------------------------------------------------------------------------- /scripts/my-functions.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | TIMEOUT=120 4 | 5 | # -- wait_for_container_log -- 6 | # $1: docker container name 7 | # S2: spring value to wait to appear in container logs 8 | function wait_for_container_log() { 9 | local log_waiting="Waiting for string '$2' in the $1 logs ..." 10 | echo "${log_waiting} It will timeout in ${TIMEOUT}s" 11 | SECONDS=0 12 | 13 | while true ; do 14 | local log=$(docker logs $1 2>&1 | grep "$2") 15 | if [ -n "$log" ] ; then 16 | echo $log 17 | break 18 | fi 19 | 20 | if [ $SECONDS -ge $TIMEOUT ] ; then 21 | echo "${log_waiting} TIMEOUT" 22 | break; 23 | fi 24 | sleep 1 25 | done 26 | } -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | wrapperVersion=3.3.2 18 | distributionType=only-script 19 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip 20 | -------------------------------------------------------------------------------- /customer-api/src/main/java/com/ivanfranchin/customerapi/customer/CustomerService.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.customerapi.customer; 2 | 3 | import com.ivanfranchin.customerapi.customer.exception.CustomerNotFoundException; 4 | import com.ivanfranchin.customerapi.customer.model.Customer; 5 | import lombok.RequiredArgsConstructor; 6 | import org.springframework.stereotype.Service; 7 | 8 | import java.util.List; 9 | 10 | @RequiredArgsConstructor 11 | @Service 12 | public class CustomerService { 13 | 14 | private final CustomerRepository customerRepository; 15 | 16 | public List getAllCustomers() { 17 | return customerRepository.findAll(); 18 | } 19 | 20 | public Customer validateAndGetCustomer(Long id) { 21 | return customerRepository.findById(id).orElseThrow(() -> new CustomerNotFoundException(id)); 22 | } 23 | 24 | public Customer saveCustomer(Customer customer) { 25 | return customerRepository.save(customer); 26 | } 27 | 28 | public void deleteCustomer(Customer customer) { 29 | customerRepository.delete(customer); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /customer-api/src/main/java/com/ivanfranchin/customerapi/config/ErrorAttributesConfig.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.customerapi.config; 2 | 3 | import org.springframework.boot.web.error.ErrorAttributeOptions; 4 | import org.springframework.boot.web.error.ErrorAttributeOptions.Include; 5 | import org.springframework.boot.web.servlet.error.DefaultErrorAttributes; 6 | import org.springframework.boot.web.servlet.error.ErrorAttributes; 7 | import org.springframework.context.annotation.Bean; 8 | import org.springframework.context.annotation.Configuration; 9 | import org.springframework.web.context.request.WebRequest; 10 | 11 | import java.util.Map; 12 | 13 | @Configuration 14 | public class ErrorAttributesConfig { 15 | 16 | @Bean 17 | ErrorAttributes errorAttributes() { 18 | return new DefaultErrorAttributes() { 19 | @Override 20 | public Map getErrorAttributes(WebRequest webRequest, ErrorAttributeOptions options) { 21 | return super.getErrorAttributes(webRequest, 22 | options.including(Include.EXCEPTION, Include.MESSAGE, Include.BINDING_ERRORS)); 23 | } 24 | }; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | org.springframework.boot 7 | spring-boot-starter-parent 8 | 3.5.3 9 | 10 | 11 | com.ivanfranchin 12 | springboot-proxysql-mysql 13 | 0.0.1-SNAPSHOT 14 | pom 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | springboot-proxysql-mysql 29 | Demo project for Spring Boot 30 | 31 | 21 32 | 33 | 34 | customer-api 35 | 36 | 37 | -------------------------------------------------------------------------------- /proxysql/proxysql.cnf: -------------------------------------------------------------------------------- 1 | datadir="/var/lib/proxysql" 2 | 3 | admin_variables= 4 | { 5 | admin_credentials="admin:admin;radmin:radmin" 6 | mysql_ifaces="0.0.0.0:6032" 7 | refresh_interval=2000 8 | } 9 | 10 | mysql_variables= 11 | { 12 | threads=4 13 | max_connections=2048 14 | default_query_delay=0 15 | default_query_timeout=36000000 16 | have_compress=true 17 | poll_timeout=2000 18 | interfaces="0.0.0.0:6033;/tmp/proxysql.sock" 19 | default_schema="information_schema" 20 | stacksize=1048576 21 | server_version="5.1.30" 22 | connect_timeout_server=10000 23 | monitor_history=60000 24 | monitor_connect_interval=200000 25 | monitor_ping_interval=200000 26 | ping_interval_server_msec=10000 27 | ping_timeout_server=200 28 | commands_stats=true 29 | sessions_sort=true 30 | monitor_username="monitor" 31 | monitor_password="monitor" 32 | } 33 | 34 | mysql_replication_hostgroups = 35 | ( 36 | { writer_hostgroup=10 , reader_hostgroup=20 , comment="host groups" } 37 | ) 38 | 39 | mysql_servers = 40 | ( 41 | { address="mysql-master" , port=3306 , hostgroup=10, max_connections=100 , max_replication_lag = 5 }, 42 | { address="mysql-slave-1" , port=3306 , hostgroup=20, max_connections=100 , max_replication_lag = 5 }, 43 | { address="mysql-slave-2" , port=3306 , hostgroup=20, max_connections=100 , max_replication_lag = 5 } 44 | ) 45 | 46 | mysql_query_rules = 47 | ( 48 | { 49 | rule_id=100 50 | active=1 51 | match_pattern="^SELECT .* FOR UPDATE" 52 | destination_hostgroup=10 53 | apply=1 54 | }, 55 | { 56 | rule_id=200 57 | active=1 58 | match_pattern="^SELECT .*" 59 | destination_hostgroup=20 60 | apply=1 61 | }, 62 | { 63 | rule_id=300 64 | active=1 65 | match_pattern=".*" 66 | destination_hostgroup=10 67 | apply=1 68 | } 69 | ) 70 | 71 | mysql_users = 72 | ( 73 | { username = "admin" , password = "admin" , default_hostgroup = 10 , active = 1 } 74 | ) -------------------------------------------------------------------------------- /customer-api/src/main/java/com/ivanfranchin/customerapi/customer/model/Customer.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.customerapi.customer.model; 2 | 3 | import com.ivanfranchin.customerapi.customer.dto.CreateCustomerRequest; 4 | import com.ivanfranchin.customerapi.customer.dto.UpdateCustomerRequest; 5 | import jakarta.persistence.Column; 6 | import jakarta.persistence.Entity; 7 | import jakarta.persistence.GeneratedValue; 8 | import jakarta.persistence.GenerationType; 9 | import jakarta.persistence.Id; 10 | import jakarta.persistence.PrePersist; 11 | import jakarta.persistence.PreUpdate; 12 | import jakarta.persistence.Table; 13 | import lombok.Data; 14 | 15 | import java.time.Instant; 16 | 17 | @Data 18 | @Entity 19 | @Table(name = "customers") 20 | public class Customer { 21 | 22 | @Id 23 | @GeneratedValue(strategy = GenerationType.IDENTITY) 24 | private Long id; 25 | 26 | @Column(nullable = false) 27 | private String firstName; 28 | 29 | @Column(nullable = false) 30 | private String lastName; 31 | 32 | @Column(nullable = false) 33 | private Instant createdAt; 34 | 35 | @Column(nullable = false) 36 | private Instant updatedAt; 37 | 38 | @PrePersist 39 | public void onPrePersist() { 40 | createdAt = updatedAt = Instant.now(); 41 | } 42 | 43 | @PreUpdate 44 | public void onPreUpdate() { 45 | updatedAt = Instant.now(); 46 | } 47 | 48 | public static Customer from(CreateCustomerRequest createCustomerRequest) { 49 | Customer customer = new Customer(); 50 | customer.setFirstName(createCustomerRequest.firstName()); 51 | customer.setLastName(createCustomerRequest.lastName()); 52 | return customer; 53 | } 54 | 55 | public static void updateFrom(UpdateCustomerRequest updateCustomerRequest, Customer customer) { 56 | if (updateCustomerRequest.firstName() != null) { 57 | customer.setFirstName(updateCustomerRequest.firstName()); 58 | } 59 | if (updateCustomerRequest.lastName() != null) { 60 | customer.setLastName(updateCustomerRequest.lastName()); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /customer-api/src/main/java/com/ivanfranchin/customerapi/customer/CustomerController.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.customerapi.customer; 2 | 3 | import com.ivanfranchin.customerapi.customer.model.Customer; 4 | import com.ivanfranchin.customerapi.customer.dto.CreateCustomerRequest; 5 | import com.ivanfranchin.customerapi.customer.dto.CustomerResponse; 6 | import com.ivanfranchin.customerapi.customer.dto.UpdateCustomerRequest; 7 | import jakarta.validation.Valid; 8 | import lombok.RequiredArgsConstructor; 9 | import org.springframework.http.HttpStatus; 10 | import org.springframework.web.bind.annotation.DeleteMapping; 11 | import org.springframework.web.bind.annotation.GetMapping; 12 | import org.springframework.web.bind.annotation.PathVariable; 13 | import org.springframework.web.bind.annotation.PostMapping; 14 | import org.springframework.web.bind.annotation.PutMapping; 15 | import org.springframework.web.bind.annotation.RequestBody; 16 | import org.springframework.web.bind.annotation.RequestMapping; 17 | import org.springframework.web.bind.annotation.ResponseStatus; 18 | import org.springframework.web.bind.annotation.RestController; 19 | 20 | import java.util.List; 21 | import java.util.stream.Collectors; 22 | 23 | @RequiredArgsConstructor 24 | @RestController 25 | @RequestMapping("/api/customers") 26 | public class CustomerController { 27 | 28 | private final CustomerService customerService; 29 | 30 | @GetMapping 31 | public List getAllCustomers() { 32 | return customerService.getAllCustomers() 33 | .stream() 34 | .map(CustomerResponse::from) 35 | .collect(Collectors.toList()); 36 | } 37 | 38 | @GetMapping("/{id}") 39 | public CustomerResponse getCustomerById(@PathVariable Long id) { 40 | Customer customer = customerService.validateAndGetCustomer(id); 41 | return CustomerResponse.from(customer); 42 | } 43 | 44 | @ResponseStatus(HttpStatus.CREATED) 45 | @PostMapping 46 | public CustomerResponse createCustomer(@Valid @RequestBody CreateCustomerRequest createCustomerRequest) { 47 | Customer customer = Customer.from(createCustomerRequest); 48 | customer = customerService.saveCustomer(customer); 49 | return CustomerResponse.from(customer); 50 | } 51 | 52 | @PutMapping("/{id}") 53 | public CustomerResponse updateCustomer(@PathVariable Long id, @Valid @RequestBody UpdateCustomerRequest updateCustomerRequest) { 54 | Customer customer = customerService.validateAndGetCustomer(id); 55 | Customer.updateFrom(updateCustomerRequest, customer); 56 | customer = customerService.saveCustomer(customer); 57 | return CustomerResponse.from(customer); 58 | } 59 | 60 | @DeleteMapping("/{id}") 61 | public CustomerResponse deleteCustomer(@PathVariable Long id) { 62 | Customer customer = customerService.validateAndGetCustomer(id); 63 | customerService.deleteCustomer(customer); 64 | return CustomerResponse.from(customer); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /customer-api/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | com.ivanfranchin 7 | springboot-proxysql-mysql 8 | 0.0.1-SNAPSHOT 9 | ../pom.xml 10 | 11 | customer-api 12 | customer-api 13 | Demo project for Spring Boot 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | org.springframework.boot 30 | spring-boot-starter-data-jpa 31 | 32 | 33 | org.springframework.boot 34 | spring-boot-starter-validation 35 | 36 | 37 | org.springframework.boot 38 | spring-boot-starter-web 39 | 40 | 41 | 42 | com.mysql 43 | mysql-connector-j 44 | runtime 45 | 46 | 47 | org.projectlombok 48 | lombok 49 | true 50 | 51 | 52 | org.springframework.boot 53 | spring-boot-starter-test 54 | test 55 | 56 | 57 | 58 | 59 | 60 | 61 | org.apache.maven.plugins 62 | maven-compiler-plugin 63 | 64 | 65 | 66 | org.projectlombok 67 | lombok 68 | 69 | 70 | 71 | 72 | 73 | org.springframework.boot 74 | spring-boot-maven-plugin 75 | 76 | 77 | 78 | org.projectlombok 79 | lombok 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /init-environment.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | source scripts/my-functions.sh 4 | 5 | MYSQL_VERSION="5.7.44" 6 | PROXYSQL_VERSION="3.0.1" 7 | 8 | echo 9 | echo "Starting environment" 10 | echo "====================" 11 | 12 | echo 13 | echo "Creating network" 14 | echo "----------------" 15 | docker network create springboot-proxysql-mysql 16 | 17 | echo 18 | echo "Starting mysql-master container" 19 | echo "-------------------------------" 20 | docker run -d \ 21 | --name mysql-master \ 22 | --network=springboot-proxysql-mysql \ 23 | --restart=unless-stopped \ 24 | --env "MYSQL_ROOT_PASSWORD=secret" \ 25 | --env "MYSQL_DATABASE=customerdb" \ 26 | --env "MYSQL_USER=admin" \ 27 | --env "MYSQL_PASSWORD=admin" \ 28 | --publish 3306:3306 \ 29 | --health-cmd='mysqladmin ping -u root -p$${MYSQL_ROOT_PASSWORD}' \ 30 | mysql:${MYSQL_VERSION} \ 31 | --server-id=1 \ 32 | --log-bin='mysql-bin-1.log' \ 33 | --relay_log_info_repository=TABLE \ 34 | --master-info-repository=TABLE \ 35 | --gtid-mode=ON \ 36 | --log-slave-updates=ON \ 37 | --enforce-gtid-consistency 38 | 39 | echo 40 | echo "Starting mysql-slave-1 container" 41 | echo "--------------------------------" 42 | docker run -d \ 43 | --name mysql-slave-1 \ 44 | --network=springboot-proxysql-mysql \ 45 | --restart=unless-stopped \ 46 | --env "MYSQL_ROOT_PASSWORD=secret" \ 47 | --publish 3307:3306 \ 48 | --health-cmd='mysqladmin ping -u root -p$${MYSQL_ROOT_PASSWORD}' \ 49 | mysql:${MYSQL_VERSION} \ 50 | --server-id=2 \ 51 | --enforce-gtid-consistency=ON \ 52 | --log-slave-updates=ON \ 53 | --read_only=TRUE \ 54 | --skip-log-bin \ 55 | --skip-log-slave-updates \ 56 | --gtid-mode=ON 57 | 58 | echo 59 | echo "Starting mysql-slave-2 container" 60 | echo "--------------------------------" 61 | docker run -d \ 62 | --name mysql-slave-2 \ 63 | --network=springboot-proxysql-mysql \ 64 | --restart=unless-stopped \ 65 | --env "MYSQL_ROOT_PASSWORD=secret" \ 66 | --publish 3308:3306 \ 67 | --health-cmd='mysqladmin ping -u root -p$${MYSQL_ROOT_PASSWORD}' \ 68 | mysql:${MYSQL_VERSION} \ 69 | --server-id=3 \ 70 | --enforce-gtid-consistency=ON \ 71 | --log-slave-updates=ON \ 72 | --read_only=TRUE \ 73 | --skip-log-bin \ 74 | --skip-log-slave-updates \ 75 | --gtid-mode=ON 76 | 77 | echo 78 | wait_for_container_log "mysql-master" "port: 3306" 79 | wait_for_container_log "mysql-slave-1" "port: 3306" 80 | wait_for_container_log "mysql-slave-2" "port: 3306" 81 | 82 | echo 83 | echo "Setting MySQL Replication" 84 | echo "-------------------------" 85 | docker exec -i -e MYSQL_PWD=secret mysql-master mysql -uroot < mysql/master-replication.sql 86 | docker exec -i -e MYSQL_PWD=secret mysql-slave-1 mysql -uroot < mysql/slave-replication.sql 87 | docker exec -i -e MYSQL_PWD=secret mysql-slave-2 mysql -uroot < mysql/slave-replication.sql 88 | 89 | echo 90 | echo "Checking MySQL Replication" 91 | echo "--------------------------" 92 | ./check-replication-status.sh 93 | 94 | echo 95 | echo "Creating ProxySQL monitor user" 96 | echo "------------------------------" 97 | docker exec -i -e MYSQL_PWD=secret mysql-master mysql -uroot --ssl-mode=DISABLED < mysql/master-proxysql-monitor-user.sql 98 | 99 | echo 100 | echo "Waiting 5 seconds before starting proxysql container ..." 101 | sleep 5 102 | 103 | echo 104 | echo "Starting proxysql container" 105 | echo "---------------------------" 106 | docker run -d \ 107 | --name proxysql \ 108 | --network=springboot-proxysql-mysql \ 109 | --restart=unless-stopped \ 110 | --publish 6032:6032 \ 111 | --publish 6033:6033 \ 112 | --volume $PWD/proxysql/proxysql.cnf:/etc/proxysql.cnf \ 113 | proxysql/proxysql:${PROXYSQL_VERSION} 114 | 115 | echo 116 | echo "Waiting 5 seconds before checking mysql servers" 117 | sleep 5 118 | 119 | echo 120 | echo "Checking mysql servers" 121 | echo "----------------------" 122 | docker exec -i -e MYSQL_PWD=radmin mysql-master bash -c 'mysql -hproxysql -P6032 -uradmin --prompt "ProxySQL Admin> " <<< "select * from mysql_servers;"' 123 | 124 | echo 125 | echo "Environment Up and Running" 126 | echo "==========================" 127 | echo -------------------------------------------------------------------------------- /mvnw.cmd: -------------------------------------------------------------------------------- 1 | <# : batch portion 2 | @REM ---------------------------------------------------------------------------- 3 | @REM Licensed to the Apache Software Foundation (ASF) under one 4 | @REM or more contributor license agreements. See the NOTICE file 5 | @REM distributed with this work for additional information 6 | @REM regarding copyright ownership. The ASF licenses this file 7 | @REM to you under the Apache License, Version 2.0 (the 8 | @REM "License"); you may not use this file except in compliance 9 | @REM with the License. You may obtain a copy of the License at 10 | @REM 11 | @REM http://www.apache.org/licenses/LICENSE-2.0 12 | @REM 13 | @REM Unless required by applicable law or agreed to in writing, 14 | @REM software distributed under the License is distributed on an 15 | @REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | @REM KIND, either express or implied. See the License for the 17 | @REM specific language governing permissions and limitations 18 | @REM under the License. 19 | @REM ---------------------------------------------------------------------------- 20 | 21 | @REM ---------------------------------------------------------------------------- 22 | @REM Apache Maven Wrapper startup batch script, version 3.3.2 23 | @REM 24 | @REM Optional ENV vars 25 | @REM MVNW_REPOURL - repo url base for downloading maven distribution 26 | @REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven 27 | @REM MVNW_VERBOSE - true: enable verbose log; others: silence the output 28 | @REM ---------------------------------------------------------------------------- 29 | 30 | @IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) 31 | @SET __MVNW_CMD__= 32 | @SET __MVNW_ERROR__= 33 | @SET __MVNW_PSMODULEP_SAVE=%PSModulePath% 34 | @SET PSModulePath= 35 | @FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( 36 | IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) 37 | ) 38 | @SET PSModulePath=%__MVNW_PSMODULEP_SAVE% 39 | @SET __MVNW_PSMODULEP_SAVE= 40 | @SET __MVNW_ARG0_NAME__= 41 | @SET MVNW_USERNAME= 42 | @SET MVNW_PASSWORD= 43 | @IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) 44 | @echo Cannot start maven from wrapper >&2 && exit /b 1 45 | @GOTO :EOF 46 | : end batch / begin powershell #> 47 | 48 | $ErrorActionPreference = "Stop" 49 | if ($env:MVNW_VERBOSE -eq "true") { 50 | $VerbosePreference = "Continue" 51 | } 52 | 53 | # calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties 54 | $distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl 55 | if (!$distributionUrl) { 56 | Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" 57 | } 58 | 59 | switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { 60 | "maven-mvnd-*" { 61 | $USE_MVND = $true 62 | $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" 63 | $MVN_CMD = "mvnd.cmd" 64 | break 65 | } 66 | default { 67 | $USE_MVND = $false 68 | $MVN_CMD = $script -replace '^mvnw','mvn' 69 | break 70 | } 71 | } 72 | 73 | # apply MVNW_REPOURL and calculate MAVEN_HOME 74 | # maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ 75 | if ($env:MVNW_REPOURL) { 76 | $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" } 77 | $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')" 78 | } 79 | $distributionUrlName = $distributionUrl -replace '^.*/','' 80 | $distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' 81 | $MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" 82 | if ($env:MAVEN_USER_HOME) { 83 | $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" 84 | } 85 | $MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' 86 | $MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" 87 | 88 | if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { 89 | Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" 90 | Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" 91 | exit $? 92 | } 93 | 94 | if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { 95 | Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" 96 | } 97 | 98 | # prepare tmp dir 99 | $TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile 100 | $TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" 101 | $TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null 102 | trap { 103 | if ($TMP_DOWNLOAD_DIR.Exists) { 104 | try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } 105 | catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } 106 | } 107 | } 108 | 109 | New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null 110 | 111 | # Download and Install Apache Maven 112 | Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." 113 | Write-Verbose "Downloading from: $distributionUrl" 114 | Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" 115 | 116 | $webclient = New-Object System.Net.WebClient 117 | if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { 118 | $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) 119 | } 120 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 121 | $webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null 122 | 123 | # If specified, validate the SHA-256 sum of the Maven distribution zip file 124 | $distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum 125 | if ($distributionSha256Sum) { 126 | if ($USE_MVND) { 127 | Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." 128 | } 129 | Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash 130 | if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { 131 | Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." 132 | } 133 | } 134 | 135 | # unzip and move 136 | Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null 137 | Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null 138 | try { 139 | Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null 140 | } catch { 141 | if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { 142 | Write-Error "fail to move MAVEN_HOME" 143 | } 144 | } finally { 145 | try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } 146 | catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } 147 | } 148 | 149 | Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" 150 | -------------------------------------------------------------------------------- /mvnw: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # ---------------------------------------------------------------------------- 3 | # Licensed to the Apache Software Foundation (ASF) under one 4 | # or more contributor license agreements. See the NOTICE file 5 | # distributed with this work for additional information 6 | # regarding copyright ownership. The ASF licenses this file 7 | # to you under the Apache License, Version 2.0 (the 8 | # "License"); you may not use this file except in compliance 9 | # with the License. You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, 14 | # software distributed under the License is distributed on an 15 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | # KIND, either express or implied. See the License for the 17 | # specific language governing permissions and limitations 18 | # under the License. 19 | # ---------------------------------------------------------------------------- 20 | 21 | # ---------------------------------------------------------------------------- 22 | # Apache Maven Wrapper startup batch script, version 3.3.2 23 | # 24 | # Optional ENV vars 25 | # ----------------- 26 | # JAVA_HOME - location of a JDK home dir, required when download maven via java source 27 | # MVNW_REPOURL - repo url base for downloading maven distribution 28 | # MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven 29 | # MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output 30 | # ---------------------------------------------------------------------------- 31 | 32 | set -euf 33 | [ "${MVNW_VERBOSE-}" != debug ] || set -x 34 | 35 | # OS specific support. 36 | native_path() { printf %s\\n "$1"; } 37 | case "$(uname)" in 38 | CYGWIN* | MINGW*) 39 | [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" 40 | native_path() { cygpath --path --windows "$1"; } 41 | ;; 42 | esac 43 | 44 | # set JAVACMD and JAVACCMD 45 | set_java_home() { 46 | # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched 47 | if [ -n "${JAVA_HOME-}" ]; then 48 | if [ -x "$JAVA_HOME/jre/sh/java" ]; then 49 | # IBM's JDK on AIX uses strange locations for the executables 50 | JAVACMD="$JAVA_HOME/jre/sh/java" 51 | JAVACCMD="$JAVA_HOME/jre/sh/javac" 52 | else 53 | JAVACMD="$JAVA_HOME/bin/java" 54 | JAVACCMD="$JAVA_HOME/bin/javac" 55 | 56 | if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then 57 | echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 58 | echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 59 | return 1 60 | fi 61 | fi 62 | else 63 | JAVACMD="$( 64 | 'set' +e 65 | 'unset' -f command 2>/dev/null 66 | 'command' -v java 67 | )" || : 68 | JAVACCMD="$( 69 | 'set' +e 70 | 'unset' -f command 2>/dev/null 71 | 'command' -v javac 72 | )" || : 73 | 74 | if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then 75 | echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 76 | return 1 77 | fi 78 | fi 79 | } 80 | 81 | # hash string like Java String::hashCode 82 | hash_string() { 83 | str="${1:-}" h=0 84 | while [ -n "$str" ]; do 85 | char="${str%"${str#?}"}" 86 | h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) 87 | str="${str#?}" 88 | done 89 | printf %x\\n $h 90 | } 91 | 92 | verbose() { :; } 93 | [ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } 94 | 95 | die() { 96 | printf %s\\n "$1" >&2 97 | exit 1 98 | } 99 | 100 | trim() { 101 | # MWRAPPER-139: 102 | # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. 103 | # Needed for removing poorly interpreted newline sequences when running in more 104 | # exotic environments such as mingw bash on Windows. 105 | printf "%s" "${1}" | tr -d '[:space:]' 106 | } 107 | 108 | # parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties 109 | while IFS="=" read -r key value; do 110 | case "${key-}" in 111 | distributionUrl) distributionUrl=$(trim "${value-}") ;; 112 | distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; 113 | esac 114 | done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" 115 | [ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties" 116 | 117 | case "${distributionUrl##*/}" in 118 | maven-mvnd-*bin.*) 119 | MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ 120 | case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in 121 | *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; 122 | :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; 123 | :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; 124 | :Linux*x86_64*) distributionPlatform=linux-amd64 ;; 125 | *) 126 | echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 127 | distributionPlatform=linux-amd64 128 | ;; 129 | esac 130 | distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" 131 | ;; 132 | maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; 133 | *) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; 134 | esac 135 | 136 | # apply MVNW_REPOURL and calculate MAVEN_HOME 137 | # maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ 138 | [ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" 139 | distributionUrlName="${distributionUrl##*/}" 140 | distributionUrlNameMain="${distributionUrlName%.*}" 141 | distributionUrlNameMain="${distributionUrlNameMain%-bin}" 142 | MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" 143 | MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" 144 | 145 | exec_maven() { 146 | unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : 147 | exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" 148 | } 149 | 150 | if [ -d "$MAVEN_HOME" ]; then 151 | verbose "found existing MAVEN_HOME at $MAVEN_HOME" 152 | exec_maven "$@" 153 | fi 154 | 155 | case "${distributionUrl-}" in 156 | *?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; 157 | *) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; 158 | esac 159 | 160 | # prepare tmp dir 161 | if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then 162 | clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } 163 | trap clean HUP INT TERM EXIT 164 | else 165 | die "cannot create temp dir" 166 | fi 167 | 168 | mkdir -p -- "${MAVEN_HOME%/*}" 169 | 170 | # Download and Install Apache Maven 171 | verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." 172 | verbose "Downloading from: $distributionUrl" 173 | verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" 174 | 175 | # select .zip or .tar.gz 176 | if ! command -v unzip >/dev/null; then 177 | distributionUrl="${distributionUrl%.zip}.tar.gz" 178 | distributionUrlName="${distributionUrl##*/}" 179 | fi 180 | 181 | # verbose opt 182 | __MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' 183 | [ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v 184 | 185 | # normalize http auth 186 | case "${MVNW_PASSWORD:+has-password}" in 187 | '') MVNW_USERNAME='' MVNW_PASSWORD='' ;; 188 | has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; 189 | esac 190 | 191 | if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then 192 | verbose "Found wget ... using wget" 193 | wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" 194 | elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then 195 | verbose "Found curl ... using curl" 196 | curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" 197 | elif set_java_home; then 198 | verbose "Falling back to use Java to download" 199 | javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" 200 | targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" 201 | cat >"$javaSource" <<-END 202 | public class Downloader extends java.net.Authenticator 203 | { 204 | protected java.net.PasswordAuthentication getPasswordAuthentication() 205 | { 206 | return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); 207 | } 208 | public static void main( String[] args ) throws Exception 209 | { 210 | setDefault( new Downloader() ); 211 | java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); 212 | } 213 | } 214 | END 215 | # For Cygwin/MinGW, switch paths to Windows format before running javac and java 216 | verbose " - Compiling Downloader.java ..." 217 | "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" 218 | verbose " - Running Downloader.java ..." 219 | "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" 220 | fi 221 | 222 | # If specified, validate the SHA-256 sum of the Maven distribution zip file 223 | if [ -n "${distributionSha256Sum-}" ]; then 224 | distributionSha256Result=false 225 | if [ "$MVN_CMD" = mvnd.sh ]; then 226 | echo "Checksum validation is not supported for maven-mvnd." >&2 227 | echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 228 | exit 1 229 | elif command -v sha256sum >/dev/null; then 230 | if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then 231 | distributionSha256Result=true 232 | fi 233 | elif command -v shasum >/dev/null; then 234 | if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then 235 | distributionSha256Result=true 236 | fi 237 | else 238 | echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 239 | echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 240 | exit 1 241 | fi 242 | if [ $distributionSha256Result = false ]; then 243 | echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 244 | echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 245 | exit 1 246 | fi 247 | fi 248 | 249 | # unzip and move 250 | if command -v unzip >/dev/null; then 251 | unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" 252 | else 253 | tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" 254 | fi 255 | printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" 256 | mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" 257 | 258 | clean || : 259 | exec_maven "$@" 260 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # springboot-proxysql-mysql 2 | 3 | The goal of this project is to use [`ProxySQL`](https://proxysql.com/) to load balance requests from a [`Spring Boot`](https://docs.spring.io/spring-boot/index.html) application to [`MySQL`](https://www.mysql.com/) Replication Master-Slave Cluster. 4 | 5 | ## Proof-of-Concepts & Articles 6 | 7 | On [ivangfr.github.io](https://ivangfr.github.io), I have compiled my Proof-of-Concepts (PoCs) and articles. You can easily search for the technology you are interested in by using the filter. Who knows, perhaps I have already implemented a PoC or written an article about what you are looking for. 8 | 9 | ## Additional Readings 10 | 11 | - \[**Medium**\] [**Optimizing Spring Boot’s Connection to MySQL Master-Slave Clusters with ProxySQL**](https://medium.com/@ivangfr/optimizing-spring-boots-connection-to-mysql-master-slave-clusters-with-proxysql-af275a0a4cea) 12 | 13 | ## Project Architecture 14 | 15 | ![project-diagram](documentation/project-diagram.jpeg) 16 | 17 | ## Applications 18 | 19 | - ### MySQL 20 | 21 | [`MySQL`](https://www.mysql.com/) is the most popular Open Source SQL database management system, supported by `Oracle`. In this project, we set a **MySQL Replication Master-Slave Cluster** that contains three `MySQL` instances: one master and two slaves. In the replication process, data is automatically copied from the master to the slaves. 22 | 23 | - ### ProxySQL 24 | 25 | [`ProxySQL`](https://proxysql.com/) is an open-source, high-performance `MySQL` proxy server. It sits between application and database servers, accepting incoming traffic from `MySQL` clients and forwarding it to backend `MySQL` servers. In this project, we set two `hostgroups`: `writer=10` and `reader=20`. Those hostgroups say to which database servers write or read requests should go. The `MySQL` master belongs to the `writer` hostgroup. On the other hand, the slaves belong to the `reader` hostgroup. 26 | 27 | - ### customer-api 28 | 29 | `Spring Boot` Web Java application that exposes a REST API for managing customers. Instead of connecting directly to `MySQL`, as usual, the application will be connected to `ProxySQL`. 30 | 31 | `customer-api` has the following endpoints: 32 | ```text 33 | GET /api/customers 34 | GET /api/customers/{id} 35 | POST /api/customers {"firstName":"...", "lastName":"..."} 36 | PUT /api/customers/{id} {"firstName":"...", "lastName":"..."} 37 | DELETE /api/customers/{id} 38 | ``` 39 | 40 | ## Prerequisites 41 | 42 | - [`Java 21`](https://www.oracle.com/java/technologies/downloads/#java21) or higher. 43 | - A containerization tool (e.g., [`Docker`](https://www.docker.com), [`Podman`](https://podman.io), etc.) 44 | 45 | ## Start Environment 46 | 47 | - Open a terminal and, inside the `springboot-proxysql-mysql` root folder, run the following script: 48 | ```bash 49 | ./init-environment.sh 50 | ``` 51 | 52 | - Wait until the environment is up and running 53 | 54 | ## Check MySQL Replication 55 | 56 | - In a terminal, make sure you are inside the `springboot-proxysql-mysql` root folder; 57 | 58 | - To check the replication status run: 59 | ```bash 60 | ./check-replication-status.sh 61 | ``` 62 | 63 | You should see something like: 64 | ```text 65 | mysql-master 66 | ------------ 67 | File Position Binlog_Do_DB Binlog_Ignore_DB Executed_Gtid_Set 68 | mysql-bin-1.000003 1397 62a2f52f-b16b-11ed-91fc-0242c0a85002:1-14 69 | 70 | mysql-slave-1 71 | ------------- 72 | *************************** 1. row *************************** 73 | Slave_IO_State: Waiting for master to send event 74 | Master_Host: mysql-master 75 | Master_User: replication 76 | Master_Port: 3306 77 | Connect_Retry: 60 78 | Master_Log_File: mysql-bin-1.000003 79 | Read_Master_Log_Pos: 1397 80 | Relay_Log_File: fa249eba35d6-relay-bin.000003 81 | Relay_Log_Pos: 1614 82 | Relay_Master_Log_File: mysql-bin-1.000003 83 | Slave_IO_Running: Yes 84 | Slave_SQL_Running: Yes 85 | ... 86 | 87 | mysql-slave-2 88 | ------------- 89 | *************************** 1. row *************************** 90 | Slave_IO_State: Waiting for master to send event 91 | Master_Host: mysql-master 92 | Master_User: replication 93 | Master_Port: 3306 94 | Connect_Retry: 60 95 | Master_Log_File: mysql-bin-1.000003 96 | Read_Master_Log_Pos: 1397 97 | Relay_Log_File: cbfd1f4bb01a-relay-bin.000003 98 | Relay_Log_Pos: 1614 99 | Relay_Master_Log_File: mysql-bin-1.000003 100 | Slave_IO_Running: Yes 101 | Slave_SQL_Running: Yes 102 | ... 103 | ``` 104 | 105 | ## Check ProxySQL configuration 106 | 107 | - In a terminal and inside the `springboot-proxysql-mysql` root folder, run the script below to connect to `ProxySQL` command line terminal: 108 | ```bash 109 | ./proxysql-admin.sh 110 | ``` 111 | 112 | - In `ProxySQL Admin> ` terminal run the following command to see the `MySQL` servers: 113 | ```bash 114 | SELECT * FROM mysql_servers; 115 | ``` 116 | 117 | - The following select shows the global variables: 118 | ```bash 119 | SELECT * FROM global_variables; 120 | ``` 121 | 122 | - In order to exit `ProxySQL` command line terminal, type `exit`. 123 | 124 | ## Start customer-api 125 | 126 | - In a terminal and navigate to the `springboot-proxysql-mysql` root folder; 127 | 128 | - Run the following Maven command to start the application: 129 | ```bash 130 | ./mvnw clean spring-boot:run --projects customer-api 131 | ``` 132 | 133 | ## Simulation 134 | 135 | 1. Open three terminals: one for `mysql-master`, one for `mysql-slave-1` and another for `mysql-slave-2`; 136 | 137 | 2. In `mysql-master` terminal, connect to `MySQL Monitor` by running: 138 | ```bash 139 | docker exec -it -e MYSQL_PWD=secret mysql-master mysql -uroot --database customerdb 140 | ``` 141 | 142 | 3. Do the same for `mysql-slave-1`... 143 | ```bash 144 | docker exec -it -e MYSQL_PWD=secret mysql-slave-1 mysql -uroot --database customerdb 145 | ``` 146 | 147 | 4. ... and `mysql-slave-2` 148 | ```bash 149 | docker exec -it -e MYSQL_PWD=secret mysql-slave-2 mysql -uroot --database customerdb 150 | ``` 151 | 152 | 5. Inside each `MySQL Monitor's` terminal, run the following commands to enable `MySQL` logs: 153 | ```bash 154 | SET GLOBAL general_log = 'ON'; 155 | SET global log_output = 'table'; 156 | ``` 157 | 158 | 6. Open a new terminal. In it, we will just run `curl` commands; 159 | 160 | 7. In the `curl` terminal, let's create a customer: 161 | ```bash 162 | curl -i -X POST http://localhost:8080/api/customers \ 163 | -H 'Content-Type: application/json' \ 164 | -d '{"firstName": "Ivan", "lastName": "Franchin"}' 165 | ``` 166 | 167 | 8. Go to `mysql-master` terminal and run the following `SELECT` command: 168 | ```bash 169 | SELECT event_time, command_type, SUBSTRING(argument,1,250) argument FROM mysql.general_log 170 | WHERE command_type = 'Query' AND (argument LIKE 'insert into customers %' OR argument LIKE 'select c1_0.id%' OR argument LIKE 'update customers %' OR argument LIKE 'delete from customers %'); 171 | ``` 172 | 173 | It should return: 174 | ```text 175 | +----------------------------+--------------+-------------------------------------------------------------------------------------------------------------------------------------------------+ 176 | | event_time | command_type | argument | 177 | +----------------------------+--------------+-------------------------------------------------------------------------------------------------------------------------------------------------+ 178 | | 2023-02-20 22:13:15.400178 | Query | insert into customers (created_at, first_name, last_name, updated_at) values ('2023-02-20 22:13:15', 'Ivan', 'Franchin', '2023-02-20 22:13:15') | 179 | +----------------------------+--------------+-------------------------------------------------------------------------------------------------------------------------------------------------+ 180 | ``` 181 | 182 | > **Note**: If you run the same `SELECT` in the slave's terminal, you will see that just the `mysql-master` processed the `insert` command. By the way, all inserts, updates, and deletes are executed on `mysql-master`. 183 | 184 | 9. Now, let's call to the `GET` endpoint to retrieve `customer 1`. For it, go to `curl` terminal and run: 185 | ```bash 186 | curl -i http://localhost:8080/api/customers/1 187 | ``` 188 | 189 | 10. If you run, in one of the slave's terminal, the `SELECT` command below: 190 | ```bash 191 | SELECT event_time, command_type, SUBSTRING(argument,1,250) argument FROM mysql.general_log 192 | WHERE command_type = 'Query' AND (argument LIKE 'insert into customers %' OR argument LIKE 'select c1_0.id%' OR argument LIKE 'update customers %' OR argument LIKE 'delete from customers %'); 193 | ``` 194 | 195 | It should return: 196 | ```text 197 | +----------------------------+--------------+-------------------------------------------------------------------------------------------------------------------+ 198 | | event_time | command_type | argument | 199 | +----------------------------+--------------+-------------------------------------------------------------------------------------------------------------------+ 200 | | 2023-02-20 22:14:06.582449 | Query | select c1_0.id,c1_0.created_at,c1_0.first_name,c1_0.last_name,c1_0.updated_at from customers c1_0 where c1_0.id=1 | 201 | +----------------------------+--------------+-------------------------------------------------------------------------------------------------------------------+ 202 | ``` 203 | > **Note**: Just one slave should process it. 204 | 205 | 11. Next, let's `UPDATE` the `customer 1`. For it, go to the `curl` terminal and run: 206 | ```bash 207 | curl -i -X PUT http://localhost:8080/api/customers/1 \ 208 | -H 'Content-Type: application/json' \ 209 | -d '{"firstName": "Ivan2", "lastName": "Franchin2"}' 210 | ``` 211 | 212 | 12. Running the following `SELECT` inside the `mysql-master` terminal: 213 | ```bash 214 | SELECT event_time, command_type, SUBSTRING(argument,1,250) argument FROM mysql.general_log 215 | WHERE command_type = 'Query' AND (argument LIKE 'insert into customers %' OR argument LIKE 'select c1_0.id%' OR argument LIKE 'update customers %' OR argument LIKE 'delete from customers %'); 216 | ``` 217 | 218 | It should return: 219 | ```text 220 | +----------------------------+--------------+-------------------------------------------------------------------------------------------------------------------------------------------------+ 221 | | event_time | command_type | argument | 222 | +----------------------------+--------------+-------------------------------------------------------------------------------------------------------------------------------------------------+ 223 | | 2023-02-20 22:13:15.400178 | Query | insert into customers (created_at, first_name, last_name, updated_at) values ('2023-02-20 22:13:15', 'Ivan', 'Franchin', '2023-02-20 22:13:15') | 224 | | 2023-02-20 22:14:33.019875 | Query | update customers set created_at='2023-02-20 22:13:15', first_name='Ivan2', last_name='Franchin2', updated_at='2023-02-20 22:14:33' where id=1 | 225 | +----------------------------+--------------+-------------------------------------------------------------------------------------------------------------------------------------------------+ 226 | ``` 227 | > **Note**: During an update, Hibernate/JPA does a select before performing the record update. So, you should see another select on one of the slaves. 228 | 229 | 13. Finally, let's `DELETE` the `customer 1`. For it, go to the `curl` terminal and run: 230 | ```bash 231 | curl -i -X DELETE http://localhost:8080/api/customers/1 232 | ``` 233 | 234 | 14. Running the following `SELECT` inside the `mysql-master` terminal: 235 | ```bash 236 | SELECT event_time, command_type, SUBSTRING(argument,1,250) argument FROM mysql.general_log 237 | WHERE command_type = 'Query' AND (argument LIKE 'insert into customers %' OR argument LIKE 'select c1_0.id%' OR argument LIKE 'update customers %' OR argument LIKE 'delete from customers %'); 238 | ``` 239 | 240 | It should return: 241 | ```text 242 | +----------------------------+--------------+-------------------------------------------------------------------------------------------------------------------------------------------------+ 243 | | event_time | command_type | argument | 244 | +----------------------------+--------------+-------------------------------------------------------------------------------------------------------------------------------------------------+ 245 | | 2023-02-20 22:13:15.400178 | Query | insert into customers (created_at, first_name, last_name, updated_at) values ('2023-02-20 22:13:15', 'Ivan', 'Franchin', '2023-02-20 22:13:15') | 246 | | 2023-02-20 22:14:33.019875 | Query | update customers set created_at='2023-02-20 22:13:15', first_name='Ivan2', last_name='Franchin2', updated_at='2023-02-20 22:14:33' where id=1 | 247 | | 2023-02-20 22:14:52.358207 | Query | delete from customers where id=1 | 248 | +----------------------------+--------------+-------------------------------------------------------------------------------------------------------------------------------------------------+ 249 | ``` 250 | > **Note**: As with an update, during a deletion, Hibernate/JPA performs a select before deleting the record. So, you should see another select in one of the slaves. 251 | 252 | ## Shutdown 253 | 254 | - To stop the `customer-api` application, go to the terminal where it's running and press `Ctrl+C`; 255 | - In order to get out of the `MySQL Monitors` type `exit`; 256 | - To stop and remove `MySQL`s and `ProxySQL` containers, network and volumes, make sure you are inside the `springboot-proxysql-mysql` root folder and run the following script: 257 | ```bash 258 | ./shutdown-environment.sh 259 | ``` 260 | -------------------------------------------------------------------------------- /documentation/project-diagram.excalidraw: -------------------------------------------------------------------------------- 1 | { 2 | "type": "excalidraw", 3 | "version": 2, 4 | "source": "https://excalidraw.com", 5 | "elements": [ 6 | { 7 | "type": "rectangle", 8 | "version": 1937, 9 | "versionNonce": 1694069704, 10 | "isDeleted": false, 11 | "id": "3nhq8C5rjOxS0OEYzBYan", 12 | "fillStyle": "hachure", 13 | "strokeWidth": 1, 14 | "strokeStyle": "solid", 15 | "roughness": 1, 16 | "opacity": 100, 17 | "angle": 0, 18 | "x": 435.58267066954545, 19 | "y": -187.08378809875, 20 | "strokeColor": "#000000", 21 | "backgroundColor": "#228be6", 22 | "width": 209.18356323242188, 23 | "height": 99.67071533203125, 24 | "seed": 1894382536, 25 | "groupIds": [], 26 | "roundness": { 27 | "type": 3 28 | }, 29 | "boundElements": [ 30 | { 31 | "type": "text", 32 | "id": "lOZj23caNLwLMw6i7eZrx" 33 | }, 34 | { 35 | "id": "7uyeTQhEJ8S59Tg4aNS_W", 36 | "type": "arrow" 37 | } 38 | ], 39 | "updated": 1679667049849, 40 | "link": null, 41 | "locked": false 42 | }, 43 | { 44 | "type": "text", 45 | "version": 887, 46 | "versionNonce": 408849592, 47 | "isDeleted": false, 48 | "id": "lOZj23caNLwLMw6i7eZrx", 49 | "fillStyle": "hachure", 50 | "strokeWidth": 1, 51 | "strokeStyle": "solid", 52 | "roughness": 1, 53 | "opacity": 100, 54 | "angle": 0, 55 | "x": 454.32649085997514, 56 | "y": -154.0484304327344, 57 | "strokeColor": "#000000", 58 | "backgroundColor": "transparent", 59 | "width": 171.6959228515625, 60 | "height": 33.6, 61 | "seed": 1738179768, 62 | "groupIds": [], 63 | "roundness": null, 64 | "boundElements": null, 65 | "updated": 1679667049849, 66 | "link": null, 67 | "locked": false, 68 | "fontSize": 28, 69 | "fontFamily": 1, 70 | "text": "customer-api", 71 | "textAlign": "center", 72 | "verticalAlign": "middle", 73 | "containerId": "3nhq8C5rjOxS0OEYzBYan", 74 | "originalText": "customer-api", 75 | "lineHeight": 1.2 76 | }, 77 | { 78 | "type": "rectangle", 79 | "version": 1952, 80 | "versionNonce": 1583173576, 81 | "isDeleted": false, 82 | "id": "AFjTg_6ur4FytvF9woV-p", 83 | "fillStyle": "hachure", 84 | "strokeWidth": 1, 85 | "strokeStyle": "solid", 86 | "roughness": 1, 87 | "opacity": 100, 88 | "angle": 0, 89 | "x": 764.2308703075296, 90 | "y": -187.08378809875, 91 | "strokeColor": "#000000", 92 | "backgroundColor": "#fab005", 93 | "width": 209.18356323242188, 94 | "height": 99.67071533203125, 95 | "seed": 965309384, 96 | "groupIds": [], 97 | "roundness": { 98 | "type": 3 99 | }, 100 | "boundElements": [ 101 | { 102 | "type": "text", 103 | "id": "Ax9HsHPUga_dhfZVbn57k" 104 | }, 105 | { 106 | "id": "7uyeTQhEJ8S59Tg4aNS_W", 107 | "type": "arrow" 108 | }, 109 | { 110 | "id": "ovP3ppf2AKcV8zwccc36n", 111 | "type": "arrow" 112 | }, 113 | { 114 | "id": "moQgjIRtnoq8RvGoGVVES", 115 | "type": "arrow" 116 | }, 117 | { 118 | "id": "rbLJdiNWzj5RzaJ_0hibq", 119 | "type": "arrow" 120 | } 121 | ], 122 | "updated": 1679667049849, 123 | "link": null, 124 | "locked": false 125 | }, 126 | { 127 | "type": "text", 128 | "version": 908, 129 | "versionNonce": 1018589368, 130 | "isDeleted": false, 131 | "id": "Ax9HsHPUga_dhfZVbn57k", 132 | "fillStyle": "hachure", 133 | "strokeWidth": 1, 134 | "strokeStyle": "solid", 135 | "roughness": 1, 136 | "opacity": 100, 137 | "angle": 0, 138 | "x": 814.7826815257913, 139 | "y": -154.0484304327344, 140 | "strokeColor": "#000000", 141 | "backgroundColor": "transparent", 142 | "width": 108.07994079589844, 143 | "height": 33.6, 144 | "seed": 349080760, 145 | "groupIds": [], 146 | "roundness": null, 147 | "boundElements": null, 148 | "updated": 1679667049849, 149 | "link": null, 150 | "locked": false, 151 | "fontSize": 28, 152 | "fontFamily": 1, 153 | "text": "proxysql", 154 | "textAlign": "center", 155 | "verticalAlign": "middle", 156 | "containerId": "AFjTg_6ur4FytvF9woV-p", 157 | "originalText": "proxysql", 158 | "lineHeight": 1.2 159 | }, 160 | { 161 | "id": "qtCQbqKfe9iVslwVHJG8v", 162 | "type": "rectangle", 163 | "x": 1097.1831097272484, 164 | "y": -365.20556738634355, 165 | "width": 463.63999379051575, 166 | "height": 439.4478781453456, 167 | "angle": 0, 168 | "strokeColor": "#000000", 169 | "backgroundColor": "#ced4da", 170 | "fillStyle": "hachure", 171 | "strokeWidth": 1, 172 | "strokeStyle": "solid", 173 | "roughness": 1, 174 | "opacity": 100, 175 | "groupIds": [], 176 | "roundness": { 177 | "type": 3 178 | }, 179 | "seed": 631351992, 180 | "version": 378, 181 | "versionNonce": 1689261768, 182 | "isDeleted": false, 183 | "boundElements": null, 184 | "updated": 1679667049849, 185 | "link": null, 186 | "locked": false 187 | }, 188 | { 189 | "type": "rectangle", 190 | "version": 2157, 191 | "versionNonce": 253204920, 192 | "isDeleted": false, 193 | "id": "-UrpQnFNOP5kMIeE_36XM", 194 | "fillStyle": "cross-hatch", 195 | "strokeWidth": 1, 196 | "strokeStyle": "solid", 197 | "roughness": 1, 198 | "opacity": 100, 199 | "angle": 0, 200 | "x": 1329.8410906042538, 201 | "y": -352.68580728010096, 202 | "strokeColor": "#000000", 203 | "backgroundColor": "#82c91e", 204 | "width": 209.18356323242188, 205 | "height": 99.67071533203125, 206 | "seed": 292023224, 207 | "groupIds": [ 208 | "BfB3WS8KoqWk-NF_78CkE" 209 | ], 210 | "roundness": { 211 | "type": 3 212 | }, 213 | "boundElements": [ 214 | { 215 | "type": "text", 216 | "id": "A0b-JISCYORrE98SC0vNc" 217 | }, 218 | { 219 | "id": "ovP3ppf2AKcV8zwccc36n", 220 | "type": "arrow" 221 | } 222 | ], 223 | "updated": 1679667049849, 224 | "link": null, 225 | "locked": false 226 | }, 227 | { 228 | "type": "text", 229 | "version": 1147, 230 | "versionNonce": 1125252552, 231 | "isDeleted": false, 232 | "id": "A0b-JISCYORrE98SC0vNc", 233 | "fillStyle": "hachure", 234 | "strokeWidth": 1, 235 | "strokeStyle": "solid", 236 | "roughness": 1, 237 | "opacity": 100, 238 | "angle": 0, 239 | "x": 1349.1729006018124, 240 | "y": -319.65044961408535, 241 | "strokeColor": "#000000", 242 | "backgroundColor": "transparent", 243 | "width": 170.5199432373047, 244 | "height": 33.6, 245 | "seed": 170380232, 246 | "groupIds": [ 247 | "BfB3WS8KoqWk-NF_78CkE" 248 | ], 249 | "roundness": null, 250 | "boundElements": null, 251 | "updated": 1679667049849, 252 | "link": null, 253 | "locked": false, 254 | "fontSize": 28, 255 | "fontFamily": 1, 256 | "text": "mysql-slave-1", 257 | "textAlign": "center", 258 | "verticalAlign": "middle", 259 | "containerId": "-UrpQnFNOP5kMIeE_36XM", 260 | "originalText": "mysql-slave-1", 261 | "lineHeight": 1.2 262 | }, 263 | { 264 | "id": "R_tBJ7vrFsQ5VhM7incZO", 265 | "type": "rectangle", 266 | "x": 1363.3520438585163, 267 | "y": -285.6500916777574, 268 | "width": 151, 269 | "height": 35, 270 | "angle": 0, 271 | "strokeColor": "#000000", 272 | "backgroundColor": "#ffffff", 273 | "fillStyle": "solid", 274 | "strokeWidth": 1, 275 | "strokeStyle": "solid", 276 | "roughness": 1, 277 | "opacity": 100, 278 | "groupIds": [ 279 | "BfB3WS8KoqWk-NF_78CkE" 280 | ], 281 | "roundness": { 282 | "type": 3 283 | }, 284 | "seed": 1467060664, 285 | "version": 325, 286 | "versionNonce": 762028728, 287 | "isDeleted": false, 288 | "boundElements": [ 289 | { 290 | "type": "text", 291 | "id": "OmSPQFxr_0F2HJ0h-hu34" 292 | }, 293 | { 294 | "id": "vMby8tIOEsbjLY6UYmF_2", 295 | "type": "arrow" 296 | } 297 | ], 298 | "updated": 1679667049849, 299 | "link": null, 300 | "locked": false 301 | }, 302 | { 303 | "id": "OmSPQFxr_0F2HJ0h-hu34", 304 | "type": "text", 305 | "x": 1372.832108250606, 306 | "y": -280.6500916777574, 307 | "width": 132.0398712158203, 308 | "height": 25, 309 | "angle": 0, 310 | "strokeColor": "#000000", 311 | "backgroundColor": "#ffffff", 312 | "fillStyle": "hachure", 313 | "strokeWidth": 1, 314 | "strokeStyle": "solid", 315 | "roughness": 1, 316 | "opacity": 100, 317 | "groupIds": [ 318 | "BfB3WS8KoqWk-NF_78CkE" 319 | ], 320 | "roundness": null, 321 | "seed": 1010856904, 322 | "version": 213, 323 | "versionNonce": 1788806344, 324 | "isDeleted": false, 325 | "boundElements": null, 326 | "updated": 1679667049849, 327 | "link": null, 328 | "locked": false, 329 | "text": "hostgroup 20", 330 | "fontSize": 20, 331 | "fontFamily": 1, 332 | "textAlign": "center", 333 | "verticalAlign": "middle", 334 | "containerId": "R_tBJ7vrFsQ5VhM7incZO", 335 | "originalText": "hostgroup 20", 336 | "lineHeight": 1.25 337 | }, 338 | { 339 | "type": "rectangle", 340 | "version": 2390, 341 | "versionNonce": 1223896008, 342 | "isDeleted": false, 343 | "id": "Y937hpGrCZ8rxLXY6ODyC", 344 | "fillStyle": "cross-hatch", 345 | "strokeWidth": 1, 346 | "strokeStyle": "solid", 347 | "roughness": 1, 348 | "opacity": 100, 349 | "angle": 0, 350 | "x": 1329.4680318416881, 351 | "y": -46.47218089934535, 352 | "strokeColor": "#000000", 353 | "backgroundColor": "#82c91e", 354 | "width": 209.18356323242188, 355 | "height": 99.67071533203125, 356 | "seed": 462094520, 357 | "groupIds": [ 358 | "HIfXvWimxFnb8b7iDJhJO" 359 | ], 360 | "roundness": { 361 | "type": 3 362 | }, 363 | "boundElements": [ 364 | { 365 | "type": "text", 366 | "id": "5I_TDqds8Bj-bcz2Rbtei" 367 | }, 368 | { 369 | "id": "rbLJdiNWzj5RzaJ_0hibq", 370 | "type": "arrow" 371 | }, 372 | { 373 | "id": "jSS0jp_3HEV61MtO2BQUl", 374 | "type": "arrow" 375 | } 376 | ], 377 | "updated": 1679667049849, 378 | "link": null, 379 | "locked": false 380 | }, 381 | { 382 | "type": "text", 383 | "version": 1383, 384 | "versionNonce": 1409045688, 385 | "isDeleted": false, 386 | "id": "5I_TDqds8Bj-bcz2Rbtei", 387 | "fillStyle": "hachure", 388 | "strokeWidth": 1, 389 | "strokeStyle": "solid", 390 | "roughness": 1, 391 | "opacity": 100, 392 | "angle": 0, 393 | "x": 1342.625845867567, 394 | "y": -13.436823233329719, 395 | "strokeColor": "#000000", 396 | "backgroundColor": "transparent", 397 | "width": 182.86793518066406, 398 | "height": 33.6, 399 | "seed": 1999406792, 400 | "groupIds": [ 401 | "HIfXvWimxFnb8b7iDJhJO" 402 | ], 403 | "roundness": null, 404 | "boundElements": null, 405 | "updated": 1679667049849, 406 | "link": null, 407 | "locked": false, 408 | "fontSize": 28, 409 | "fontFamily": 1, 410 | "text": "mysql-slave-2", 411 | "textAlign": "center", 412 | "verticalAlign": "middle", 413 | "containerId": "Y937hpGrCZ8rxLXY6ODyC", 414 | "originalText": "mysql-slave-2", 415 | "lineHeight": 1.2 416 | }, 417 | { 418 | "type": "rectangle", 419 | "version": 556, 420 | "versionNonce": 942305992, 421 | "isDeleted": false, 422 | "id": "jHtYzPG01z16q5qgg3W4q", 423 | "fillStyle": "solid", 424 | "strokeWidth": 1, 425 | "strokeStyle": "solid", 426 | "roughness": 1, 427 | "opacity": 100, 428 | "angle": 0, 429 | "x": 1362.9789850959505, 430 | "y": 20.563534702998197, 431 | "strokeColor": "#000000", 432 | "backgroundColor": "#ffffff", 433 | "width": 151, 434 | "height": 35, 435 | "seed": 1155148216, 436 | "groupIds": [ 437 | "HIfXvWimxFnb8b7iDJhJO" 438 | ], 439 | "roundness": { 440 | "type": 3 441 | }, 442 | "boundElements": [ 443 | { 444 | "type": "text", 445 | "id": "mPtvXeTXnaYYIivx3K934" 446 | } 447 | ], 448 | "updated": 1679667049849, 449 | "link": null, 450 | "locked": false 451 | }, 452 | { 453 | "type": "text", 454 | "version": 444, 455 | "versionNonce": 201391544, 456 | "isDeleted": false, 457 | "id": "mPtvXeTXnaYYIivx3K934", 458 | "fillStyle": "hachure", 459 | "strokeWidth": 1, 460 | "strokeStyle": "solid", 461 | "roughness": 1, 462 | "opacity": 100, 463 | "angle": 0, 464 | "x": 1372.4590494880404, 465 | "y": 25.563534702998197, 466 | "strokeColor": "#000000", 467 | "backgroundColor": "#ffffff", 468 | "width": 132.0398712158203, 469 | "height": 25, 470 | "seed": 746614216, 471 | "groupIds": [ 472 | "HIfXvWimxFnb8b7iDJhJO" 473 | ], 474 | "roundness": null, 475 | "boundElements": null, 476 | "updated": 1679667049849, 477 | "link": null, 478 | "locked": false, 479 | "fontSize": 20, 480 | "fontFamily": 1, 481 | "text": "hostgroup 20", 482 | "textAlign": "center", 483 | "verticalAlign": "middle", 484 | "containerId": "jHtYzPG01z16q5qgg3W4q", 485 | "originalText": "hostgroup 20", 486 | "lineHeight": 1.25 487 | }, 488 | { 489 | "type": "rectangle", 490 | "version": 2190, 491 | "versionNonce": 260605624, 492 | "isDeleted": false, 493 | "id": "T36oLVxHL5dVgpOlEVcXy", 494 | "fillStyle": "cross-hatch", 495 | "strokeWidth": 1, 496 | "strokeStyle": "solid", 497 | "roughness": 1, 498 | "opacity": 100, 499 | "angle": 0, 500 | "x": 1142.3679805238933, 501 | "y": -188.26628823390615, 502 | "strokeColor": "#000000", 503 | "backgroundColor": "#40c057", 504 | "width": 209.18356323242188, 505 | "height": 99.67071533203125, 506 | "seed": 1833173176, 507 | "groupIds": [ 508 | "qPS2aimO9fM-fsRsMNM07" 509 | ], 510 | "roundness": { 511 | "type": 3 512 | }, 513 | "boundElements": [ 514 | { 515 | "type": "text", 516 | "id": "g8Y3AuPciX_BVfWMDvIuQ" 517 | }, 518 | { 519 | "id": "moQgjIRtnoq8RvGoGVVES", 520 | "type": "arrow" 521 | }, 522 | { 523 | "id": "vMby8tIOEsbjLY6UYmF_2", 524 | "type": "arrow" 525 | }, 526 | { 527 | "id": "jSS0jp_3HEV61MtO2BQUl", 528 | "type": "arrow" 529 | } 530 | ], 531 | "updated": 1679667049849, 532 | "link": null, 533 | "locked": false 534 | }, 535 | { 536 | "type": "text", 537 | "version": 1184, 538 | "versionNonce": 1882349768, 539 | "isDeleted": false, 540 | "id": "g8Y3AuPciX_BVfWMDvIuQ", 541 | "fillStyle": "hachure", 542 | "strokeWidth": 1, 543 | "strokeStyle": "solid", 544 | "roughness": 1, 545 | "opacity": 100, 546 | "angle": 0, 547 | "x": 1159.4597926576823, 548 | "y": -155.23093056789054, 549 | "strokeColor": "#000000", 550 | "backgroundColor": "transparent", 551 | "width": 174.99993896484375, 552 | "height": 33.6, 553 | "seed": 2038662856, 554 | "groupIds": [ 555 | "qPS2aimO9fM-fsRsMNM07" 556 | ], 557 | "roundness": null, 558 | "boundElements": null, 559 | "updated": 1679667049849, 560 | "link": null, 561 | "locked": false, 562 | "fontSize": 28, 563 | "fontFamily": 1, 564 | "text": "mysql-master", 565 | "textAlign": "center", 566 | "verticalAlign": "middle", 567 | "containerId": "T36oLVxHL5dVgpOlEVcXy", 568 | "originalText": "mysql-master", 569 | "lineHeight": 1.2 570 | }, 571 | { 572 | "type": "rectangle", 573 | "version": 354, 574 | "versionNonce": 725020600, 575 | "isDeleted": false, 576 | "id": "zta9lJCDlygiZcLrNrilK", 577 | "fillStyle": "solid", 578 | "strokeWidth": 1, 579 | "strokeStyle": "solid", 580 | "roughness": 1, 581 | "opacity": 100, 582 | "angle": 0, 583 | "x": 1175.8789337781557, 584 | "y": -121.23057263156261, 585 | "strokeColor": "#000000", 586 | "backgroundColor": "#ffffff", 587 | "width": 151, 588 | "height": 35, 589 | "seed": 2004036024, 590 | "groupIds": [ 591 | "qPS2aimO9fM-fsRsMNM07" 592 | ], 593 | "roundness": { 594 | "type": 3 595 | }, 596 | "boundElements": [ 597 | { 598 | "type": "text", 599 | "id": "ew2nODeGy6OOl5j4PUEtW" 600 | } 601 | ], 602 | "updated": 1679667049849, 603 | "link": null, 604 | "locked": false 605 | }, 606 | { 607 | "type": "text", 608 | "version": 244, 609 | "versionNonce": 2075854792, 610 | "isDeleted": false, 611 | "id": "ew2nODeGy6OOl5j4PUEtW", 612 | "fillStyle": "hachure", 613 | "strokeWidth": 1, 614 | "strokeStyle": "solid", 615 | "roughness": 1, 616 | "opacity": 100, 617 | "angle": 0, 618 | "x": 1189.7689942029604, 619 | "y": -116.23057263156261, 620 | "strokeColor": "#000000", 621 | "backgroundColor": "#ffffff", 622 | "width": 123.21987915039062, 623 | "height": 25, 624 | "seed": 1274506696, 625 | "groupIds": [ 626 | "qPS2aimO9fM-fsRsMNM07" 627 | ], 628 | "roundness": null, 629 | "boundElements": null, 630 | "updated": 1679667049849, 631 | "link": null, 632 | "locked": false, 633 | "fontSize": 20, 634 | "fontFamily": 1, 635 | "text": "hostgroup 10", 636 | "textAlign": "center", 637 | "verticalAlign": "middle", 638 | "containerId": "zta9lJCDlygiZcLrNrilK", 639 | "originalText": "hostgroup 10", 640 | "lineHeight": 1.25 641 | }, 642 | { 643 | "id": "7uyeTQhEJ8S59Tg4aNS_W", 644 | "type": "arrow", 645 | "x": 652.0895605641555, 646 | "y": -137.13344306680105, 647 | "width": 110.73151912242292, 648 | "height": 0.8362373989554044, 649 | "angle": 0, 650 | "strokeColor": "#000000", 651 | "backgroundColor": "#ffffff", 652 | "fillStyle": "solid", 653 | "strokeWidth": 1, 654 | "strokeStyle": "solid", 655 | "roughness": 1, 656 | "opacity": 100, 657 | "groupIds": [], 658 | "roundness": { 659 | "type": 2 660 | }, 661 | "seed": 647503304, 662 | "version": 425, 663 | "versionNonce": 1999648952, 664 | "isDeleted": false, 665 | "boundElements": null, 666 | "updated": 1679667049849, 667 | "link": null, 668 | "locked": false, 669 | "points": [ 670 | [ 671 | 0, 672 | 0 673 | ], 674 | [ 675 | 110.73151912242292, 676 | 0.8362373989554044 677 | ] 678 | ], 679 | "lastCommittedPoint": null, 680 | "startBinding": { 681 | "elementId": "3nhq8C5rjOxS0OEYzBYan", 682 | "focus": -0.02333683869826681, 683 | "gap": 7.323326662188151 684 | }, 685 | "endBinding": { 686 | "elementId": "AFjTg_6ur4FytvF9woV-p", 687 | "focus": -0.092678056121751, 688 | "gap": 1.4097906209511848 689 | }, 690 | "startArrowhead": "arrow", 691 | "endArrowhead": "arrow" 692 | }, 693 | { 694 | "id": "ovP3ppf2AKcV8zwccc36n", 695 | "type": "arrow", 696 | "x": 975.8813608016433, 697 | "y": -164.05991400599197, 698 | "width": 352.31785091459665, 699 | "height": 148.7904258948426, 700 | "angle": 0, 701 | "strokeColor": "#000000", 702 | "backgroundColor": "#ffffff", 703 | "fillStyle": "solid", 704 | "strokeWidth": 1, 705 | "strokeStyle": "solid", 706 | "roughness": 1, 707 | "opacity": 100, 708 | "groupIds": [], 709 | "roundness": { 710 | "type": 2 711 | }, 712 | "seed": 723533256, 713 | "version": 476, 714 | "versionNonce": 2107035336, 715 | "isDeleted": false, 716 | "boundElements": [ 717 | { 718 | "type": "text", 719 | "id": "K7JYijNIlyuNGVo4M6_iQ" 720 | } 721 | ], 722 | "updated": 1679667049849, 723 | "link": null, 724 | "locked": false, 725 | "points": [ 726 | [ 727 | 0, 728 | 0 729 | ], 730 | [ 731 | 61.707821669506984, 732 | -128.02124220488042 733 | ], 734 | [ 735 | 352.31785091459665, 736 | -148.7904258948426 737 | ] 738 | ], 739 | "lastCommittedPoint": null, 740 | "startBinding": { 741 | "elementId": "AFjTg_6ur4FytvF9woV-p", 742 | "focus": 0.7319261105579832, 743 | "gap": 2.466927261691808 744 | }, 745 | "endBinding": { 746 | "elementId": "-UrpQnFNOP5kMIeE_36XM", 747 | "focus": 0.3069632560055075, 748 | "gap": 1.6418788880139346 749 | }, 750 | "startArrowhead": null, 751 | "endArrowhead": "arrow" 752 | }, 753 | { 754 | "id": "K7JYijNIlyuNGVo4M6_iQ", 755 | "type": "text", 756 | "x": 1124.4803115885315, 757 | "y": -250.95512695341327, 758 | "width": 55.11994934082031, 759 | "height": 25, 760 | "angle": 0, 761 | "strokeColor": "#000000", 762 | "backgroundColor": "#ffffff", 763 | "fillStyle": "solid", 764 | "strokeWidth": 1, 765 | "strokeStyle": "solid", 766 | "roughness": 1, 767 | "opacity": 100, 768 | "groupIds": [], 769 | "roundness": null, 770 | "seed": 555611592, 771 | "version": 12, 772 | "versionNonce": 1729651128, 773 | "isDeleted": false, 774 | "boundElements": null, 775 | "updated": 1679667049849, 776 | "link": null, 777 | "locked": false, 778 | "text": "reads", 779 | "fontSize": 20, 780 | "fontFamily": 1, 781 | "textAlign": "center", 782 | "verticalAlign": "middle", 783 | "containerId": "ovP3ppf2AKcV8zwccc36n", 784 | "originalText": "reads", 785 | "lineHeight": 1.25 786 | }, 787 | { 788 | "id": "moQgjIRtnoq8RvGoGVVES", 789 | "type": "arrow", 790 | "x": 975.0382530498191, 791 | "y": -133.37487545771697, 792 | "width": 162.55101037550253, 793 | "height": 3.1337003890778874, 794 | "angle": 0, 795 | "strokeColor": "#000000", 796 | "backgroundColor": "#ffffff", 797 | "fillStyle": "solid", 798 | "strokeWidth": 1, 799 | "strokeStyle": "solid", 800 | "roughness": 1, 801 | "opacity": 100, 802 | "groupIds": [], 803 | "roundness": { 804 | "type": 2 805 | }, 806 | "seed": 1028952760, 807 | "version": 270, 808 | "versionNonce": 2120332744, 809 | "isDeleted": false, 810 | "boundElements": [ 811 | { 812 | "type": "text", 813 | "id": "jExNybKUC_RvbQJwpAe3t" 814 | } 815 | ], 816 | "updated": 1679667049849, 817 | "link": null, 818 | "locked": false, 819 | "points": [ 820 | [ 821 | 0, 822 | 0 823 | ], 824 | [ 825 | 162.55101037550253, 826 | -3.1337003890778874 827 | ] 828 | ], 829 | "lastCommittedPoint": null, 830 | "startBinding": { 831 | "elementId": "AFjTg_6ur4FytvF9woV-p", 832 | "focus": 0.11417672630000668, 833 | "gap": 1.6238195098676442 834 | }, 835 | "endBinding": { 836 | "elementId": "T36oLVxHL5dVgpOlEVcXy", 837 | "focus": 0.0035894291139901306, 838 | "gap": 4.778717098571633 839 | }, 840 | "startArrowhead": null, 841 | "endArrowhead": "arrow" 842 | }, 843 | { 844 | "id": "jExNybKUC_RvbQJwpAe3t", 845 | "type": "text", 846 | "x": 1019.4676380372766, 847 | "y": -148.1265014274538, 848 | "width": 58.25993347167969, 849 | "height": 25, 850 | "angle": 0, 851 | "strokeColor": "#000000", 852 | "backgroundColor": "#ffffff", 853 | "fillStyle": "solid", 854 | "strokeWidth": 1, 855 | "strokeStyle": "solid", 856 | "roughness": 1, 857 | "opacity": 100, 858 | "groupIds": [], 859 | "roundness": null, 860 | "seed": 592541624, 861 | "version": 13, 862 | "versionNonce": 1511221944, 863 | "isDeleted": false, 864 | "boundElements": null, 865 | "updated": 1679667049849, 866 | "link": null, 867 | "locked": false, 868 | "text": "writes", 869 | "fontSize": 20, 870 | "fontFamily": 1, 871 | "textAlign": "center", 872 | "verticalAlign": "middle", 873 | "containerId": "moQgjIRtnoq8RvGoGVVES", 874 | "originalText": "writes", 875 | "lineHeight": 1.25 876 | }, 877 | { 878 | "id": "rbLJdiNWzj5RzaJ_0hibq", 879 | "type": "arrow", 880 | "x": 975.203944687086, 881 | "y": -118.12475557671266, 882 | "width": 351.11450277001086, 883 | "height": 130.42166404916983, 884 | "angle": 0, 885 | "strokeColor": "#000000", 886 | "backgroundColor": "#ffffff", 887 | "fillStyle": "solid", 888 | "strokeWidth": 1, 889 | "strokeStyle": "solid", 890 | "roughness": 1, 891 | "opacity": 100, 892 | "groupIds": [], 893 | "roundness": { 894 | "type": 2 895 | }, 896 | "seed": 1335875000, 897 | "version": 783, 898 | "versionNonce": 322419912, 899 | "isDeleted": false, 900 | "boundElements": [ 901 | { 902 | "type": "text", 903 | "id": "vr6i9_rwyuX7DX-0WjF9m" 904 | } 905 | ], 906 | "updated": 1679667049849, 907 | "link": null, 908 | "locked": false, 909 | "points": [ 910 | [ 911 | 0, 912 | 0 913 | ], 914 | [ 915 | 67.68503382348467, 916 | 120.39798177657312 917 | ], 918 | [ 919 | 351.11450277001086, 920 | 130.42166404916983 921 | ] 922 | ], 923 | "lastCommittedPoint": null, 924 | "startBinding": { 925 | "elementId": "AFjTg_6ur4FytvF9woV-p", 926 | "focus": -0.7211504830691621, 927 | "gap": 1.78951114713459 928 | }, 929 | "endBinding": { 930 | "elementId": "Y937hpGrCZ8rxLXY6ODyC", 931 | "focus": -0.23805440921545734, 932 | "gap": 3.1495843845912077 933 | }, 934 | "startArrowhead": null, 935 | "endArrowhead": "arrow" 936 | }, 937 | { 938 | "id": "vr6i9_rwyuX7DX-0WjF9m", 939 | "type": "text", 940 | "x": 1123.2012214016813, 941 | "y": -65.41392355212774, 942 | "width": 55.11994934082031, 943 | "height": 25, 944 | "angle": 0, 945 | "strokeColor": "#000000", 946 | "backgroundColor": "#ffffff", 947 | "fillStyle": "solid", 948 | "strokeWidth": 1, 949 | "strokeStyle": "solid", 950 | "roughness": 1, 951 | "opacity": 100, 952 | "groupIds": [], 953 | "roundness": null, 954 | "seed": 770854328, 955 | "version": 13, 956 | "versionNonce": 717575096, 957 | "isDeleted": false, 958 | "boundElements": null, 959 | "updated": 1679667049849, 960 | "link": null, 961 | "locked": false, 962 | "text": "reads", 963 | "fontSize": 20, 964 | "fontFamily": 1, 965 | "textAlign": "center", 966 | "verticalAlign": "middle", 967 | "containerId": "rbLJdiNWzj5RzaJ_0hibq", 968 | "originalText": "reads", 969 | "lineHeight": 1.25 970 | }, 971 | { 972 | "id": "vMby8tIOEsbjLY6UYmF_2", 973 | "type": "arrow", 974 | "x": 1356.5832184383057, 975 | "y": -159.96298795914365, 976 | "width": 77.1532597055218, 977 | "height": 85.58896976390477, 978 | "angle": 0, 979 | "strokeColor": "#000000", 980 | "backgroundColor": "#ffffff", 981 | "fillStyle": "solid", 982 | "strokeWidth": 1, 983 | "strokeStyle": "solid", 984 | "roughness": 1, 985 | "opacity": 100, 986 | "groupIds": [], 987 | "roundness": { 988 | "type": 2 989 | }, 990 | "seed": 668351176, 991 | "version": 520, 992 | "versionNonce": 737487560, 993 | "isDeleted": false, 994 | "boundElements": [ 995 | { 996 | "type": "text", 997 | "id": "RqnUSJ0RQvb-FM9Ol-KNE" 998 | } 999 | ], 1000 | "updated": 1679667049850, 1001 | "link": null, 1002 | "locked": false, 1003 | "points": [ 1004 | [ 1005 | 0, 1006 | 0 1007 | ], 1008 | [ 1009 | 74.4179614937766, 1010 | -11.917062650582267 1011 | ], 1012 | [ 1013 | 77.1532597055218, 1014 | -85.58896976390477 1015 | ] 1016 | ], 1017 | "lastCommittedPoint": null, 1018 | "startBinding": { 1019 | "elementId": "T36oLVxHL5dVgpOlEVcXy", 1020 | "focus": -0.059733176180929694, 1021 | "gap": 5.03167468199058 1022 | }, 1023 | "endBinding": { 1024 | "elementId": "R_tBJ7vrFsQ5VhM7incZO", 1025 | "focus": 0.0561596106616119, 1026 | "gap": 5.098133954708999 1027 | }, 1028 | "startArrowhead": null, 1029 | "endArrowhead": "arrow" 1030 | }, 1031 | { 1032 | "id": "RqnUSJ0RQvb-FM9Ol-KNE", 1033 | "type": "text", 1034 | "x": 1339.683746096144, 1035 | "y": -206.6929222197819, 1036 | "width": 95.5198974609375, 1037 | "height": 25, 1038 | "angle": 0, 1039 | "strokeColor": "#000000", 1040 | "backgroundColor": "#ffffff", 1041 | "fillStyle": "solid", 1042 | "strokeWidth": 1, 1043 | "strokeStyle": "solid", 1044 | "roughness": 1, 1045 | "opacity": 100, 1046 | "groupIds": [], 1047 | "roundness": null, 1048 | "seed": 215837880, 1049 | "version": 17, 1050 | "versionNonce": 425575864, 1051 | "isDeleted": false, 1052 | "boundElements": null, 1053 | "updated": 1679667049850, 1054 | "link": null, 1055 | "locked": false, 1056 | "text": "replicates", 1057 | "fontSize": 20, 1058 | "fontFamily": 1, 1059 | "textAlign": "center", 1060 | "verticalAlign": "middle", 1061 | "containerId": "vMby8tIOEsbjLY6UYmF_2", 1062 | "originalText": "replicates", 1063 | "lineHeight": 1.25 1064 | }, 1065 | { 1066 | "id": "jSS0jp_3HEV61MtO2BQUl", 1067 | "type": "arrow", 1068 | "x": 1355.9270189358376, 1069 | "y": -131.10657744844883, 1070 | "width": 82.10667082569876, 1071 | "height": 81.76205964411315, 1072 | "angle": 0, 1073 | "strokeColor": "#000000", 1074 | "backgroundColor": "#ffffff", 1075 | "fillStyle": "solid", 1076 | "strokeWidth": 1, 1077 | "strokeStyle": "solid", 1078 | "roughness": 1, 1079 | "opacity": 100, 1080 | "groupIds": [], 1081 | "roundness": { 1082 | "type": 2 1083 | }, 1084 | "seed": 947365320, 1085 | "version": 742, 1086 | "versionNonce": 143954376, 1087 | "isDeleted": false, 1088 | "boundElements": [ 1089 | { 1090 | "type": "text", 1091 | "id": "_5-cWUXYqgpU5-EUvV7OD" 1092 | } 1093 | ], 1094 | "updated": 1679667049850, 1095 | "link": null, 1096 | "locked": false, 1097 | "points": [ 1098 | [ 1099 | 0, 1100 | 0 1101 | ], 1102 | [ 1103 | 70.86455783693532, 1104 | 10.420948916000043 1105 | ], 1106 | [ 1107 | 82.10667082569876, 1108 | 81.76205964411315 1109 | ] 1110 | ], 1111 | "lastCommittedPoint": null, 1112 | "startBinding": { 1113 | "elementId": "T36oLVxHL5dVgpOlEVcXy", 1114 | "focus": -0.13339918736459333, 1115 | "gap": 4.375475179522482 1116 | }, 1117 | "endBinding": { 1118 | "elementId": "Y937hpGrCZ8rxLXY6ODyC", 1119 | "focus": 0.1092062105285591, 1120 | "gap": 2.872336904990334 1121 | }, 1122 | "startArrowhead": null, 1123 | "endArrowhead": "arrow" 1124 | }, 1125 | { 1126 | "id": "_5-cWUXYqgpU5-EUvV7OD", 1127 | "type": "text", 1128 | "x": 1342.5506226267628, 1129 | "y": -105.34093053676983, 1130 | "width": 95.5198974609375, 1131 | "height": 25, 1132 | "angle": 0, 1133 | "strokeColor": "#000000", 1134 | "backgroundColor": "#ffffff", 1135 | "fillStyle": "solid", 1136 | "strokeWidth": 1, 1137 | "strokeStyle": "solid", 1138 | "roughness": 1, 1139 | "opacity": 100, 1140 | "groupIds": [], 1141 | "roundness": null, 1142 | "seed": 865809592, 1143 | "version": 17, 1144 | "versionNonce": 1712082616, 1145 | "isDeleted": false, 1146 | "boundElements": null, 1147 | "updated": 1679667049850, 1148 | "link": null, 1149 | "locked": false, 1150 | "text": "replicates", 1151 | "fontSize": 20, 1152 | "fontFamily": 1, 1153 | "textAlign": "center", 1154 | "verticalAlign": "middle", 1155 | "containerId": "jSS0jp_3HEV61MtO2BQUl", 1156 | "originalText": "replicates", 1157 | "lineHeight": 1.25 1158 | } 1159 | ], 1160 | "appState": { 1161 | "gridSize": null, 1162 | "viewBackgroundColor": "#ffffff" 1163 | }, 1164 | "files": {} 1165 | } --------------------------------------------------------------------------------