ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Django/Flask项目打包成exe全过程:从PyInstaller到Inno Setup

Django/Flask项目打包成exe全过程:从PyInstaller到Inno Setup 手头有个Django或者Flask项目想让它在没有Python环境的Windows机器上跑起来最常见的诉求就是打包成exe。很多朋友踩过这个坑照着教程打包一个hello.py没问题换成自己的Flask项目双击exe就闪退要么报错找不到模板要么干脆提示缺模块。我前前后后拿Python写过十几次Web项目打包从Flask的管理后台到Django的内容管理系统都试过这里把最终沉淀下来的完整流程和踩坑记录一次性写清楚。这篇文章适合两类人一是要给客户做演示系统想把整个Web应用打包成开箱即用的程序二是要做内网离线部署不希望在目标机器上装Python环境。读完你可以直接照着操作从代码调整、PyInstaller打包到用Inno Setup生成安装包整个链路一次走通。1. 先说透原理Django/Flask 打包成 exe 到底在打包什么如果你只是把一个普通Python脚本打包成exe那PyInstaller做的事情非常简单把Python解释器、import到的模块和脚本本身冻成一个可执行文件。但Django和Flask是Web框架它们不是给你画界面的而是跑一个HTTP服务。所以打包出来的exe本质是一个服务器进程用户双击exe后需要再去浏览器里访问本机地址才能看到界面。1.1 打包的本质解释器、依赖、应用资源三合一具体来说Web项目exe内部由三部分组成。一是Python解释器本体包括标准库和一堆动态链接库比如python312.dll、VCRUNTIME140.dll这一类。二是你项目依赖的所有第三方模块包括Flask或Django自身、它的附属库、你用的ORM、模板引擎等。三是应用资源也就是模板文件、静态文件css/javascript/images、配置文件还有可能存在的数据库文件。这里就引出了Web项目和普通GUI脚本的本质区别。普通Tkinter或者PyQt应用资源文件也就一个图标、一个配置文件但Web框架里的模板和静态文件是运行时按路径去磁盘上查找的。你光把代码打进去而忘了资源文件exe运行后会直接告诉你 jinja2.exceptions.TemplateNotFound。这几乎是所有Web打包新手遇到的第一个问题也是为什么很多人同样的pyinstaller命令打包普通脚本没问题一换Flask项目就翻车。1.2 打包前的三个决策点动手之前先想清楚三个问题能省掉后面一大半返工。第一运行形态。你要的是单文件exe-F模式还是一个文件夹程序-D模式单文件的好处是分发方便只要给人家一个exe就行坏处是启动时会先解压到一个临时目录启动速度慢一点而且杀毒软件更容易误报。文件夹模式启动快、报错定位容易但分发时是一堆文件看着不够整洁。我个人建议如果最终要做安装包优先选文件夹模式因为安装包本身就是完整的分发介质安装到目标机器之后用户看到的通常只有快捷方式文件夹模式完全够用。第二端口策略。Web应用需要监听一个端口常见的有5000Flask默认和8000Django默认但这些端口不一定空闲被其他程序占用的情况不少。我在exe里通常让用户启动时传一个端口参数不传就自动找空闲端口同时自动打开浏览器访问127.0.0.1:端口。这才是桌面端程序该有的体验而不是让用户自己输地址。第三数据目录。应用运行时要写SQLite数据库、日志文件、用户上传文件这些不能写到PyInstaller解压的临时目录里因为临时目录随时可能被系统清理下次启动文件就丢了。常规做法是写到用户目录比如os.path.join(os.environ[APPDATA], MyApp)或者%USERPROFILE%\Documents\MyApp。这个决策最好在打包前就定好后面再改要动的地方很多。这三个决策点定完后面打包基本就是流水线操作了。2. 工具选型与打包环境PyInstaller 为主Nuitka 为辅打包工具圈子里有几种主流方案我先把对比摆出来。2.1 主流工具横向对比工具打包方式体积启动速度反编译难度上手难度PyInstaller冻结打包较大中等低低Nuitka先编译成C/C再打包略小较快高中高cx_Freeze冻结打包较大中等低低py2exe冻结打包大慢低比较老Nativefier针对纯前端网站用Electron包裹网页很大中等低低冻结这个词指的是把解释器和模块原样打包PyInstaller、cx_Freeze都是这一类打包产物本质上是个自包含的老鼠洞里面装着Python运行时。Nuitka走的是另一条路它先把Python代码编译成C代码再用C编译器编译成原生二进制所以反编译难度高、启动快缺点是编译一次要几分钟而且Django这种重框架偶尔会有动态行为编译不过去的场景排查成本不低。2.2 为什么我主推PyInstaller我的选型结论很直接Web框架集成项目优先用PyInstaller。理由有三个。一是社区最活跃你遇到的99%的问题在官方文档或GitHub issue里都能找到答案这是打包这种黑盒操作最需要的。二是对Flask和Django的适配性最好PyInstaller有专门针对常见框架的hook机制会自动替你把一些奇怪的隐式依赖处理掉。三是命令行简单一条命令加几个参数就能出结果。Nuitka则要先装C编译环境Windows上还得装Visual Studio Build Tools很多人卡在了环境配置这一步连打包的边都没摸到。当然如果你发布后特别担心逻辑被反编译或者想追求更快的启动速度PyInstaller打完之后再用Nuitka做一次进阶构建是有价值的。但作为第一版交付PyInstaller是投入产出比最高的选择。2.3 打包环境准备干净隔离的 venv 是第一步这里要强调一个经验不要在你日常开发用的全局Python环境里直接打包。开发环境很可能装了几十个包有数据分析的、有机器学习的、有调试工具的PyInstaller默认虽然只打包import到的模块但环境过于复杂会让分析过程变慢甚至因为某些模块被间接引用而塞进包内白白增大体积。标准做法是给项目单独创建一个虚拟环境python -m venv .venv .venv\Scripts\activate pip install flask waitress pip install pyinstaller只装项目运行真正需要的东西再加一个PyInstaller本体。打包完可以检查一下exe大小正常一个纯Flask应用打出来在20MB到40MB左右如果超过100MB多半是环境不干净或参数没配好。3. Flask 项目打包实操从 hello world 到能交付的 exe这一节我完整走一遍Flask项目的打包流程。为了贴近真实交付场景我会把模板、静态文件、端口处理和自动打开浏览器的逻辑都写进去。3.1 项目准备一个能跑的真实Flask应用假设项目结构是这样flask_app/ ├── main.py ├── templates/ │ ├── index.html │ └── result.html ├── static/ │ ├── css/style.css │ └── js/app.js └── requirements.txtmain.py核心逻辑大概长这样from flask import Flask, render_template, request app Flask(__name__) app.route(/) def index(): return render_template(index.html) app.route(/result, methods[POST]) def result(): name request.form.get(name, world) return render_template(result.html, namename) if __name__ __main__: app.run(host127.0.0.1, port5000, debugTrue)直接打包这个版本不推荐有两个问题。一是app.run()跑的是Flask自带的开发服务器它性能一般也不适合交付。二是debugTrue会开启调试器绝对不能让客户看到否则等于把代码调试后门送给人家。所以打包前要换成waitress这是一个专为Windows场景设计的轻量级WSGI服务器稳定性和性能都比开发服务器高一个档次。3.2 资源路径处理sys._MEIPASS 是打包后的命门PyInstaller在Onefile模式下会把所有资源解压到一个临时文件夹这个临时文件夹路径存放在sys._MEIPASS属性里在Onedir模式下exe所在的目录是基准。Flask/Django的模板和静态文件用的是相对路径不处理的话运行时就会去exe所在目录找templates结果当然找不到。所以main.py要加一个helper函数import sys import os def resource_path(relative_path): 获取打包后资源的真实路径 base_path getattr(sys, _MEIPASS, os.path.abspath(.)) return os.path.join(base_path, relative_path)然后创建Flask实例时这样写app Flask(__name__, template_folderresource_path(templates), static_folderresource_path(static))这样开发模式PyCharm直接运行和打包模式exe运行都能正确找到资源。注意在用--add-data添加模板和静态目录时目标路径要和resource_path里的相对路径严格对应。Windows上分隔符是分号冒号是Linux下用的我看到过有人在这上面卡了半天。3.3 PyInstaller打包命令和参数解读这是我常用的正规Flask打包命令pyinstaller -D -w -n FlaskDemo --add-data templates;templates --add-data static;static --iconassets/icon.ico --exclude-module tkinter --exclude-module pytest main.py参数含义-D生成文件夹模式也可以写成--onedir这里不用-F来避免单文件启动慢和杀软误报-w不显示控制台窗口。注意调试阶段建议先不加加上之后如果启动报错你连报错信息都看不到-n FlaskDemo指定exe名字--add-data把模板、静态资源按 源路径;目标相对路径 加入包内--icon设置exe图标--exclude-module排除确定用不到的模块可以显著减小体积打包完成后会在dist/FlaskDemo/下生成一个完整目录里面有exe和一堆依赖文件。双击FlaskDemo.exe理论上浏览器访问http://127.0.0.1:5000就能看到应用。3.4 正式入口用waitress替代开发服务器顺便自动开浏览器为了让exe交付出去更像一个正经桌面程序我把启动逻辑写成这样import threading import webbrowser import socket from waitress import serve from flask import Flask, render_template app Flask(__name__, template_folderresource_path(templates), static_folderresource_path(static)) def find_free_port(): with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind((127.0.0.1, 0)) return s.getsockname()[1] if __name__ __main__: port find_free_port() threading.Timer(1.5, lambda: webbrowser.open(fhttp://127.0.0.1:{port})).start() print(f服务已启动访问 http://127.0.0.1:{port}) serve(app, host127.0.0.1, portport)这里我刻意做了两件事。一是用find_free_port()动态找一个空闲端口避免5000端口被占导致启动失败二是用threading.Timer延迟1.5秒再打开浏览器给waitress留足绑定端口的时间。这两个小优化对用户体验提升非常明显你试过就知道为什么不能直接用固定端口。注意serve()里的host写127.0.0.1只监听本机不会把服务暴露到局域网。如果确实希望局域网内其他设备也能访问再改成0.0.0.0同时一定要考虑认证和防火墙策略。4. Django 项目打包实操多一层静态文件和 settings 的处理Django和Flask打包的核心思路完全一致都是解释器依赖资源三合一但因为Django的治理结构更重会有几个额外的坑。4.1 Django 打包前的配置调整先把settings.py里几个关键位置改掉DEBUG False必须关否则会有安全漏洞而且模板报错时会把源代码路径吐给用户ALLOWED_HOSTS [127.0.0.1, localhost]不要用通配符否则别人用你电脑IP就能访问你的服务DATABASES如果默认用SQLite数据库文件路径要改成动态的因为BASE_DIR在打包后可能指向临时目录数据库文件写进去下次启动就没了具体可以这样import os import sys def appdata_dir(): if getattr(sys, frozen, False): return os.path.join(os.environ[APPDATA], MyDjangoApp) return os.path.dirname(os.path.abspath(__file__)) SQLITE_DIR appdata_dir() if not os.path.exists(SQLITE_DIR): os.makedirs(SQLITE_DIR) DATABASES { default: { ENGINE: django.db.backends.sqlite3, NAME: os.path.join(SQLITE_DIR, db.sqlite3), } }这里用sys.frozen判断是否处于打包状态比用_MEIPASS更通用因为这个属性在PyInstaller打包后的运行环境下一定存在。4.2 静态资源和模板的收集Django的静态文件机制和Flask不一样。开发模式下Django能自动找到app里的static目录但DEBUGFalse后Django根本不会自己去找静态文件而是假定你有一个正规Web服务器nginx、IIS之类来负责托管。打包成exe时没有nginx所以要让Django自己去服务静态文件通常用whitenoise这个库。安装whitenoise后在settings.py的MIDDLEWARE最上面加一行MIDDLEWARE [ whitenoise.middleware.WhiteNoiseMiddleware, # 其他中间件 ]再配置STATIC_URL /static/ STATIC_ROOT os.path.join(BASE_DIR, staticfiles)然后执行python manage.py collectstatic --noinput这一步会把所有app里的静态文件收集到staticfiles/目录打包时把整个staticfiles目录加进去即可。模板文件则分散在各个app的templates目录里逐个添加比较繁琐更省事的方式是写一个spec文件直接把所有templates目录收集进去。4.3 入口脚本与spec文件配置Django的启动不能直接用manage.py因为manage.py里做了不少路径假设而且它默认用runserver那是开发服务器不能交付给客户。我自己写了一个entry.pyimport os import socket import threading import webbrowser import django from django.core.wsgi import get_wsgi_application from waitress import serve os.environ.setdefault(DJANGO_SETTINGS_MODULE, myproject.production_settings) django.setup() def find_free_port(): with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind((127.0.0.1, 0)) return s.getsockname()[1] if __name__ __main__: port find_free_port() application get_wsgi_application() threading.Timer(1.5, lambda: webbrowser.open(fhttp://127.0.0.1:{port})).start() print(f启动完成访问 http://127.0.0.1:{port}) serve(application, host127.0.0.1, portport)为什么单独拎一个production_settings.py而不是直接改settings.py因为开发环境需要DEBUGTrue和开发服务器打包环境需要DEBUGFalse和waitress混在一起每次打包都要来回改容易改错。拆开以后开发用settings.py打包用production_settings.py互不干扰。Django依赖的模块很多PyInstaller偶尔会漏掉一些隐式导入稳妥的做法是在spec文件里手动声明hidden imports。一个精简版spec片段# myproject.spec a Analysis( [entry.py], pathex[], binaries[], datas[ (staticfiles, staticfiles), (myproject/templates, myproject/templates), (app1/templates, app1/templates), (app2/templates, app2/templates), ], hiddenimports[ django.contrib.admin, django.contrib.auth, django.contrib.contenttypes, django.contrib.sessions, django.contrib.messages, django.contrib.staticfiles, whitenoise, ], excludes[tkinter, pytest], noarchiveFalse, )之后用pyinstaller myproject.spec命令打包。spec文件的好处是可以把打包参数固化成代码以后重新打包不需要翻历史命令也方便交给同事复现。5. 从 exe 到安装包用 Inno Setup 完成用户端交付到了这一步你手里已经有一个能跑的exe或者一个dist目录。但如果直接把整个文件夹打包发给用户非技术用户的使用体验很差而且目录里一堆dll和资源文件随便缺一个就启动失败。所以我都会再加一道工序打安装包。5.1 为什么必须做安装包做安装包的好处有三点。第一是体验统一。用户拿到的是一个Setup.exe双击后一路下一步就装好桌面自动生成快捷方式这和正经软件的交付形态一致客户对你的专业度信任度完全不同。第二是防止文件缺失。安装包内是完整的文件快照安装时一次性解压全部内容不容易出现少拷贝一个dll之类的低级错误。第三是方便卸载和控制安装位置。Inno Setup会自动生成卸载程序、写注册表、创建开始菜单目录。从exe到安装包我用的工具是Inno Setup免费、脚本语法简单、对中文界面支持也好。NSIS也可以但语法偏老调试起来不如Inno Setup直观。如果嫌写脚本麻烦还有个非脚本方案是RAR自解压但那个做出来本质只是个解压工具不能创建快捷方式和卸载入口只适合临时分发不建议用于正式交付。5.2 Inno Setup脚本编写与常见配置用Inno Setup向导新建脚本核心配置如下[Setup] AppName我的Flask应用 AppVersion1.0.0 DefaultDirName{autopf}\MyFlaskApp DefaultGroupNameMyFlaskApp OutputDiroutput OutputBaseFilenameMyFlaskApp_Setup Compressionlzma2 SolidCompressionyes ArchitecturesAllowedx64compatible ArchitecturesInstallIn64BitModex64compatible [Files] Source: dist\FlaskDemo\*; DestDir: {app}; Flags: ignoreversion recursesubdirs createallsubdirs [Icons] Name: {autodesktop}\我的Flask应用; Filename: {app}\FlaskDemo.exe; Tasks: desktopicon Name: {group}\我的Flask应用; Filename: {app}\FlaskDemo.exe [Tasks] Name: desktopicon; Description: 创建桌面快捷方式; GroupDescription: 附加任务: [Run] Filename: {app}\FlaskDemo.exe; Description: 立即运行我的Flask应用; Flags: nowait postinstall skipifsilent简单解释几个关键点。DefaultDirName默认安装到Program Files如果用户自定义路径里出现中文或空格不要紧Inno Setup自己会处理但exe内部的资源路径处理必须用os.path而不是字符串拼接否则会踩路径空格的大坑。[Files]段用recursesubdirs把dist目录下的所有内容原样装进安装目录这是保证程序完整性的关键。[Icons]生成桌面快捷方式和开始菜单快捷方式。[Tasks]提供可选的桌面图标选项默认勾选。[Run]在安装完成后询问是否立即运行实际发布版本建议保留这个提示。注意[Files]里Source路径要指向PyInstaller生成的dist目录里面内容必须整体放入而不是只放exe因为onedir模式下exe依赖旁边的文件。如果只放exe只能在目标机器上看到闪退。5.3 安装包发布前的体验优化正式发布前我会做这几件润色。一是版本信息。PyInstaller里通过--version-file指定一个版本文件Inno Setup里把[Setup]段的AppVersion、AppPublisher、AppCopyright都填好右键文件属性就能看到公司名和版本号用户看着正规很多。没有人愿意装一个找不到出品方的软件。二是裸机测试。至少找一台没装过Python的干净Windows虚拟机把安装包装上去从双击安装到打开浏览器走一遍完整流程。很多问题在开发机上根本复现不了比如缺少VC运行库、防火墙首次启动弹窗等。我在交付前一定会跑一遍这个流程。三是处理外网依赖。很多前端开发者习惯在HTML里引用BootCDN的jQuery但在内网或离线环境下这些请求全挂页面样式全乱。打包Web应用时所有外部资源要么内联进HTML要么把前端依赖也作为静态文件放进项目里一起打包。6. 常见报错和避坑经验打包过程中我踩过的坑最后整理一张速查表再讲几个我印象很深的坑。6.1 报错速查表现象常见原因解决办法双击exe没有反应或秒退缺DLL、模板路径错误在cmd里运行exe看报错或先去掉-w参数调试TemplateNotFoundtemplates没打包或路径没处理确认 --add-data 已添加resource_path已生效ModuleNotFoundError: No module named xxxPyInstaller没分析到动态import的模块用 --hidden-import 显式导入OperationalError: unable to open database file数据库路径指向临时目录或无权限目录把数据库路径改到用户数据目录端口被占用启动失败默认端口被其他程序占用使用动态空闲端口杀毒软件拦截exe未签名且版本信息缺失增加版本信息、加签名或改为文件夹模式打开页面没样式static/staticfiles没打包或DEBUGFalse下没用whitenoisecollectstatic WhiteNoiseexe体积异常巨大环境太脏装了很多无关模块用纯净venv加 --exclude-module 剔除无用模块这张表基本涵盖了Web项目打包90%的问题。如果你哪一行都没遇过说明你的打包流程已经比较健康了。6.2 三个我印象很深的坑第一个坑是路径里的空格。我有一个项目放在D:\my works\project name这种带空格的路径下PyInstaller打包过程本身没报错但生成的exe启动后模板全部找不到。排查了很久最后把路径移到D:\works\project_name就正常了。所以源码路径里尽量不要有中文和空格这是最稳妥的做法。第二个坑是Django的collectstatic重复执行。第一次打包时没执行collectstatic直接打包结果打开页面只有纯HTML样式全丢。我一度以为是static文件没加进去后来才反应过来Django在DEBUGFalse下根本不会自动服务静态文件必须靠collectstatic把所有app的静态文件汇总到一个目录再配合whitenoise放进WSGI中间件缺一步都不行。第三个坑出现在一次升级里用户机器上的Windows没有装VC运行库exe双击后直接报错0xc000007b。开发机上看不到这个问题因为装了Visual Studio的开发机肯定有这些运行库。稳妥的方案是在Inno Setup里加一个检测或者在做安装包时把常见DLL一起带过去再或者用静态链接的Python版本打包。后来我干脆在安装包发布前跑一遍干净虚拟机测试问题才被真正堵住。还有一个实际操作中的小技巧打包后的exe在迭代更新时记得清空build/和dist/目录再重新打包。旧文件混进新包容易出现一些莫名其妙的偶发错误清干净再打能省很多排查时间。说实话Django/Flask项目打包成exe这件事难的不是PyInstaller本身而是你对Web应用运行时资源路径和数据目录的理解是否到位。我现在给任何Web项目做交付都会先把资源路径、端口策略、数据目录这三个决策点写进设计文档再动手打包速度能快很多。最后再分享一个小技巧如果你经常要同时兼容老旧Windows系统别只想着默认打包PyInstaller有--target-architecture参数可以指定架构不同版本的系统兼容性差异确实很大。希望这篇实操总结能让你少走一点我当年走过的弯路。
返回列表