Linux高性能服务器(二)

6.1 pipe函数

pipe函数可以用来创建一个单向管道,来实现进程间的通信。

arduino 复制代码
 #include <unistd.h>
 int pipe(int fd[2])

pipe创建的管道是单向且阻塞的 。该函数成功时返回0,并将一对打开的文件描述符填入其参数指向的数组。如果失败则返回-1,并设置errno

如果管道的写入端描述符的引用计数为0时,那么针对该管道的读取端描述符返回0,即读取到了文件结束标记;如果管道的读取端描述符的引用计数为0,那针对该管道的写入端描述符的write操作将失败,并引发SIGPIPE信号

管道是字节流,默认大小为65536,且可以通过fcntl函数来修改管道容量

而socketpair函数可以很方便的创建双向管道

arduino 复制代码
 #include <sys/types.h>
 #include <sys/socket.h>
 int socketpair(int domain, int type, int protocol, int fd[2]);

6.2 dup函数和dup2函数

该函数可以标准输入重定向到一个文件,或者把标准输出重定向到一个网络连接

arduino 复制代码
 #include <unistd.h>
 int dup(int file_descriptor)
 int dup2(int file_descriptor_one, int file_descriptor_tow)

dup函数重新创建一个新的文件描述符,该新文件描述符和原有文件描述符file_descriptor指向相同的文件、管道或者网络连接。但是它们返回的文件描述符数值不太一样

服务器发送数据代码:

ini 复制代码
 #include <sys/socket.h>
 #include <netinet/in.h>
 #include <arpa/inet.h>
 #include <assert.h>
 #include <stdio.h>
 #include <unistd.h>
 #include <stdlib.h>
 #include <errno.h>
 #include <string.h>
 ​
 int main( int argc, char* argv[] )
 {
     if( argc <= 2 )
     {
         printf( "usage: %s ip_address port_number\n", basename( argv[0] ) );
         return 1;
     }
     const char* ip = argv[1];
     int port = atoi( argv[2] );
 ​
     struct sockaddr_in address;
     bzero( &address, sizeof( address ) );
     address.sin_family = AF_INET;
     inet_pton( AF_INET, ip, &address.sin_addr );
     address.sin_port = htons( port );
 ​
     int sock = socket( PF_INET, SOCK_STREAM, 0 );
     assert( sock >= 0 );
 ​
     int ret = bind( sock, ( struct sockaddr* )&address, sizeof( address ) );
     assert( ret != -1 );
 ​
     ret = listen( sock, 5 );
     assert( ret != -1 );
 ​
     struct sockaddr_in client;
     socklen_t client_addrlength = sizeof( client );
     int connfd = accept( sock, ( struct sockaddr* )&client, &client_addrlength );
     if ( connfd < 0 )
     {
         printf( "errno is: %d\n", errno );
     }
     else
     {
         /*关闭标准输入文件符*/
         close( STDOUT_FILENO );
         dup( connfd );
         printf( "abcd\n" );
         close( connfd );
     }
 ​
     close( sock );
     return 0;
 }

客户端接收数据代码:

arduino 复制代码
 #include <sys/socket.h>
 #include <netinet/in.h>
 #include <arpa/inet.h>
 #include <assert.h>
 #include <stdio.h>
 #include <unistd.h>
 #include <string.h>
 #include <stdlib.h>
 ​
 int main( int argc, char* argv[] )
 {
     if( argc <= 2 )
     {
         printf( "usage: %s ip_address port_number\n", basename( argv[0] ) );
         return 1;
     }
     const char* ip = argv[1];
     int port = atoi( argv[2] );
 ​
     struct sockaddr_in server_address;
     bzero( &server_address, sizeof( server_address ) );
     server_address.sin_family = AF_INET;
     inet_pton( AF_INET, ip, &server_address.sin_addr );
     server_address.sin_port = htons( port );
 ​
     int sockfd = socket( PF_INET, SOCK_STREAM, 0 );
     assert( sockfd >= 0 );
     if ( connect( sockfd, ( struct sockaddr* )&server_address, sizeof( server_address ) ) < 0 )
     {
         printf( "connection failed\n" );
     }
     else
     {
         /*由客户端来接收服务端重定向到网络socket的数据*/
         char buffer[1024];
         memset(buffer, '\0', 1024);
         int ret = recv(sockfd, buffer, 1023, 0);
         printf("the dup data: %s", buffer);
     }
 ​
     close( sockfd );
     return 0;
 }

客户端接收数据实验截图:

6.3 readv函数和writev函数

readv函数将数据从文件描述符读到分散的内存块中,即分散读;writev函数则将多块分散的内存数据一并写入文件描述符中,即集中写。

arduino 复制代码
 #include <sys/uio.h>
 ssize_t readv(int fd, const struct iovec* vector, int count)
 ssize_t writev(int fd, const struct iovec* vector, int count)
  • fd参数是被操作的目标文件描述符;
  • vector是iovec结构数据;

readv和writev成功时返回读取/写入fd的字节数,失败则返回-1

ini 复制代码
 struct iovec iv[2];
 iv[0].iov_base = header_buf;
 iv[0].iov_len = strlen(header_buf);
 iv[1].iov_base = file_buf;
 iv[1].iov_len = file_stat.st_size;
 ret = writev(connfd, iv, 2);

6.4 sendfile函数

sendfile函数直接在两个文件描述符之间传递数据(完全在内核中操作),从而避免了内核缓冲区和用户缓冲区之间的数据拷贝,效率很高,这被称为零拷贝。

arduino 复制代码
 #include <sys/sendfile.h>
 ssize_t sendfile(int out_fd, int in_fd, off_t* offset, size_t count)
  • in_fd参数是待读出内容的文件描述符;
  • out_fd参数是代写入内容的文件描述符;
  • offset参数指定从读入文件流的哪个位置开始读,如果为空,则使用读入文件流默认的起始位置
  • count参数指定在文件描述符in_fd和out_fd之间传输的字节数

sendfile成功时返回传输的字节数,失败则返回-1并设置errno

in_fd必须是一个支持类似mmap函数的文件描述符,即它必须指向真实的文件,不能是socket和管道;而out_fd则必须是一个socket

服务端发送文件代码:

c 复制代码
 #include <sys/socket.h>
 #include <netinet/in.h>
 #include <arpa/inet.h>
 #include <assert.h>
 #include <stdio.h>
 #include <unistd.h>
 #include <stdlib.h>
 #include <errno.h>
 #include <string.h>
 #include <sys/types.h>
 #include <sys/stat.h>
 #include <fcntl.h>
 #include <sys/sendfile.h>
 ​
 int main( int argc, char* argv[] )
 {
     if( argc <= 3 )
     {
         printf( "usage: %s ip_address port_number filename\n", basename( argv[0] ) );
         return 1;
     }
     const char* ip = argv[1];
     int port = atoi( argv[2] );
     const char* file_name = argv[3];
 ​
     int filefd = open( file_name, O_RDONLY );
     assert( filefd > 0 );
     /*struct stat用来描述一个Linux系统文件系统中的文件属性的结构*/
     struct stat stat_buf;
     /*fstat获取文件的信息*/
     fstat( filefd, &stat_buf );
 ​
     struct sockaddr_in address;
     bzero( &address, sizeof( address ) );
     address.sin_family = AF_INET;
     inet_pton( AF_INET, ip, &address.sin_addr );
     address.sin_port = htons( port );
 ​
     int sock = socket( PF_INET, SOCK_STREAM, 0 );
     assert( sock >= 0 );
 ​
     int ret = bind( sock, ( struct sockaddr* )&address, sizeof( address ) );
     assert( ret != -1 );
 ​
     ret = listen( sock, 5 );
     assert( ret != -1 );
 ​
     struct sockaddr_in client;
     socklen_t client_addrlength = sizeof( client );
     int connfd = accept( sock, ( struct sockaddr* )&client, &client_addrlength );
     if ( connfd < 0 )
     {
         printf( "errno is: %d\n", errno );
     }
     else
     {
         /*直接从内核缓冲区拷贝数据*/
         sendfile( connfd, filefd, NULL, stat_buf.st_size );
         close( connfd );
     }
 ​
     close( sock );
     return 0;
 }

以下是stat结构体的信息:

arduino 复制代码
 #include <sys/stat.h>
 struct stat {
     dev_t     st_dev;         // 文件所在设备号
     ino_t     st_ino;         // I节点号
     mode_t    st_mode;        // 文件类型和权限
     nlink_t   st_nlink;       // 硬链接数量
     uid_t     st_uid;         // 所有者ID
     gid_t     st_gid;         // 所有者的组ID
     dev_t     st_rdev;        // 设备号(仅特殊文件)
     off_t     st_size;        // 总字节大小
     blksize_t st_blksize;     // 文件系统I/O的块大小
     blkcnt_t  st_blocks;      // 分配的512B块数量
 ​
     struct timespec st_atim;  // 文件数据的最后访问时间
     struct timespec st_mtim;  // 文件数据的最后修改时间
     struct timespec st_ctim;  // i节点状态的最后更改时间
 ​
     #define st_atime st_atim.tv_sec
     #define st_mtime st_mtim.tv_sec
     #define st_ctime st_ctim.tv_sec
 };

服务端实验截图:

客户端实验结果:

6.5 mmap函数和munmap函数

mmap函数用于申请一段内存空间。我们可以将这段内存作为进程间通信的共享内存,也可以将文件直接映射到其中。munmap函数则释放由mmap创建的这段内存空间。

[linux共享内存] zhuanlan.zhihu.com/p/633054182

arduino 复制代码
 #include <sys/mman.h>
 void mmap(void* start, size_t length, int prot, int flags, int fd, off_t offset)
 int munmap(void* start, size_t length)
  • start参数允许用户使用某个特定的地址作为这段内存的起始地址,默认为NULL,则由系统自动分配;

  • length参数指定内存段的长度;

  • prot参数设置内存段的访问权限

    • PROT_READ,内存段可读
    • PROT_WRITE,内存段可写
    • PROT_EXEC,内存段可执行
    • PROT_NONE,内存段不能被访问
  • flags参数控制内存段内容被修改后程序的行为

    常用值 含义
    MAP_SHARED 在进程间共享内存。对该内存段的修改将反映到被映射的文件中。它提供了进程间共享内存的POSIX方法
    MAP_PRIVATE 内存段为调用进程所私有
    MAP_ANONYMOUS 这段内存不是从文件映射而来的,其内容被初始化全为0
    MAP_FIXED 内存段必须位于start参数指定的地址处,start必须是内存页面大小(4096字节)的整数倍
    MAP_HUGETLB 按照大内存页面来分配空间
  • fd参数是被映射文件对应的文件描述符,一般通过open获得

  • offset参数设置从文件的何处开始映射

6.6 splice函数

splice函数用于在两个文件描述符之间移动数据,也是零拷贝操作。

arduino 复制代码
 #include <fcntl.h>
 ssize_t splice(int fd_in, loff_t* off_in, int fd_out, loff_t* off_out, size_t len, unsigned int flags)
  • fd_in参数是待输入数据的文件描述符,如果fd_in是一个管道文件描述符,那么off_in参数必须被设置为NULL

  • len参数指定移动数据的长度;

  • flags参数则控制数据如何移动,它可以被设置为下标中某些值得按位或

    常用值 含义
    SPLICE_F_MOVE
    SPLICE_F_NONBLOCK 非阻塞的splice操作,实际效果收到文件描述符本身的阻塞状态的影响
    SPLICE_F_MORE
    SPLICE_F_GIFT

    使用splice函数时,fd_in和fd_out必须至少有一个是管道文件描述符

回射服务器代码:

ini 复制代码
 #include <sys/socket.h>
 #include <netinet/in.h>
 #include <arpa/inet.h>
 #include <assert.h>
 #include <stdio.h>
 #include <unistd.h>
 #include <stdlib.h>
 #include <errno.h>
 #include <string.h>
 #include <fcntl.h>
 ​
 int main( int argc, char* argv[] )
 {
     if( argc <= 2 )
     {
         printf( "usage: %s ip_address port_number\n", basename( argv[0] ) );
         return 1;
     }
     const char* ip = argv[1];
     int port = atoi( argv[2] );
 ​
     struct sockaddr_in address;
     bzero( &address, sizeof( address ) );
     address.sin_family = AF_INET;
     inet_pton( AF_INET, ip, &address.sin_addr );
     address.sin_port = htons( port );
 ​
     int sock = socket( PF_INET, SOCK_STREAM, 0 );
     assert( sock >= 0 );
 ​
     int ret = bind( sock, ( struct sockaddr* )&address, sizeof( address ) );
     assert( ret != -1 );
 ​
     ret = listen( sock, 5 );
     assert( ret != -1 );
 ​
     struct sockaddr_in client;
     socklen_t client_addrlength = sizeof( client );
     int connfd = accept( sock, ( struct sockaddr* )&client, &client_addrlength );
     if ( connfd < 0 )
     {
         printf( "errno is: %d\n", errno );
     }
     else
     {
         int pipefd[2];
         assert( ret != -1 );
         ret = pipe( pipefd );
         ret = splice( connfd, NULL, pipefd[1], NULL, 32768, SPLICE_F_MORE | SPLICE_F_MOVE ); 
         assert( ret != -1 );
         ret = splice( pipefd[0], NULL, connfd, NULL, 32768, SPLICE_F_MORE | SPLICE_F_MOVE );
         assert( ret != -1 );
         close( connfd );
     }
 ​
     close( sock );
     return 0;
 }

客户端发送和接收数据:

c 复制代码
 #include <sys/socket.h>
 #include <netinet/in.h>
 #include <arpa/inet.h>
 #include <assert.h>
 #include <stdio.h>
 #include <unistd.h>
 #include <string.h>
 #include <stdlib.h>
 ​
 int main( int argc, char* argv[] )
 {
     if( argc <= 2 )
     {
         printf( "usage: %s ip_address port_number\n", basename( argv[0] ) );
         return 1;
     }
     const char* ip = argv[1];
     int port = atoi( argv[2] );
 ​
     struct sockaddr_in server_address;
     bzero( &server_address, sizeof( server_address ) );
     server_address.sin_family = AF_INET;
     inet_pton( AF_INET, ip, &server_address.sin_addr );
     server_address.sin_port = htons( port );
 ​
     int sockfd = socket( PF_INET, SOCK_STREAM, 0 );
     assert( sockfd >= 0 );
     if ( connect( sockfd, ( struct sockaddr* )&server_address, sizeof( server_address ) ) < 0 )
     {
         printf( "connection failed\n" );
     }
     else
     {
         char buf[1024];
         memset(buf, '\0', 1024);
         const char* normal_data = "hello, world!";
         printf("send %zu bytes of data %s \n", strlen(normal_data), normal_data);
         send( sockfd, normal_data, strlen( normal_data ), 0 );
         int ret = recv(sockfd, buf, 1023, 0);
         printf("got %d bytes of data %s \n", ret, buf);
         close(sockfd);
     }
 ​
     close( sockfd );
     return 0;
 }

服务端实验截图:

客户端实验截图:

6.7 tee函数

tee函数在两个管道文件描述符之间复制数据,也是零拷贝操作。并且它不消耗数据,因此源文件描述符上的数据仍然可以用于后续的读操作。

ini 复制代码
 #include <assert.h>
 #include <stdio.h>
 #include <unistd.h>
 #include <errno.h>
 #include <string.h>
 #include <fcntl.h>
 ​
 int main( int argc, char* argv[] )
 {
     if ( argc != 2 )
     {
         printf( "usage: %s <file>\n", argv[0] );
         return 1;
     }
     int filefd = open( argv[1], O_CREAT | O_WRONLY | O_TRUNC, 0666 );
     assert( filefd > 0 );
 ​
     int pipefd_stdout[2];
     int ret = pipe( pipefd_stdout );
     assert( ret != -1 );
 ​
     int pipefd_file[2];
     ret = pipe( pipefd_file );
     assert( ret != -1 );
 ​
     /*将标准输入内容输入管道pipefd_stdout*/
     ret = splice( STDIN_FILENO, NULL, pipefd_stdout[1], NULL, 32768, SPLICE_F_MORE | SPLICE_F_MOVE );
     assert( ret != -1 );
     /*将pipefd_stdout的内容拷贝到pipefd_file中*/
     ret = tee( pipefd_stdout[0], pipefd_file[1], 32768, SPLICE_F_NONBLOCK ); 
     assert( ret != -1 );
     /*将pipefd_file中的内容输出到文件描述符filefd中*/
     ret = splice( pipefd_file[0], NULL, filefd, NULL, 32768, SPLICE_F_MORE | SPLICE_F_MOVE );
     assert( ret != -1 );
     /*将pipefd_stdout中的内容输出到标准输出*/
     ret = splice( pipefd_stdout[0], NULL, STDOUT_FILENO, NULL, 32768, SPLICE_F_MORE | SPLICE_F_MOVE );
     assert( ret != -1 );
 ​
     close( filefd );
         close( pipefd_stdout[0] );
         close( pipefd_stdout[1] );
         close( pipefd_file[0] );
         close( pipefd_file[1] );
     return 0;
 }

实验结果截图:

6.8 fcntl函数

该函数提供了对文件描述符的各种控制操作。

arduino 复制代码
 #include <fcntl.h>
 int fcntl(int fd, int cmd, ...);
  • fd参数为被操作的文件描述符;
  • cmd参数指定执行何种类型的操作;
  • 根据操作类型不同,函数可能需要第三个可选参数arg

在网络编程中,fcntl函数通常用来将一个文件描述符设置为非阻塞的,代码清单如下:

perl 复制代码
 int setnonblocking(int fd) {
     int old_option = fcntl(fd, F_GETFL);         /*获取文件描述符旧的状态标志*/
     int new_option = old_option | O_NONBLOCK;    /*设置非阻塞标志*/
     fcntl(fd, F_SETFL, new_option);
     return old_option;                           /*返回文件描述符旧的状态标志*/
 }

7. Linux服务器程序规范

7.1 Linux系统日志

7.2 用户信息

7.2.1 UID、EUID、GID和EGID

某些进程在运行的时候,它可能需要一些特权操作。比如大部分服务器必须以root身份启动,但不能以root身份运行。

arduino 复制代码
 #include <sys/types.h>
 #include <unistd.h>
 ​
 uid_t getuid()                    /*获取真实用户ID*/
 uid_t geteuid()                   /*获取有效用户ID*/
 gid_t getgid()                    /*获取真实组ID*/
 gid_t getegid()                   /*获取有效组ID*/
 int setuid(uid_t uid)             /*设置真实用户ID*/
 int seteuid(uid_t uid)            /*设置有效用户ID*/
 int setgid(gid_t gid)             /*设置真实组ID*/
 int setegid(gid_t gid)            /*设置有效组ID*/

一个进程拥有两个用户ID:UID和EUID 。UID就是进程的启动用户,而EUID是进程的运行权限用户。它使得运行程序的用户拥有该程序的有效用户的权限。

下列程序可以测试进程的UID和和EUID的区别。

arduino 复制代码
 #include <unistd.h>
 #include <stdio.h>
 ​
 int main()
 {
     uid_t uid = getuid();
     uid_t euid = geteuid();
     printf( "userid is %d, effective userid is: %d\n", uid, euid );
     return 0;
 }

实验结果:

8.1 服务器模型------CS模型

绑定完 IP 地址和端口后,就可以调用 listen() 函数进行监听,此时对应 TCP 状态图中的 listen,如果我们要判定服务器中一个网络程序有没有启动,可以通过 netstat 命令查看对应的端口号是否有被监听。

在 TCP 连接的过程中,服务器的内核实际上为每个 Socket 维护了两个队列:

  • 一个是「还没完全建立」连接的队列,称为 TCP 半连接队列 ,这个队列都是没有完成三次握手的连接,此时服务端处于 syn_rcvd 的状态;
  • 一个是「已经建立」连接的队列,称为 TCP 全连接队列 ,这个队列都是完成了三次握手的连接,此时服务端处于 established 状态;

8.2 服务器编程框架

模块 功能描述
I/O处理单元 处理用户连接,读写网络数据
逻辑单元 业务进程或线程
网络存储单元 本地数据库、文件或缓存
请求队列 各模块之间的通信方式

8.3 I/O模型

阻塞与非阻塞 :这两个概念能应用于所有文件描述符,而不仅仅是socket。我们称阻塞的文件描述符为阻塞I/O,非阻塞的文件描述符为非阻塞I/O。针对阻塞I/O执行的系统调用可能因为无法立即完成而被操作系统挂起,直到等待的时间发生为止。比如accept、connect、send、recv都有可能发生阻塞。 针对非阻塞I/O的执行的系统调用总是立即返回,而不管事件是否已经发生。如果事件没有立即发生,这些系统调用就返回-1,和出错的情况一样。此时我们必须通过errno来区分这两种情况。

显然,我们需要在事件发生的情况下操作非阻塞I/O才能提高效率。因此非阻塞式I/O通常要与其他的I/O通知机制一起使用,比如I/O复用和SIGIO信号。

通俗来说,非阻塞式I/O会立即返回结果。比如非阻塞式read,如果有数据就返回数据,没有数据就返回一个错误。此时我们就需要轮询来获取数据。这样效率很低,因为依然占用了CPU时间来等待数据准备好。

于是,我们需要引入I/O多路复用 ,这里的复用是指多个I/O事件复用一个线程。如果每个socket都创建一个线程来处理的话,线程数量过度,CPU大部分时间都在切换上下文,利用率低。而通过多路复用,即一个线程来检查多个文件描述符。比如select和poll函数,传入多个文件描述符,如果有一个文件描述符就绪则返回,否则阻塞直到超时。得到就绪状态之后进行真正的操作可以在同一个线程里执行,也可以启动线程执行(比如线程池)。

需要注意的是:select 实现多路复用的方式是,将已连接的 Socket 都放到一个 文件描述符集合 ,然后调用 select 函数将文件描述符集合 拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过 遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写, 接着再把整个文件描述符集合 拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。

所以,对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合 ,一次是在内核态里,一个次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。

I/O复用函数 区别
select 使用固定长度的BitsMap,表示文件描述符集合
poll 使用动态数组,以链表形式组织
epoll 使用红黑树来储存跟踪文件描述符

理论上来说,阻塞I/O、I/O复用和信号驱动I/O都是同步I/O模型

同步I/O模型与异步I/O模型

同步I/O模型 异步I/O模型
同步I/O模型要求用户代码自行执行I/O操作(将数据从内核缓冲区读入用户缓冲区,或将数据从用户缓冲区读入内核缓冲区) 异步I/O机制则由内核来执行I/O操作(数据在内核缓冲区和用户缓冲区之间的移动式内核在"后台"完成的)
同步I/O向应用程序通知的是I/O就绪事件 异步I/O向应用程序通知的是I/O完成事件

8.4 两种高效的事件处理模型

8.5.1 半同步/半异步模式

此处的同步/异步和I/O模型中的同步/异步是完全不同的概念。在I/O模型中,"同步"和"异步"的区别是内核向应用进程通知的是何种I/O事件(是就绪事件还是完成事件),以及该由谁来完成I/O读写(是应用程序和内核)

在并发模式中,"同步"指的是程序完全按照代码序列的顺序执行;"异步"指的是程序的执行需要由系统事件来驱动。常见的系统事件包括中断、信号等。

这里的同步/异步其实和阻塞/非阻塞是相对应的。比如对于read系统调用,同步方式运行必须等待数据准备完成;而异步方式运行则不关心调用是否完成直接执行下一行代码。那么异步调用下调用方怎么知道调用函数是否执行完成呢?分为两种情况:

  1. 调用方不关心执行结果
  2. 调用方需要知道执行结果

第一种情况比较简单,第二种情况则需要一种通知机制,也就是说当任务执行完成后发送信号通知调用方任务完成。

相关推荐
姜学迁1 小时前
Rust-枚举
开发语言·后端·rust
爱学习的小健1 小时前
MQTT--Java整合EMQX
后端
北极小狐2 小时前
Java vs JavaScript:类型系统的艺术 - 从 Object 到 any,从静态到动态
后端
【D'accumulation】2 小时前
令牌主动失效机制范例(利用redis)注释分析
java·spring boot·redis·后端
2401_854391082 小时前
高效开发:SpringBoot网上租赁系统实现细节
java·spring boot·后端
Cikiss2 小时前
微服务实战——SpringCache 整合 Redis
java·redis·后端·微服务
Cikiss2 小时前
微服务实战——平台属性
java·数据库·后端·微服务
OEC小胖胖3 小时前
Spring Boot + MyBatis 项目中常用注解详解(万字长篇解读)
java·spring boot·后端·spring·mybatis·web
2401_857617623 小时前
SpringBoot校园资料平台:开发与部署指南
java·spring boot·后端
计算机学姐3 小时前
基于SpringBoot+Vue的在线投票系统
java·vue.js·spring boot·后端·学习·intellij-idea·mybatis