python之寫了3個週末及幾個晚上的ftp終於完成了
本程式已上傳githup,點我
作業:開發一個支援多使用者線上的FTP程式
要求:
使用者加密認證
允許同時多使用者登入
每個使用者有自己的家目錄 ,且只能訪問自己的家目錄
對使用者進行磁碟配額,每個使用者的可用空間不同
允許使用者在ftp server的隨意切換目錄
允許使用者檢視當前目錄下檔案
允許上傳和下載檔案,保證檔案一致性
檔案傳輸過程中顯示進度條
支援檔案的斷點續傳
目錄結構
ftp/
|
|—–bin/
| |—–ftpClient.py # 客戶端的入口
| |—–ftpServer.py # 服務端的入口
|
|—-conf/
| |—–setting.py # 一些配置資訊
|
| —–core/
| |—–auth.py # 使用者認證模組
| |—–socket_client.py # socket客戶端主程式
| |—–socket_server.py # socket服務端主程式
| |—–upload.py # 傳輸資料模組
|
|——database/ # 已經存在的使用者資料庫檔案
|—–kai.db
|—–xiaolv.db
|
|—–download/ # 使用者下載檔案的預設地址資料夾
|
|—–home/ # 已經建立的使用者的家目錄
| |—–kai/
| | —–xiaolv/
|
|—–README.py
實現過程(主要是下載上傳檔案過程)
舉例: 客戶端向服務端下載檔案
1.判斷客戶端是否存在已下載的檔案
|
|——- 是 —–》 客戶端確認是否需要續傳 ——》 是 ——–》 服務端開始找資源—》 找到資源 —-》 開始續傳
| | |—–》未找到資源—-》 退出到下載介面
| |
| | —–》 否 —-》 退出到下載介面
|
|——- 否 —–》服務端開始找資源—》 找到資源 —-》 開始續傳
|—-》 未找到資源—-》退出到下載介面
功能實現:
1.實現使用者加密認證登入,目前暫不支援在互動介面新增使用者,需要自己手動在database/目錄下建立使用者的資料庫檔案及新增相關的使用者資訊,並在home/目錄下新增相關的目錄
2.支援檔案上傳下載,支援斷點續傳,目前只支援檔案傳輸,不支援目錄的上傳下載
3.支援目錄切換及顯示當前賬戶下的目錄
http狀態碼:
200: 客戶請求成功
202:建立的檔案已經存在
400:使用者名稱不存在,使用者認證失敗
403.11:密碼錯誤
401: 命令不存在
402 :檔案不存在
413:磁碟空間不夠用
000: 系統互動碼
—————-我是分割線————–
接下來直接上程式碼
bin/
ftp客戶端介面
import os,sys
path = os.path.dirname(os.path.abspath(__file__))
sys.path.append(path)
from core import socket_client
if __name__ == "__main__":
host,port = "localhost",9901
myClient = socket_client.MySocketClient(host,port)
myClient.start()
ftp服務端介面
import sys,os
path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.append(path)
from core import socket_server
if __name__ == "__main__":
HOST,PORT = "localhost",9901
server = socket_server.socketserver.ThreadingTCPServer((HOST,PORT),socket_server.MyTCPServer)
server.serve_forever()
conf/
setting.py
import os,sys
BASEDIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
#使用者家目錄
HOMEPATH = os.path.join(BASEDIR,"home")
#資料庫目錄
DATABASE = os.path.join(BASEDIR,"database")
#分給沒個使用者的磁碟配額
LIMITSIZE = 20480000
#使用者從服務端下載檔案的預設地址
DEFAULT_PATH = "C:\\Users\\Administrator\\Desktop\\python\\ftp\\download"
core/
auth.py
import hashlib,os,json
from conf import settings
class User_auth(object):
def auth(self,account_info):
"""
#此功能是進行使用者的登入資訊驗證,如果登入成功,那麼返回使用者對應的http狀態碼及賬戶資訊,否則只返回http狀態碼
:param account_info: 使用者的賬戶資訊:使用者名稱,密碼
:return:
"""
name = account_info.split(":")[0]
pwd = account_info.split(":")[1]
pwd = self.hash(pwd.encode()) # 將使用者名稱的密碼轉換成hash值
user_db_file = settings.DATABASE + r"\%s.db" %name # 也可以寫成 "\\%s.db" or "/%s.db"
if os.path.isfile(user_db_file): # 輸入的使用者名稱存在
with open(user_db_file) as fr:
user_db_info = json.loads(fr.read()) # or josn.load(fr)
if pwd == user_db_info['password']:
return "200",user_db_info # 確定,客戶請求成功
else:
return "403.11",None # 密碼錯誤
else:
return "400",None # 使用者名稱不存在,使用者認證失敗
def hash(self,pwd):
"""
使用者的密碼加密
:param self:
:param pwd: 使用者密碼
:return:
"""
m = hashlib.md5()
m.update(pwd)
return m.hexdigest()
socket_client.py
import sys,os,time
import socket,hashlib
from core import upload
from conf import settings
class MySocketClient(object):
def __init__(self,host,port):
self.host = host
self.port = port
self.client = socket.socket() # 建立客戶端例項
def start(self):
self.client.connect((self.host,self.port)) # 連線服務端,localhost為迴環地址,一般用於測試
while True:
"""客戶端開始輸入賬戶資訊登入"""
name = input("\033[31m 請輸入賬戶名: \033[0m").strip()
pwd = input("\033[31m 請輸入密碼: \033[0m").strip()
# name = "kai";pwd = 123456
if not name or not pwd :continue # 如果賬戶或密碼為空請重新輸入
userInfo = "%s:%s" % (name,pwd)
self.client.send(userInfo.encode()) # 向服務端傳送使用者資訊
status_code = self.client.recv(1024).decode() # 接受來自服務端的http狀態碼
if status_code == "400":
print("\033[1;32m 使用者名稱不存在,使用者認證失敗,請重新輸入 \033[0m")
continue # 使用者名稱不存在重新輸入
if status_code == "403.11":
print("\033[1;32m 密碼錯誤,請重新輸入 033[0m")
continue
if status_code == "200":
print("\033[1;32m 登入成功\033[0m")
self.interact()
def interact(self):
"""
客戶端與服務端的互動介面
:return:
"""
while True:
cmd = input("""請輸入操作:
\033[1;31m
eg:
get filename # 從服務端下載檔案
put filename # 從服務端上傳檔案
ls # 檢視當前目錄下的檔案
cd # 目錄切換
pwd # 檢視當前目錄
mkdir dirname # 建立目錄
>>> \033[0m""").strip()
cmd_action = cmd.split()[0]
if hasattr(self,cmd_action):
func = getattr(self,cmd_action)
func(cmd)
else:
print("輸入命令不存在")
def put(self,cmd):
"""
客戶端上傳檔案
:param self:
:param cmd: 使用者的操作命令 eg: put filename
:return:
"""
lst = cmd.split() # 命令分割,此次只允許一次上傳一個檔案
if len(lst) == 2:
filename = lst[1]
if os.path.isfile(filename):
self.client.send(cmd.encode()) # 向服務端傳送上傳檔案命令
status_code = self.client.recv(1024).decode()
total_size = os.stat(filename).st_size
self.client.send(str(total_size).encode())
status_code = self.client.recv(1024).decode() # 接收來自服務端的http狀態碼,判斷賬戶是否空間充足,檔案是否存在
if status_code == "202":
while True:
affirm_msg = input("建立的檔案已經存在,請確認是否需要續傳:1:續傳 2:不續傳:")
if affirm_msg == "1":
print("開始續傳")
self.client.send("000".encode()) # 傳送互動碼,避免粘包,服務端此時需要2次連續send
has_send_size = self.client.recv(1024).decode() # 已經發送給服務端的檔案大小
send_md5 = upload.Breakpoint().transfer(filename, int(has_send_size), total_size, self.client) # 進行檔案的傳輸,返回此次傳送資料的md5
recv_md5 = self.client.recv(1024).decode() # 服務端接收到此次傳送資料的md5
# print("\nsend",send_md5)
# print("recv",recv_md5)
if send_md5 == recv_md5 :
print("傳送資料成功")
break
else:
print("傳送資料不成功,請重新發送")
break
elif affirm_msg == "2":
print("不續傳")
break
else:
print("輸入的命令不存在")
continue
elif status_code == "402":
print("檔案不存在") # 檔案不存在
send_md5 = upload.Breakpoint().transfer(filename, 0, total_size,self.client) # 進行檔案的傳輸,返回此次傳送資料的md5
recv_md5 = self.client.recv(1024).decode() # 服務端接收到此次傳送資料的md5
print("\nsend md5", send_md5)
print("recv md5",recv_md5)
if send_md5 == recv_md5 : print("傳送資料成功")
else : print("傳送資料不成功,請重新發送")
else:
print("使用者磁碟空間不夠")
else:print("傳送的檔案不存在")
else: print("401 error,命令不正確,一次只能上傳一個檔案")
def get(self,cmd):
lst = cmd.split()
if len(lst) == 2:
filename = lst[1]
fileName = filename.split("\\")[-1]
self.default_file = os.path.join(settings.DEFAULT_PATH,fileName)
if os.path.isfile(self.default_file): # 使用者下載的檔案在預設地址下是否存在
while True:
affirm_msg = input("建立的檔案已經存在,請確認是否需要續傳:1:續傳 2:不續傳:")
if affirm_msg == "1":
self.get_file(cmd)
elif affirm_msg == "2":
break
else:
print("命令不正確")
else:
self.get_file(cmd,exist="no")
else: print("401 error,命令不正確,一次只能下載一個檔案")
def get_file(self,cmd,exist="yes"):
"""
因為get方法從客戶端下載檔案,檔案存在續傳和檔案不存在直接下載寫法一樣,故提取
:param cmd:
:param exist: 判斷客戶端檔案是否存在
:return:
"""
self.client.send(cmd.encode())
status_code = self.client.recv(1024).decode()
if status_code == "206":
print("需要下載的檔案在客戶端存在,在服務端也存在,開始續傳" if exist == "yes" else "需要下載的檔案在服務端存在,在客戶端不存在,開始下載")
has_recvd_size = os.stat(self.default_file).st_size if exist == "yes" else 0
self.client.send(str(has_recvd_size).encode())
total_size = self.client.recv(1024).decode()
self.client.send("000".encode()) # 系統互動,防止粘包
recvd_size = 0
m = hashlib.md5()
with open(self.default_file, "ab") as fa:
remain_size = int(total_size) - has_recvd_size
while recvd_size != remain_size: # 迴圈接收來自服務端的檔案資料
data = self.client.recv(1024)
m.update(data)
recvd_size += len(data)
fa.write(data)
all_recvd_size = has_recvd_size + recvd_size
upload.Breakpoint().progress_bar(all_recvd_size,int(total_size))
self.client.send("000".encode()) # 粘包
send_md5 = self.client.recv(1024).decode() # 服務端傳送資料的md5
if has_recvd_size == int(total_size):
print("檔案大小一致,無需下載")
self.interact() # 如果按這種邏輯(先判斷客戶端檔案是否存在,在判斷該檔案的大小是否一致)寫,只能呼叫interact()選項才能返回
if send_md5 == m.hexdigest():
print("接收檔案成功")
else:
print("接收檔案不成功")
else:
print("需要下載的檔案不存在,無法續傳")
def mkdir(self,cmd):
self.client.send(cmd.encode())
status_code = self.client.recv(1024).decode()
if status_code == "403":
print("建立的目錄存在")
elif status_code == "401":
print("輸入的命令不存在")
def pwd(self,cmd):
self.client.send(cmd.encode())
cur_path = self.client.recv(1024).decode()
print(cur_path)
def cd(self,cmd):
"""
:param cmd: eg cd dirname / cd .. /
:return:
"""
self.client.send(cmd.encode())
status_code = self.client.recv(1024).decode()
if status_code == "402":
print("建立的目錄不存在")
elif status_code == "400":
print("命令不正確,格式 cd .. / cd dirname")
elif status_code == "403":
print("沒有上層目錄了")
else:
pass
def ls(self,cmd):
self.client.send(cmd.encode())
dirs = self.client.recv(1024).decode()
print(dirs)
if __name__ == "__main__":
host,port = "localhost",9998
myClient = MySocketClient(host,port)
myClient.start()
socket_server.py
import socketserver
import hashlib,os
from core import upload
from core import auth
from conf import settings
#socketserver
class MyTCPServer(socketserver.BaseRequestHandler):
def handle(self):
try:
while True:
self.data = self.request.recv(1024).decode() # 接受來自客戶端的賬號的登入資訊
auth_result = auth.User_auth().auth(self.data) # 進行使用者驗證
status_code = auth_result[0]
if status_code == "400" or status_code == "403.11":
self.request.send(status_code.encode()) # 使用者名稱不存在或密碼錯誤
continue
self.request.send(status_code.encode()) # 使用者認證成功
self.user_db_info = auth_result[1] # 獲取使用者的資料庫資訊
self.home_path = self.user_db_info['homepath']
self.current_path = self.user_db_info['homepath'] # 登陸成功後立即定義一個“當前路徑”變數,供後面建立目錄,切換目錄使用
while True:
self.cmd = self.request.recv(1024).decode() # 接收客戶端的上傳或下載命令
self.cmd_action = self.cmd.split()[0]
if hasattr(self,self.cmd_action):
func = getattr(self,self.cmd_action)
func(self.cmd)
else:
self.request.send("401".encode()) # 命令不存在
except ConnectionResetError as e:
self.request.close()
print(e)
"""服務端可以提供的命令"""
def put(self,cmd):
"""
接受客戶端的上傳檔案命令
:param self:
:param cmd: eg: put filename
:return:
"""
filename = cmd.split()[1]
fileName = filename.split("\\")[-1] # 檔案的名稱
home_file = os.path.join(self.current_path, fileName) # 判斷當前路徑下是否有上傳的檔名
self.request.send("000".encode()) # 系統互動碼
total_size = self.request.recv(1024).decode() # 上傳檔案大小
remain_size = self.accountRemainSize() # 獲得賬戶剩餘大小
if remain_size > int(total_size):
if os.path.isfile(home_file):
self.request.send("202".encode()) # 建立的檔案已經存在
self.request.recv(1024) # 等待ack指令
has_recvd_size = os.stat(home_file).st_size # 已經接收的大小
self.request.send(str(has_recvd_size).encode())
else :
has_recvd_size = 0
self.request.send("402".encode()) # 建立的檔案不存在
###開始接受客戶端的資料
recvd_size = 0
m = hashlib.md5()
with open(home_file,"ab") as fa:
while recvd_size != int(total_size) - has_recvd_size: # 迴圈接受來自的檔案資料
data = self.request.recv(1024)
m.update(data)
recvd_size += len(data)
fa.write(data)
self.request.send(m.hexdigest().encode()) # 傳送接收到的檔案的md5
else:
self.request.send("413".encode()) # 磁碟空間不夠用
def get(self,cmd):
"""
接受客戶端的上傳檔案命令
:param self:
:param cmd: eg: get filename
:return:
"""
filename = cmd.split()[1]
fileName = filename.split("\\")[-1] # 檔案的名稱
home_file = os.path.join(self.home_path, fileName) # 用於判斷家目錄下是否有下載的檔名
if os.path.isfile(home_file): # 這裡不用判斷if status_code == "205" or status_code == "402": ,因為如果接收到客戶端的狀態碼,服務端一定要先去找檔案是否存在
total_size = os.stat(home_file).st_size
self.request.send("206".encode()) # 服務端有客戶需要下載的檔案
has_send_size = self.request.recv(1024).decode()
self.request.send(str(total_size).encode())
self.request.recv(1024) # 粘包
send_md5 = upload.Breakpoint().transfer(home_file, int(has_send_size), total_size, self.request)
self.request.recv(1024) # 粘包
self.request.send(send_md5.encode())
else:
self.request.send("402".encode()) # 服務端檔案不存在
def accountRemainSize(self):
"""
統計登入使用者可用目錄大小
:return:
"""
account_path = self.user_db_info["homepath"]
print("homepath:",account_path,)
limitsize = self.user_db_info['limitsize']
used_size = 0
for root,dirs,files in os.walk(account_path):
used_size += sum([ os.path.getsize(os.path.join(root,filename)) for filename in files])
return limitsize - used_size
def mkdir(self,cmd):
"""
建立目錄,支援建立級聯目錄
:param cmd:
:return:
"""
dir = cmd.split()
dir_path = os.path.join(self.current_path,dir[-1])
if len(dir) == 2:
if not os.path.isdir(dir_path):
try:
os.mkdir(dir_path)
except FileNotFoundError as e:
os.makedirs(dir_path) #建立級聯目錄
self.request.send("200".encode())
else:
self.request.send("403".encode()) # 建立目錄存在
else:
self.request.send("400".encode())
def pwd(self,cmd):
self.request.send(self.current_path.encode())
def cd(self,cmd):
dir = cmd.split()
if len(dir) == 2:
if dir[-1] == "..": # 只能讓使用者在自己目錄下操作
if len(self.current_path) > len(self.home_path):
self.current_path = os.path.dirname(self.current_path)
self.request.send("200".encode())
else:
self.request.send("403".encode()) # 請求被拒絕,沒有上層目錄了
elif os.path.isdir(self.current_path + "\\" + dir[-1]):
self.current_path = self.current_path + "\\" + dir[-1]
self.request.send("200".encode())
else:
self.request.send("402".encode())
else:
self.request.send("400".encode())
def ls(self,cmd):
dirs = os.listdir(self.current_path)
self.request.send(str(dirs).encode())
upload.py
import hashlib,sys,time
class Breakpoint(object):
# 本模組確認使用者上傳或下載的檔案是否存在,如果存在是否需要斷點續傳
def transfer(self,filename,has_send_size,total_size,conn):
"""
進行續傳
:param filename:
:param has_send_size: 已經發送的檔案大小
:param total_size: 需要傳輸檔案總大小
:param conn: 客戶端和服務端進行資料交換的介面
:return:
"""
with open(filename,'rb') as fr:
fr.seek(has_send_size) # 定位到續傳的位置
print("has_send",has_send_size,"total",total_size)
m = hashlib.md5()
if has_send_size == total_size:
self.progress_bar(has_send_size, total_size)
for line in fr:
conn.send(line)
m.update(line)
has_send_size += len(line)
# self.progress_bar(has_send_size,total_size)
return m.hexdigest()
def progress_bar(self,has_send_size,total_size):
bar_width = 50 # 進度條長度
process = has_send_size / total_size
send_bar = int(process * bar_width + 0.5) # 傳送的資料佔到的進度條長度,四捨五入取整
sys.stdout.write("#" * send_bar + "=" * (bar_width - send_bar) +"\r" ) # 注意點:貌似只能這麼寫才能達到要求
sys.stdout.write("\r%.2f%%: %s%s"% (process * 100,"#" * send_bar,"=" * (bar_width - send_bar))) # 注意點:在pycharm中要加\r\n,用sublime只要\r否則換行
sys.stdout.flush()
————-我還是分割線———————-
最後效果圖
相關推薦
python之寫了3個週末及幾個晚上的ftp終於完成了
本程式已上傳githup,點我 作業:開發一個支援多使用者線上的FTP程式 要求: 使用者加密認證 允許同時多使用者登入 每個使用者有自己的家目錄 ,且只能訪問自己的家目錄
python之有用的3個內置函數(filter/map/reduce)
得到 fun func 一個 == reduce 語法 too map 這三個內置函數還是非常有用的,在工作中用的還不少,順手,下面一一進行介紹 1、filter 語法:filter(function,iterable) 解釋:把叠代器通過function函數進行過
Python之路58-Django安裝配置及一些基礎知識點
python目錄一、安裝Django二、創建工程三、創建app四、靜態文件五、模板路徑六、設置settings七、定義路由八、定義視圖九、渲染模板十、運行Django是一款Python的web框架一、安裝Djangopip3 install django安裝完成後C:\Python35\Script下面會生成
用Python月薪能翻3倍?這4個行業真相字字戳心!
從2017年開始,Python成為了現象級語言,一舉拿下程式語言的C位。作為“最容易學習”的膠水語言,萬能屬性的 Python 在程式設計開發中可以說是大殺四方,幾乎都可以輕鬆勝任。 而在這背後,有層出不窮的話題和文章出來,尤其噹噹 Python進入小學課本、乃至浙江省高考的政策
python之旅-日記3(記錄零基礎自己的每天學習)
2018/9/14 基礎知識 字串 正則表示式 xpath 字串 1.> len() 2.> eval(n’+'m)字串計算 3.> ord()單個字元轉為ASCII chr()整數轉為字元 4.> raw_input()輸入轉為字元型別 input()基礎型別 5.
Python之Pandas(3)
#常用數學,統計方法 import numpy as np import pandas as pd In [7]: df = pd.DataFrame({'key1':[4,5,3,np.nan,2], 'key2':[1,2,np.nan,4,5],
Day5:python之函式(3)
一、函式預設值引數 內建函式: input()、print()、int() 常用模組: 1、列表生成式 s =[1,2,3,4,5,6,7,8] for i in s: print(i+1) res = [ i+1 for i in s] res = [str(i) for i in
Python之路(組合資料型別及操作)
Python之路(組合資料型別及操作) 集合型別 集合型別的定義 集合是多個元素的無序組合 集合型別與數學中的集合概念一致 集合型別之間無序,每個元素唯一,不存在相同元素 集合元素之間不可或更改,不是可變資料型別 集合用大括號 {} 表示,元素間用逗號分隔 建立集合型別用 {}
python之路 ---列表/字典生成式及匿名函式
列表解析: l = ['aaa','bbb','ccc'] #將l內的元素全部變為大寫... L = [] for i in l: L.append(i.upper()) print(L) #使用列表解析: L = [i.upper() for i in
python爬蟲學習筆記3:bs4及BeautifulSoup庫學習
Beuatiful Soup bs類對應一個HTML/xml文件的全部內容 from bs4 import BeautifulSoup import bs4 soup=BeautifulSoup('<p>data</p>','ht
Python之字典中的鍵映射多個值
多個 pen pytho code col collect ons pan 映射 字典的鍵值是多個,那麽就可以用列表,集合等來存儲這些 鍵值 舉例 print({"key":list()}) # {‘key‘: []} print({"key":set()})
JSP三個指令及9個內置對象
內置對象 class lang itl lan 解釋 utf port taglib 註:本文編寫方便自己以後用到時查閱 三大指令:include、 page、taglib include指令: 作用: 在當前頁面用於包含其他頁面 語法: <
3.springioc bean 的幾個屬性
就會 ring proto 創建 spring 構造函數 問題 false 提前 1.lazy-init="false" 默認值為false,指的是bean的創建時機的spring容器一啟動就會加載這些類.有點是及時發現bean的相關錯誤,因為spring容器啟動,bean
假如高考考python編程,這些題目你會幾個呢?
img 命令 OS 高級 map .com 學校 面試題 程序設計語言 Python(發音:英[?pa?θ?n],美[?pa?θɑ:n]),是一種面向對象、直譯式電腦編程語言,也是一種功能強大的通用型語言,已經具有近二十年的發展歷史,成熟且穩定。它包含了一組完善而且容易理
一個正定矩陣 可以寫成它的特徵值與幾個正定矩陣的乘積和
最近看 Byod 的凸優化書,裡面有這個表示式,若 X X X 為正定矩陣,則
在經歷了6個月的學習後,我終於上架了自己的第一款APP---酷課堂iOS群問答精華整理(201807
酷課堂iOS交流群 我們是一個什麼樣的組織:酷課堂iOS交流群,聚集了一群熱愛技術、有趣、有料,平均Q齡在10年以上的“老司機”,他們遍佈在全國/球各地,有知名企業iOS工程師、高校大學生、自由職業者……如果你也是這樣的人,歡迎加入我們,一起暢聊iOS技術及周邊。 “很乾”“很佛系”每晚11點後熄燈(禁言
秋招後實習初感及幾個小flag
秋招 歷經20多天的秋招,每天機器人般的生活,早起奔波於各大宣講會,參加一場又一場的筆試,晚上懷著忐忑的心情等著面試通知,從剛開始的興奮到後來的麻木,面試已經全靠本能去回答,終於也是拿了幾個還算滿意的offer,結束了自己的秋招,開始了人生的第一份職場生活
python中,向 list 新增資料及幾種方法
//...1... def a(): list=[] for i in range(1000): list=list+[i] print(list) //...
HDFS讀寫檔案中涉及到的幾個思想
HDFS讀寫檔案中涉及到的幾個思想 1.網路拓撲--節點距離計算 2.機架感知(副本節點選擇) 1.網路拓撲–節點距離計算 1.節點距離:兩個節點到達最近的共同祖先的距離總和。 2.圖解: 2.機架感知(副本節點選擇) 1.官方說明
在經歷了6個月的學習後,我終於上架了自己的第一款APP---酷課堂iOS群問答精華整理(201807期)
酷課堂iOS交流群 我們是一個什麼樣的組織: 酷課堂iOS交流群,聚集了一群熱愛技術、有趣、有料,平均Q齡在10年以上的“老司機”,他們遍佈在全國/球各地,有知名企業iOS工程師、高校大學生、自由職業者……如果你也是這樣的人,歡迎加入我們,一起暢聊iOS技術及周邊。