C++包管理器——conan
C++ 版本(Ubuntu爲例子,Windows 更復雜)
Pre-requisites
$ [sudo] apt-get install build-essential autoconf libtool pkg-config
Protoc
$ cd grpc/third_party/protobuf
$ sudo make install # 'make' should have been run by core grpc
Build from Source
$ git clone -b $(curl -L https://grpc.io/release) https://github.com/grpc/grpc
$ cd grpc
$ git submodule update --init
$ make
$ [sudo] make install
Android 版本
compile 'io.grpc:grpc-okhttp:1.11.0'
compile 'io.grpc:grpc-protobuf-lite:1.11.0'
compile 'io.grpc:grpc-stub:1.11.0'
眼神再不好使,大概也能看出,Android版本會比C++版本簡單很多,更重要的是Android版本除了拷貝上面這段代碼之外其他的基本都是自動化的過程,而C++版本需要你各種手動的輸入和折騰才能僥倖得到你想要的結果。
包管理器有最大的好處在於,絕大部分操作都是自動化的,所以它的操作很簡單,基本不會出現錯誤。作爲C++的死忠粉,我除了仇視Android的開發者之外沒有其他選擇。直到有一個天我遇到conan
,這個跨平臺的C++包版本管理器,我終於可以像下面使用Windows的gRPC:
[requires]
gRPC/1.9.1@inexorgame/stable
嗯,世界真美好。
跨平臺的包管理器
實際上,conan
並不是第一個流行起來的C++包管理器,在VS的生態中,Nuget可以用於管理VS平臺的C++包,所以如果你只需要支持Windows平臺,你可以直接使用Nuget,因爲它和VS的集成度會比較高,在開發上可能便利性可能會超過conan
。
conan
最大的優勢在於它的跨平臺,它可以支持:
- 不同的操作系統(Windows,Linux,macOS,FreeBSD等等)
- 不同的編譯器(gcc,msvc,clang等等)
- 不同的構建工具(CMake,QMake,MSBuild,Autotools等等)
- 不同的構建方式(原生編譯,交叉編譯等等)
如果你需要一個跨平臺的解決方案,conan
可能是目前唯一的選擇
安裝
有意思的是,作爲C++的包版本管理器,conan不是用C++來實現的,它甚至不是使用編譯型語言來實現的,它使用的是腳本語言Python【1】。所以安裝conan
之前,我們需要先安裝Python和pip,然後執行下面的命令安裝conan
包:
$ pip install conan
使用
conan的使用其實分爲兩個角色:包的使用者和包的創建者,這一節重點介紹包的使用者的操作,下一節介紹包的創建者的操作。
本小節以假設你使用CMake來做自動構建,其他的自動構建工具大同小異,我會在後文中給出參考文檔
conanfile.txt
conan
的使用比較方便,我們只需要一個配置文件conanfile.txt
【2】,用於寫明我們需要直接依賴的包即可(conan會自動處理依賴的傳遞):
[requires]
zlib/1.2.11@conan/stable
[generators]
cmake
conan install
然後執行下面這條命令:
$ conan install .
上面這條命令中的.
表示conanfile.txt
的路徑,如果你不是在同一個路徑下面(比如在編譯路徑下),你需要指定相對路徑或者全路徑。通常上面這條命令會自動安裝我們想要的包,然後在在執行install
命令的路徑下生成三個文件:
- conanbuildinfo.txt
- conanbuildinfo.cmake
- conaninfo.txt
其中conaninfo.txt
這個文件可以用來判斷這個包的詳細信息,包括編譯器信息,系統架構(x86
,x86_64
等),通常如果你自動安裝出現編譯錯誤時可以考慮查看這個文件來確認一下包的信息和你期望的信息是否一致(比如你想要一個Debug包,但是下載成來Release包)。
conanbuildinfo.cmake
這個文件是給CMake用的,讓它知道如何引用依賴包,比如頭文件的引用路徑,庫的引用路徑,庫的鏈接等信息。相對的conanbuildinfo.txt
這個文件可供我們閱讀,排查上面這些信息是否有誤。不同的generators
導致不同的供構建系統使用的文件生成(比如如果指定generators
是visual_studio
,會生成conanbuildinfo.props
),但是統統都會生成供我們閱讀的conanbuildinfo.txt
。
引用生成自動生成的編譯文件
我們需要額外的設置,把這個生成的文件集成到我們自己的編譯系統中去,比如如果我們使用的是CMake
,我們需要修改我們頂層的的CMakefile.txt
,加入下面這兩句(第一句的include
怎麼寫,依賴於我們在哪個路徑下執行conan install
命令):
include(${CMAKE_BINARY_DIR}/conanbuildinfo.cmake)
conan_basic_setup()
然後讓我們的target
鏈接我們的依賴庫,
target_link_libraries(target ${CONAN_LIBS})
上面這個過程通常只需要執行一次,所以其實工作量比我們想象中的要小一些。
使用動態庫
大部分的包既提供動態庫版本又提供動態庫版本,大部分包默認情況下自動安裝static
版本的包,如果你需要使用shared
版本的包,通常可以在配置文件中加入shared
這個option
(注意,並不是所有的包都會提供這個選項):
[options]
zlib:shared=True
在Windows中,動態庫分成兩個部分xxx.dll
會導出庫xxx.lib
,dll
不用參與鏈接,但是需要放到可執行目錄下,所以使用動態庫通常還意味着需要拷貝dll
到bin
目錄下,conan
使用import
這個配置來自動完成這個操作:
[imports]
bin, *.dll -> ./bin
lib, *.dylib* -> ./bin
使用 Debug 版本的庫
默認情況下,conan自動安裝Release
版本的包,但是使用Debug
版本的庫對於調試開發其實比較有幫助。和前面不同的是,如果我們想要使用Debug
版本包,我們通常不是使用conanfile.txt
而是使用命令行參數:
conan install . -s build_type=Debug
上面的-s
表示setting
,主要包括build_type
,compiler
,arch
等。設置和選項最大的不同在於,設置是conan
內置的,每個包都存在,而option
是每個包單獨定義的,不同的包可能有不同的option
(雖然像shared這樣的option基本上都會有,理論上更像是setting,conan最新的版本默認都加上這個選項,個人感覺更像是conan的設計失誤的一種彌補範式)。
除了-s
參數之外,conan
還提供-o
參數用於指定選項:
conan install . -o zlib:shared=True
你甚至可以不提供conanfile.txt
文件,直接使用命令行完成包的安裝:
conan install zlib/1.2.11@conan/stable -g cmake -s build_type=Debug -o zlib:shared=True
profile
其實和option
一樣,setting
既可以用命令行參數指定,也可以通過配置文件指定,只不過setting
是寫在profile
而不是conanfile.txt
中。conan安裝完之後會有一個默認的profile:$HOME/.conan/profiles/default
【3】。如果你想要系統默認都下載Debug
包,你可以修改這個文件,把build_type
改成Debug
。
如果你不想影響全局,又不想頻繁的輸入命令行參數,你可以在$HOME/.conan/profiles/
下新建一個profile,比如myproject
,然後使用下面命令安裝依賴包:
conan install . --profile=myproject
這種方式在對於簡單的參數來說沒有太大的意義,但是對於交叉編譯的中特別有用,可以避免大量的參數的輸入工作。
其他工具的集成
前面的例子以CMake
爲例,其他工具的集成使用可以參考下面這個官方文檔
創建一個包
好吧,我得承認,上面一小節其實帶着忽悠的成分。因爲實際上conan
目前的生態並不是特別完善,所以很多時候,你可能找不到你想要的包,很多時候你可能沒有辦法直接通過install
安裝你要的包的依賴。
俗話說,求人不如求己,我們完全可以自己的打包,自己用。這一些節主要介紹如何創建一個conan
包。
理解conan的包名
在實際創建一個包之前,我們先來理解一下conan包的包名。
zlib/1.2.11@conan/stable
上面這個包名包含四個部分:
- zlib 名字
- 1.2.11 版本
- conan 用戶
- stable 通道(channel)
雖然它們出現在同一個名字中,但它們不在同一個位置設置。前面兩個通常寫在配置文件中,而後面兩個在命令行中指定。
包的創建步驟
創建一個包,實際上就是編譯一個包的過程,只不過conan
把這個過程腳本化來而已。所以在創建一個包之前,我們先整理一下編譯一個包需要的步驟:
- 重複下面這三個步驟,直到所有依賴編譯完成,後然執行下面三個步驟編譯自身
- 下載源碼
- 編譯
- 安裝
所以創建一個conan
包大概也就是把這幾個步驟自動化而已,自動化這些操作的腳本叫做:conanfile.py
,官方稱之爲recipe
。recipe
是菜譜的意思,這個詞非常好的體現了conanfile.py
的功能,引導conan
一步一步的創建一個包。
conanfile.py
一個 conanfile.py
通常的像下面這個樣子:
from conans import ConanFile, CMake, tools
class HelloConan(ConanFile):
name = "Hello"
version = "0.0.1"
license = "MIT"
url = "https://github.com/hello/hello.git"
description = "Hello conan"
settings = "os", "compiler", "build_type", "arch"
options = {"shared": [True, False]}
default_options = "shared=False"
generators = "cmake"
def source(self):
self.run("git clone https://github.com/hello/hello.git")
def build(self):
cmake = CMake(self)
cmake.configure(source_folder="hello")
cmake.build()
def package(self):
self.copy("*.h", dst="include", src="hello")
self.copy("*hello.lib", dst="lib", keep_path=False)
self.copy("*.dll", dst="bin", keep_path=False)
self.copy("*.so", dst="lib", keep_path=False)
self.copy("*.dylib", dst="lib", keep_path=False)
self.copy("*.a", dst="lib", keep_path=False)
def package_info(self):
self.cpp_info.libs = ["hello"]
你可以使用下面的命令來創建這個腳本:
conan new Hello/0.0.1
conanfile.py
是一個合法的Python腳本,裏面定義了一個繼承自:ConanFile的類。這個類包括兩個部分:屬性和方法,屬性用於設置一些只讀信息而方法用於自動化打包的邏輯。下面我們分步驟講解一下Conan的打包過程。
依賴
conanfile.py提供了兩種方式來聲明包的依賴,屬性requires
和requirements
成員函數。通常如果依賴邏輯比較簡單,我們可以直接設置屬性。
class MyLibConan(ConanFile):
requires = (("Hello/0.1@user/testing"),
("Say/0.2@dummy/stable", "override"),
("Bye/2.1@coder/beta", "private"))
依賴本身有兩種屬性,override
和private
。前者出現在需要覆蓋依賴的依賴的時候;而後者用於限定內部依賴,比如動態庫中依賴的靜態庫。
如果依賴的邏輯比較複雜,比如需要根據不同的option和setting來設定,我們可以在requirements()
成員函數中聲明依賴:
def requirements(self):
if self.options.myoption:
self.requires("zlib/1.2@drl/testing", private=True)
else:
self.requires("opencv/2.2@drl/stable", override=True)
這個成員函數最終調用的是requires()
函數,這個函數同樣可以設置private
和override
屬性。
下載源碼
依賴處理完之後,我們可以正式的編譯我們自己的包來,第一步要做的就是獲取源碼,同樣源碼獲取其實分爲兩種情況,一種是使用exports_sources
屬性,一個使用source()
成員函數。
使用哪一種方式主要看你的recipe
文件(conanfile.py)是否和源碼放在一起。假如你是這個包的開發者,通常你可以把你的recipe和你的源碼放一起託管到同一個倉庫,假如你只是打包人員【4】,通常你的recipe
和源碼不在同一個倉庫。
如果recipe
和源碼在同一個倉庫,通常使用exports_sources
,否則使用source()
成員函數。
exports_sources
這個屬性可以用於導出當前倉庫下的源碼,比如:
exports_sources = "include*", "src*", "!src/build/*"
recipe
中的大部分屬性如果支持多個都是以這種tuple的形式設置,因爲它們是隻讀的。如上所示,我們可以使用通配符*
也可以排除單獨的文件!
。
source() 方法
如果你需要手動下載代碼,你可以定義這個成員函數,然後在函數內部編寫源碼的獲取邏輯,最常用的兩種方式是:git clone
和下載源碼包。
def source(self):
self.run("git clone https://github.com/openssl/openssl.git")
source_tgz = "https://www.openssl.org/source/openssl-%s.tar.gz" % version
def source(self):
tools.download(self.source_tgz, "openssl.tar.gz")
tools.unzip("openssl.tar.gz")
conan給我們提供了大量的工具輔助我們編寫recipe
,這些工具大多集中在tools
模塊裏面,比如我們上面用到的tools.download
和tools.unzip
編譯
這個過程可能是整個過程中最複雜的操作,因爲C++沒有統一的構建工具(CMake慢慢的在變成事實標準,但是目前還有大量的歷史遺留項目不支持CMake)。幸運的是,conan本身提供了很多封裝來幫助我們減少這個過程的複雜性。
CMake
如果你在編寫一個新項目,很有可能你也在使用CMake(如果不是,推薦你試試),如果你使用CMake,那麼構建實際上也非常簡單,因爲conan提供了CMake這個類幫我們簡化編譯流程:
def build(self):
cmake = CMake(self)
cmake.configure()
cmake.build()
AutoToolsBuildEnvironment
在CMake流行之前,在泛UNIX圈我們通常使用AutoTool
來自動構建我們的項目,如果你恰好需要處理這種項目,我們可以使用Conan提供的AutoToolsBuildEnvironment
來編譯項目。
def build(self):
env_build = AutoToolsBuildEnvironment(self)
env_build.configure()
env_build.make()
MSBuild
如果你的項目需要在Windows下使用,但又沒有使用CMake,很有可能你用來VS的MSBuild來做自動構建,同樣conan爲我們提供了MSBuild
類來簡化編譯邏輯:
def build(self):
msbuild = MSBuild(self)
msbuild.build("MyProject.sln")
同時兼容多種編譯方式
如果你沒有辦法使用統一的方式處理所有平臺的編譯,我們可以根據settting或options動態選擇:
def build(self):
if self.settings.os == "Windows":
build_with_msbuild(self)
else:
build_with_cmake(self)
打包
編譯完成之後,我們需要對編譯輸出打包,在conan中打包分爲兩種情況,主要得看我們的自動構建系統是否已經做了打包這個環節。
構建腳本包含打包
如果我們使用CMake或者使用AutoTool
,我們通常會在編譯腳本中指定要安裝的文件,這樣在執行make install
的時候,可以把我們想要的頭文件和庫文件都安裝到指定的目錄。如果我們的構建腳本中包含這些操作,那麼打包這一步我們可以什麼都不做,只需要在編譯的時候加上install
這個步驟就可以了。
如果你使用CMake
你可以調用install()
函數:
def build(self):
...
cmake.install()
如果你使用AutoTool
,你可以使用make()
函數並指定install
參數
def build(self):
...
env_build.make(args=['install'])
構建腳本不包含打包
如果你的構建腳本中沒有包含打包這個過程,你可以通過conan提供的package()
成員函數來完成打包,你可以自動拷貝你想要的文件。
def package(self):
self.copy("*.h", dst="include")
self.copy("*.so", dst="lib", keep_path=False)
conan會自動查找符合條件的文件,並拷貝到最終的輸出目錄下面。這個操作對於MSBuild
的編譯比較方便。
配置包信息
打包的過程實際上到上面已經結束了,最後這個步驟其實是設置包的信息,以便使用者能夠正常的使用,最常見的操作是設置self.cpp_info.libs
這個屬性,它用來告訴使用者在使用的時候需要鏈接什麼庫。
def package_info(self):
self.cpp_info.libs = ["hello"]
上面這段代碼表示使用的時候需要在庫引用列表中加上hello
(具體的設置方式還要看使用者用的是什麼編譯器)。
創建本地的conan包
有了上面腳本,我們可以使用下面的命令來創建一個conan包:
conan create . guorongfei/testing
它會把recipe
和相關的文件拷貝到本地的緩存中,然後根據recipe
創建一個包。本地緩存通常放在$HOME/.conan/data
目錄下,我們可以直接在這個目錄下面找到我們剛剛創建的包,通常這個包裏面會包含下面幾個目錄,
- export
- export_source
- source
- build
- package
其中 export 裏面存放了我們的recipe
,剩下的幾個目錄的功能我們在前面conanfile.py
的講解中有講解,這裏不贅述。package
中存放了我們最終打包出來的文件,如果你想知道自己打的包對不對,可以檢查一下這個目錄。
conan 打包的內部過程
conan create 把文件拷貝到本地緩存中,之後把文件拷貝到source目錄下,執行source()函數下載代碼,然後把文件拷貝到build目錄下(沒一種配置都會有一個相應的build目錄),執行build()函數,最後執行package()函數把最終的輸出拷貝的對應的package
目錄。
如果我們的編譯腳本中包含了install
這個步驟,所有的文件會被install到package目錄下,所以可以不用編寫package()再做拷貝。
只打包不編譯
上面提到,不同的成員函數對應打包的不同的步驟,如果我們沒有辦法獲取源碼,只能獲取到二進制文件,但是又不想自己去設置庫的路徑,conan給我們提供來一種方式:只打包不編譯。
因爲我們不需要編譯,所以recipe
中絕大部分的函數我們都可以不重載,通常我們只需要重載packet()
和packet_info()
方法。假如我們已經下載好了所有需要的文件,我們可以這樣寫:
from conans import ConanFile, CMake, tools
class HelloConan(ConanFile):
name = "Hello"
version = "0.0.1"
license = "MIT"
url = "https://github.com/hello/hello.git"
description = "Hello conan"
settings = "os", "compiler", "build_type", "arch"
options = {"shared": [True, False]}
default_options = "shared=False"
generators = "cmake"
def package(self):
self.copy("*")
def package_info(self):
self.cpp_info.libs = ["hello"]
當然我們也可以重載build()
函數從構建服務器上自動下載已經編譯好的二進制包進行打包。如果我們只打包不編譯,我們可以使用export-pkg
命令創建包:
conan export-pkg . Hello/0.0.1@conan/testing
把本地的conan包上傳到遠程的倉庫
上面這些步驟讓我們在本地創建了一個conan包,但是獨樂樂不如衆樂樂,我們最後一個步驟通常是把包上傳到遠程倉庫。這裏涉及到三個步驟:
1. 添加遠程倉庫
conan remote add remote_name remote_url
2. 獲取上傳權限
下面這條命令可以用於獲取遠程倉庫的權限,通常下載不需要權限,但是上傳需要。
conan user
3. 上傳包
因爲我們可能創建了多個包(不同到setting,不同到options),我們可以加上 --all
表示上傳所有到包。
conan upload Hello/0.0.1@conan/testing --all
conan
是一個去中心化的包版本管理工具,模型和git
十分相識。
美中不足
我非常希望conan
是一個完美的包版本管理器,但是它畢竟不是,我個人使用過程中最大的麻煩是他們對於Android的交叉編譯支持其實不是特別友好。
交叉編譯
交叉編譯是指在一個架構中編譯另一個架構的包,比如在Linux上編譯Android的包。conan對於交叉編譯的支持是通過創建toolchain
,並且設置CXX
、CC
、SYSTEM_ROOT
等環境變量來實現的,這種方式在AutoTool
流行的年代比較流行,目前很多嵌入式開發依舊使用這中方式。具體的使用方式參考官方給的例子:
但是Android的NDK,現在也支持使用toolchain
這種方式,但是這種方式在後續會被慢慢的移除掉。NDK目前的交叉編譯使用的是CMake的toolchain.cmake這種方式,直接使用CMake系統來完成交叉編譯,它不需要單獨製作toolchian
,使用起來其實方便很多。但是目前conan並不支持這種方式。
使用技巧
使用了一段時間的conan之後,積累了一些相關的經驗補充在這裏:
使用alias創建別名包,避免頻繁的更新依賴
我們在創建和使用一個conan包的時候都需要指定這個conan包的版本
# 創建
conan create . HelloConan/0.1.0@conan/teting
# 使用
conan install HelloConan/0.1.0@conan/teting
這給使用上帶來的問題是,如果我更新了一個包,但是並沒有改變他的接口,使用者依舊 需要更新自己的依賴:比如更新conanfile.txt
。使用者很多時候只要前後版本可以兼容 ,使用者通常自是想要使用最新版本就可以了。
conan提供了alias
來實現最新版本這個概念,比如如果我們剛剛創建了0.1
系列最新的包HelloConan/0.1.5@conan/teting
這個包,我們可以使用下面的命令把最新版本設置爲這個包:
conan alias HelloConan/0.1@conan/testing HelloConan/0.1.5@conan/testing
手動創建包,避免每次更新都重新編譯整包
conan create
這條命令的背後實際上執行了整個打包流程【5】,這條命令很方便,但是 會導致所有的包都重新編譯一遍,而很多時候其實我們只是做了非常小的一個改動而已。 爲了充分的利用編譯緩存,我們可以手動的執行打包流程,也就是手動執行conan create
背後的指令:
conan install . --install-folder=./build
conan build . --source-folder=./ --build-folder=./build
conan export-pkg . HelloConan/0.1.0@conan/testing --package-folder=./build/package
conan create
實際上依次執行了conan source
,conan install
,conan build
,conan package
,conan export-pkg
這幾條命名。它之所以慢是因爲每次都需要重新執行這些命令,如果我們手動創建包,我們可以有下面這些改進:
- 不執行
source
,因爲源碼就在我們手上,可能就是當前這個目錄 - 選擇性執行
conan install
,依賴項實際上我們可以只安裝一次,後續的編譯不需要重複執行 - 指定同一個編譯目錄,這個是最見效的方式,我們可以把編譯目錄手動設置爲固定的目錄,這樣可以充分的利用編譯緩存來極大是縮短編譯時間。
- 不執行
conan package
,因爲conan export-pkg
會執行package()
函數,也就是conan package
命令做的事情。
需要注意的是,export-pkg
包含兩種不同的執行模式,如果我們在build
這個步驟中使用了cmake.install()
創建了包,我們只需要指定package-folder
就可以了(這種情況通常我們不寫package
函數),否則我們需要指定source-folder
和build-folder
,以便執行package
函數打包。