首页 > > 详细

data程序辅导 、讲解 c/c++语言编程

A4: Audio Streaming
1/16
A4: Audio Streaming
Due Sunday by 11:59p.m.
Points 200
Available after Mar 13 at 12a.m.
Last updated Apr. 2
WARNING: Please define any additional macros or functions using libas.h (.c), we may need to
override your particular as_*.h files for the automated testing, thank you!
Table of Contents
1. Introduction
1.1. Background
2. Basic Outline
2.1. Setup
3. Implementation
3.1. Passive Application Architecture
3.2. Active Application Architecture
3.3. Request Format
3.4. Dynamic buffering
4. Tasks
4.1 Server Tasks
4.2 Client Tasks
5. Grading scheme
6. Software you’ll probably need and some tips about how to proceed
6.1. Debugging the server or the client separately
6.2. Ensuring files received by the client match the server
6.3. Playing audio data (and how to install mpv)
6.3.1. Listening in the labs
6.3.2. Using real files
7. Updates and clarifications (check back here as needed)
8. Concluding remarks
1. Introduction
In this assignment, we will endeavor to develop a system of interconnected applications in one of the
most often-used and fundamental configurations: a server and accompanying client(s) connected
through sockets. This particular server will serve a small music (media file) library such that compatible
clients will be able stream the files on the server’s library, save the files locally, or both.
A4: Audio Streaming
2/16
In essence, streaming means that files can be requested from the server, and they are sent
progressively and intermittently in chunks. Your implementation will be able to support many different
media file types, including wav , mp3 , ogg , flac , etc.. The clients will use a dynamic buffer to receive
as many packets as are available, as they become available, before redirecting them to their appropriate
destination.
1.1. Background
For this assignment, multiple clients will be able to simultaneously stream available audio files from the
server. The client and the server will be using sockets to communicate with each other while transferring
text or binary data.
Roughly speaking, the server is responsible for 2 things:
1. Scanning and maintaining a “music library” – which for this assignment, will simply be maintaining a
list of files with correct extensions from a specified library directory (default: library/ ). See Section
6.3.2 to download a sample library that you may use to complete the assignment.
2. Listen for clients and handle clients in their own child process. The server will wait and retrieve the
exit status of all children under normal operation. Part of handling, is receiving and responding to
requests.
The client, on the other hand, will be responsible for:
1. Making requests and receiving responses
2. Managing an accompanying media player process named mpv (https://mpv.io/) , which is a free,
open source, and cross-platform media player. For installation information, please see Section 6.3
(https://q.utoronto.ca/#org2f62d44) .
2. Basic Outline
The majority of the design is provided to you in the starter code, and you are tasked with completing:
1. Server side processing of received requests and associated responses, for:
1. Listing all files
2. Streaming particular files
3. Setting up the server to listen on the specified port number
2. Client-side sending of requests and receiving responses, for:
1. Creating requests to list or stream files
2. Receiving files with dynamic buffering
3. Storing files as they are received
4. (Separately from local storing) Piping files to an accompanying audio (media) player process
The challenge in this assignment is ensuring that no bytes are lost, all bytes are transferred and received
in the right order, and this is done without memory errors, as well as feeling comfortable working with an
A4: Audio Streaming
3/16
already developed, and somewhat larger codebase. Throughout the starter code there are TODOs that
must be completed. The functions and structs defined in the three header files as_client.h as_server.h
and libas.h should not change, but you may add to these headers as you need, and change the values
of different macros.
2.1. Setup
Follow the instructions carefully, so that we receive your work correctly.
Your first step should be to log into MarkUs (https://markus.teach.cs.toronto.edu/2024-01/courses/16/) and
navigate to the A4 assignment. Then, at the bottom of the assignment page, click on the button labelled
"Add Starter Files to Repository".
Then, open a terminal on your computer and do a git pull in your existing CSC209 MarkUs repository.
Inside the repository, you should see a new folder name A4 containing the starter files for this
assignment.
Starter Files
as_server.c: contains code that implements the audio streaming server. You should modify this
file to complete the missing server features.
as_server.h: the header file for functions and structs that are defined and used in as_server.c. Do
not change.
as_client.c: contains code that implements the audio streaming client. You should modify this file
to complete the missing client features.
as_client.h: the header file for functions that are defined in as_client.c. Do not change.
libas.c: contains common code that are used by both the server and client. Do not change.
libas.h: contains the function prototypes and structs that are used by both the server and client. Do
not change.
stream_debugger.c: a simple debugging program that can help you with debugging your streaming
service. Do not change.
Makefile: a script that is used to simplify compilation of your program. Do not change.
Compilation
You should use the following command in your A4 folder to compile your source code:
make
If your submission does not compile, you will receive a mark of 0. If the compilation displays warning
messages, you will lose 10% of the total mark. It is your responsibility to ensure that your submission
works on teach.cs.
A4: Audio Streaming
4/16
Note that the Makefile will generate a file named port.mk which randomly assigns a default port number
for you. This is to minimize the chance where you and another classmate accidentally use the same port
number on teach.cs and negatively affect each other's work, e.g., if someone else is using your port
number, you won't be able to start the server.
After compilation, you can simply run the server as follows:
./as_server
Once the server is up and running, you can run the client to connect to the server:
./as_client
Note: while the starter code compiles, you will not be able to start the server until you complete the
following:
1. See Section 6.3.2 to set up your media library. Note: your server code does not need to handle an
empty library, so you need to make sure your library has at least one valid audio file.
2. The initialize_server_socket function.
If at any point, you are tired of the excessive debug output, you may re-compile your code in release
mode.
make clean
make release
Remember that debugger symbols are not available in release mode, so you will not be able to gdb your
program. Note that you will also need to run make clean before running make again to go back to debug
mode.
3. Implementation
A passive process is on a machine known as the “server”, somewhere on the network. This process is
bound to a particular port accompanying the network address for the machine.
3.1. Passive Application Architecture
The passive process’s main/original process sets up a socket for incoming connections and uses
select to manage whether clients are connecting, or if it locally receives 'q' on stdin , implying the
application should shut down. This is managed through the run_server function, which also re-scans the
library after a certain amount of iterations of it’s main loop. It also passively checks for the termination of
any existing child processes.
Each new connection is managed in a child process by (immediately calling) handle_client , which
receives the current layout of the library for the duration of the connection. It is responsible for receiving
A4: Audio Streaming
5/16
and responding to requests from the client.
Any requests to stream a file are written to the connected client in chunks of a maximum size:
STREAM_CHUNK_SIZE .
3.2. Active Application Architecture
The “client” application is a single active process anywhere on the same network as the “server”,
including the same machine. It features a small shell; see the client_shell function, featuring the
following commands:
1. list – to list the available files in the server’s library
2. get – to save a particular file to client’s library directory
3. stream – to stream a particular file without saving it
4. stream+ – to stream a particular file and save it to the client’s library
5. help – to list the available commands
6. quit – to quit the application
These boil down to two types of requests:
1. listing the available files.
2. streaming a particular file.
The client will receive the library list and then be able to request a file to be streamed by it’s library index.
Commands 2-4 ultimately leverage stream_and_get_request with different arguments. This function will
also use select to accomplish interleaved and non-blocking: reading from the socket connection, writing
to the file-system and playing audio through the audio player. See Section 6.3, for more information on
how to stream the received files. The client must successfully wait for this process to terminate as
appropriate.
3.3. Request Format
List Request
To send a list request to the server, the client would send the following text data:
"LIST\r\n"
The server will respond with a list of files in the format index:filename, one file per line, in text format.
The list is returned as a single string with each file starting with an integer corresponding to it's index in
the library, in reverse order. A colon separates the index and the file's name. Each entry is separated by
a network newline "\r\n" (2 chars). For example, if the library contains the files "file1.wav",
"artist/file2.wav", and "artist/album/file3.wav" in this order, the data sent to the client will be the following
characters:
A4: Audio Streaming
6/16
For example:
"2:artist/album/file3.wav\r\n1:artist/file2.wav\r\n0:file1.wav\r\n"
Stream Request
To send a stream request to the server, the client would send a mixture of text and binary data. The text
data is exactly the following:
"STREAM\r\n"
and a 4-byte binary data should follow, that represents the requested file index. For example, to stream
file index 3, a full packet will look like this in hex:
5354 5245 414d 0d0a 0000 0003
S T R E A M \r\n |-- 3* -|
*3 is in 32-bit big endian byte order.
Note that the file index needs to be in network byte order, i.e., big endian byte order.
If the file index exists, the server will respond by first sending the size of the file in network byte order,
then sending a series of data chunks in the following format:
[-- size of file --][ ----- data chunk 1 -----][ ----- data chunk 2 -----][ -- last data chunk -- ]
<---- 4 bytes -----><------ 1024 bytes -------><------ 1024 bytes -------><-- up to 1024 bytes --->
Note that the last chunk of data is can be less than 1024 bytes. For example, suppose the you send a
text file with the content "hello world" using this protocol, the server response will look like this in hex:
0000 000b 6865 6c6c 6f20 776f 726c 64
|- 11 --| h e l l o _ w o r l d
Hint: 0x0000000b is the decimal 11 in big endian, which is the size of the file.
3.4. Dynamic buffering (client-side)
First thing that you’ll need to take a look at is the realloc function documentation. Have a quick read of
man realloc .
The intended dynamic buffer is as follows, and shall receive full marks if successfully accomplished.
Marks for alternative solutions will be subject to TA discretion. The simplest of which is a fixed size array
on the stack (or the heap*), and this will receive 1/3 of the buffering grade, even if everything else is
perfect. The keyword here is dynamic, so the realloc function will probably be your friend. Any solution
that does not implement dynamically changing buffer sizes on the client can achieve a maximum grade,
of at most, 90% on this assignment overall (only a third of the 15% awarded for dynamic buffering,
because technically any non-static buffer is kind of dynamic).
A4: Audio Streaming
7/16
The intended/aspirational implementation features two buffers: one to read directly from the socket --
which will be of a fixed size ( NETWORK_PRE_DYNAMIC_BUFF_SIZE ) allowing at most this many bytes to be read
from the socket each time select indicates it is ready. Then, a second and dynamic buffer to hold all the
bytes that have been received by the client, but not yet been written to (both) the audio process pipe and
library file (as needed). This includes what was read into the first buffer, and any bytes that you have no
managed to write to the other targets yet. If the byte hasn’t been written out to each fd (or one of them if
not doing stream+) yet, it should be held in this second buffer that dynamically grows, so that the first
can read from the network again. All bytes from the first fixed buffer move through the second dynamic
one.
The first bytes of the dynamic buffer shall contain the next bytes that need to be written. So, upon
reading the first, say, four bytes from the server, in your first fixed buffer, you then make your dynamic
buffer 4 bytes in size and move those bytes into the second buffer in the order they were received.
As more bytes are received from the network, your second buffer should expand to contain the additional
bytes. “Simultaneously”, you should be writing to one or both of the audio and file destinations, starting
from the beginning of the buffer. It is sufficient to simply try to write the entire contents of the buffer to
either stream when they are ready for writing. But, this doesn't guarantee that they well all be, in fact,
written. Use the return value of write to get the number of bytes actually written, to update which byte
each stream (audio or file) will need next for an unbroken stream of data.
Once you’ve finished writing data out of the second buffer, you can look to see how many of the first
bytes in the buffer are no longer needed, as in, ones that have already been written to both (for stream+ ,
otherwise it is either) the audio and file streams. You should then resize your buffer so that it no longer is
taking up memory for those bytes. So if your dynamic buffer had 100 bytes in it, and you wrote 20 bytes
to the audio process and 15 bytes to the local library file, you can remove 15 bytes from the buffer,
making a new buffer of 85 bytes in length. Somehow, the audio process must still start from the correct
byte offset in this example, so it will be "ahead" of the local file.
Note that the server should be sending the file’s bytes starting with the first byte in the file, and
ending with the last, even though network-byte-order is big-endian. Endianess pertains to
contiguous data-types, not files or streams of bytes.
How do you accomplish this “simultaneously”? Well we will use select , and read from the network if
select indicates that the connected socket has bytes to be read, and write to either file descriptor if it
indicates that either file descriptor can be written to. Then use select again in a loop.
*Allocated once and free'd once. Allocating and freeing multiple times for one file transfer/stream will be
considered dynamic, but only the described solution is guaranteed full marks. Any other dynamic
solutions will receive at least 50% (7.5/15) and up to 100% (15/15) for this portion of the work.
4. Tasks
A4: Audio Streaming
8/16
For each function listed below, there are descriptions of the expected functionality above the function
signature in the header files, or above the function body for helper functions. You should consult them for
more information on how to implement each function. Hint: there are some helper functions that have
already been implemented for you to simplify some tasks. Please take some time to understand how
they work.
For each function, there is a list of errors that you must handle. If any of the listed error occurred,
print a relevant error message (not tested but the TA will look at it) then return -1 (tested). If you
have temporarily-allocated resources (e.g., heap memory, file descriptors), you need to free/close
them before returning.
General set of errors you must always handle for all functions:
Reading or writing to socket failed.
Memory allocation, e.g., malloc , failed.
4.1. Server Tasks
For the server, you are responsible for implementing the following functions:
initialize_server_socket
int initialize_server_socket(int port)
The purpose of this function is to initialize a server socket up to the point of listening for connections. You
should use the existing helper functions to complete this task. On success, return the file descriptor used
for listening to connections.
Hints:
You should be calling init_server_addr and set_up_server_socket to complete this function. Take
some time to learn what they do.
For set_up_server_socket , use MAX_PENDING for the num_queue argument.
list_request_response
int list_request_response(const ClientSocket * client, Library *library)
The purpose of this function is to respond to the client request for listing the files in your media library.
See Section 3.3 for more information. You should take a look at the Library struct definition in libas.h ,
and understand the purpose and format of each member variable.
Advices:
Take a look at _free_library in libas.c and make sure you understand the data structure. Then,
take a look at scan_library in as_server.c and make sure you understand how the object is
initialized.
Use the write_precisely function to simplify writing to socket.
You may assume filenames do not include newline characters.
A4: Audio Streaming
9/16
You may assume the media library is never empty.
Hence, you need to ensure there is at least one audio file in your library folder, otherwise the
server will not send any response and the client will hang.
stream_request_response
int stream_request_response(const ClientSocket * client, Library *library, uint8_t
*post_req, int num_pr_bytes)
The purpose of this function is to respond to the client request for streaming a particular file.
The file is streamed in chunks of maximum STREAM_CHUNK_SIZE bytes. The client will be able to request a
specific file by its index in the library. Please see Section 3.3 for more information.
It is important to understand the post_req and num_pr_bytes parameters, because there is no guarantee
whether the file_index data from the client is included in the request packet, or would come afterwards.
This could happen:
# only 1 byte out of the 4 bytes for file_index came with the stream request
"STREAM\r\n" + 0x00
The post_req parameter is a pointer to the end of the stream request data (after the \r\n ), and
num_pr_bytes tells you how many bytes of data are available in post_req . In the above example,
num_pr_bytes would be 1.
To successfully complete this function, you must look at num_pr_bytes and determine how many more
bytes you need to read from client_socket . If num_pr_bytes is 4, then you don't need read anymore.
After you have the 4-bytes of data, you need to convert it from network byte order back to native byte
order so that the value is correct. Hint: use ntohl .
Errror Handling:
If any of the following error occurred, print a relevant error message (not tested but the TA will look at it)
then return -1 (tested). If you have temporarily-allocated heap memory, you need to free it before
returning.
The file index does not correspond to a valid media file, i.e., index out of range. (unfortunately, this
will cause the client to hang, but it indicates that you sent an invalid request).
Requested file does not exist (this can happen if someone deleted the file and your server hasn't
realized it yet).
num_pr_bytes is greater than 4 (think time: why is this an error?)
4.2. Client Tasks
For the client, you are responsible for implementing the following functions:
get_library_dir_permission
A4: Audio Streaming
10/16
int get_library_dir_permission(const char *library_dir, mode_t * perpt)
Given the name of the library folder library_dir , return its permission through the output parameter
perpt . If library_dir does not exist, create it with the permission 0700 . On success, return 0. You may
assume perpt is never NULL .
Hint: you will need to use the stat system call and mkdir system system call.
Error Handling:
Any of the system calls you made failed.
Except in the case where the first time you call stat , the library folder does not exist.
list_request
int list_request(int sockfd, Library *library)
Send a list request (please see Section 3.3) to the server and store the list of available audio files in the
library object. Upon success, print the list of audio files by their file_index in ascending order, and return
the number of files available in the library, e.g.:
0: flac/teenage-ant-marching-band.flac
1: m4a/atmospheric-eerie-song.m4a
2: m4a/night-sessions.m4a
3: mp3/dark-evil-piano.mp3
4: mp3/encryption.mp3
5: mp3/im-your-dj.mp3
6: ogg/permsound.ogg
7: ogg/rainbow-disco-bears.ogg
8: wav/ac-guitar.wav
9: wav/afx-study.wav
10: wav/cha-cha-ender.wav
11: wav/magic-harp.wav
12: wav/wedidit.wav
13: wav/weird-synth.wav
Use the following format specifier to print each line to standard output: "%d: %s\n" .
Advices:
Take a look at _free_library in libas.c and make sure you understand what is heap-allocated and
what isn't. Then, take a look at scan_library in as_server.c and make sure you understand how the
object is initialized. You can re-use some of the logic to implement this function, e.g., instead of
reading from the file system, you are reading and parsing the response packet from the server.
Most of the response packet parsing is done for you in the function get_next_filename . Learn how to
use the function.
Error Handling:
get_next_filename failed.
start_audio_process
A4: Audio Streaming
11/16
int start_audio_player_process(int *audio_out_fd)
Create the audio player process, and redirect standard input to it. Use the AUDIO_PLAYER and
AUDIO_PLAYER_ARGS macro constants for setting up arguments to exec .
Notes:
Because the audio player process sometimes takes a while to boot up, you should sleep for
AUDIO_PLAYER_BOOT_DELAY seconds.
If you are using WSL, the executable name will be "mpv.exe" instead of just "mpv". You can deal with
this discrepancy by temporarily changing the macro AUDIO_PLAYER (defined in as_client.h ) from
"mpv" to "mpv.exe". However, do not check this change in.
Error Handling:
Any of the system calls you made in the parent process failed.
send_and_process_stream_request
int send_and_process_stream_request(int sockfd, uint32_t file_index, int audio_out_fd, int
file_dest_fd)
This is the meat of this assignment. The purpose of this function is to simultaneously receive data from
the server, save data to a file, and steam data to the audio process. You are required to use select to
accomplish this feat. Failure to use select will result in 0 for this function.
Please see Section 3.3 for information on how to send a stream request packet, and what the server
response looks like.
Before returning from this function (failure or success), you must close the two file descriptors,
audio_out_fd and int file_dest_fd .
Advices:
Because an audio file can be extremely large (e.g., 4GB), it would be infeasible to preallocate a
buffer for the entire incoming file. Therefore, you need create a dynamic buffer for receiving data and
writing data to one or both file descriptors. Please see Section 3.4 for more information on how to
implement this. However, you may create the entire buffer in memory just as a intermediate step to
get things working, before attempting dynamic buffering.
Use the timeout macro constants for setting up select 's timeout argument: SELECT_TIMEOUT_SEC and
SELECT_TIMEOUT_USEC .
Error Handling:
Both file descriptors, audio_out_fd and int file_dest_fd , are invalid, i.e., -1.
If you receive more data than what the specified file size is, print an error message (not auto-tested,
but we will look at your code) and disregard the extra bytes (do not return -1).
A4: Audio Streaming
12/16
5. Grading scheme
The following is a breakdown of the grading scheme for this assignment:
10% style
30% memory management (valgrind testing)
15% server/client each
20% server functionality
25% client functionality (independent of dynamic buffer)
15% Implementation (TA inspection) of client-side buffering
Max 5% if not dynamic
6. Software you’ll probably need and some tips about
how to proceed
6.1. Debugging the server or the client separately
The basic utility known as netcat or nc (see man nc ) is ideal for sending or receiving raw text
(amongst other things with some work). You can use nc to setup a dummy server that you can view
requests with, and type back responses to your client (locally on your machine, bound to port 3456) like
this:
nc -v -C -l -p 3456
This sets up verbose printing, network newlines to be sent everytime you hit enter ( \r\n not just \n ,
notice that our requests make use of this), and listening – essentially a passive network process.
You can configure an active netcat process as follows, more commonly referred to as a client to connect
to this server with:
nc -v -C localhost 3456
Try running both, in seperate terminals, side-by-side and start entering data into either side. Notice how it
pops up in the other terminal. Once you get your client or server implementations working, you can swap
out one of these netcat programs for your solution, e.g. connect your as_client to an nc server.
Note, you can also run the server on teach.cs, and then connect by replacing localhost with
teach.cs.utoronto.ca, then you can run the server after ssh'ing into teach.cs, and the client locally or on
teach.cs, and connect to the server that is running on teach.cs!
I recommend implementing the server first, since a lot of heavy lifting has already been done for you in
this application. Then you can try running something like this before implementing the client, to see that it
is possible:
A4: Audio Streaming
13/16
echo -e "STREAM\r\n\x00\x00\x00\x02" | nc -C | dd bs=1 skip=4 | tee filenam
e.wav | mpv -
This should allow you to retrieve the file with index 2 (notice the last of 4 hex bytes is 0x02 in the echo
message) from the server, skip the first 4 bytes of response (the file’s size as an unsigned int sent by the
server), save it to filename.wav and play it through mpv . This solution may not produce a perfect
filename.wav depending on how you kill the line of commands. But, in effect, the client’s most difficult
task can be accomplished through this line of shell script with fairly standard programs. Nonetheless we
are going to do the dynamic buffering as an exercise and a demonstration of how one might buffer
media. The portion of \x00\x00\x00\0x02 above, indicates 4 hexadecimal numbers. Network byte order
is big-endian, so the above is the number two, expressed as a 32-bit big-endian integer.
6.2. Ensuring files received by the client match the server
The command line utility diff will report when two files differ, so use this to check that the received file
is the same as the one on the server. If they are identical, it will print nothing! Alternatively cmp also
provides good output. These are sufficient tools, but are hard to use for binary files. Remember that you
can send and recieve (though not actually play anything through mpv) any file data you like, as long as
the extension is compatible (see libas.h). This means you can debug with files containing only ASCII text
in them, and use diff tools like diff or cmp, or diff within your IDE to see where things went wrong.
While you are working on receiving files, changing the size of buffers, or more specifically, the number of
bytes you read or write at once, will help debug the process of sending and receiving data. Stepping
through your code with smaller buffers and test files will really help.
As a starting point, it may be helpful to create a dummy file in text format to test that file transfer is
working. You will need to rename the file to have the extension .wav , otherwise the server will not pick it
up. You should use the get command on the client to attempt transferring the file.
6.3. Playing audio data
To actually hear the output of the audio files (video files were not tested, I suspect you might need to
enlarge some of the buffers for performance reasons, but it should probably work too, see
SUPPORTED_FILE_EXTS and let me know on Piazza if you looked into it), you’ll be piping data into software
that can decode any compression used to store the file and then relay the correct data to the audio driver
system. The driver system is not consistent between Linux, Mac and Windows, so we will use the mpv
(https://mpv.io/) project to do all this work in a cross-platform way, and allow us to experiment using
files with arbitrary compressions/encodings/extensions (not all of which are compatible with streaming,
but .mp3 and .mp4 containers should represent stuff that will work if you get this far). Note that you can
use mpv's controls when you've exec'd the process (try using the arrow keys while music is playing).
tldr: A good first step overall is installing mpv . You can use mpv to play media files directly through
stdin by giving it the argument - , as in mpv -
A4: Audio Streaming
14/16
On the teach.cs machines, the basic command aplay is sufficient to process .wav files, and is a good
starting place if you need to limit output, but you should have access to mpv on the teach.cs machines
as well, and this will give you some consistency across platforms. Install mpv on your local machine,
make sure it is accessible “via command line” (i.e. it’s on your PATH ) and then switch to the mpv -specific
macros in as_client.h .
Most linux package managers provide mpv e.g. sudo apt install mpv but note if you're using the
windows linux subsystem (WSL), you're probably better off installing mpv outside of the linux subsystem
and linking to the executable (the application called mpv in the installation) on a correct path to be
accessible to a bash terminal in WSL. It will take some digging to figure out. This github repo
(https://github.com/jnozsc/mpv-nightly-build) hosts mac builds, and is linked to from mpv's main
installation page. You'll still need to be able to access mpv from your shell's path.
While this will be great when everything is working, we’ve also included the code for something called
stream_debugger , which will help you debug what you are sending to the external process and when.
6.3.1. Listening in the labs
Please avoid disturbing others with playing any audio out of the speakers in the lab. There are
headphone jacks on the lab machines, on the front of the desktop box (top jack is only microphone,
bottom is headphones with optionally connected mic, use this with standard headphones):
As
联系我们
  • QQ:99515681
  • 邮箱:99515681@qq.com
  • 工作时间:8:00-21:00
  • 微信:codinghelp
热点标签

联系我们 - QQ: 99515681 微信:codinghelp
程序辅导网!