2024年11月

Mysql 介绍

Mysql是典型的开源关系型数据库,是许多网站、应用程序、企业软件产品的首选数据库。

Mysql特性:

  • 易于使用,功能强大,支持事务、触发器、存储过程
  • 管理工具多种多样且功能丰富
  • 可以作为千万级数据管理的大型数据库
  • 采用GPL开源协议,允许自由修改源码并应用到商业系统中
  • Mysql的InnoDB事务性存储引擎符合事务ACID模型,能保证完整、可靠地进行数据地存储

高可用结构

  • 主从模式

  • MHA

  • MMM

  • MGR

主从模式

主从模式介绍

主从模式是最基本的Mysql高可用架构,一台服务器作为Master节点,若干服务器作为Slave节点。只有Master处理写数据请求,读请求可仅由Slave节点处理,也可让Master、Slave同时处理。

Master和Slave通过
主从复制
技术保持数据一致,即Master节点将数据同步给Slave节点。

主从模式具备高可用的基础是主从复制技术。

主从复制技术

  • 当Master 数据发生变更(新增、删除、修改)时,Master将变更日志写入二进制日志文件 binlog
  • Slave启动单独线程(I/O线程)与Master建立网络连接,从Master的binlog中获取变更日志
  • Slave的I/O线程捕获到数据变更日志后,按照顺序保存到中继日志文件 relay log
  • Slave启动单独线程(Sql线程)从relay log 中读取日志并执行,使Slave 库的数据和Master一致

主从模式注意事项

Mysql 5.5之前主从复制为异步方式,Master 提交事务不需要经过Slave 们的确认,那么就会有这种极端情况:

  • Slave 读取Master 的binlog失败了
  • Slave 处理relay log 失败了
  • Slave 执行Sql语句失败了
  • 等......

类似的极端情况将导致数据不一致。所以在Mysql 5.5 主从复制提供了半同步的方式,具体来说就是增加了ACK确认的机制,当Slave接收到binlog 后,会给Master 发送一条确认消息,Master在接收到ACK确认消息之后才会提交事务。半同步方式可以提高数据的一致性,但是Master在写入数据的时候需要等待Slave的确认,所以性能会有所下降。

复制风暴问题,来考虑这样一种更加极端的情况,一个Master ,10个Slave , 这种情况下基于主从复制技术,Master在写入数据前需要同时处理10个Slave的数据复制请求,这种情况下对于Master只能说是不堪重负,如果在加上“半同步机制”,写入性能将大打折扣,这种情况称之为复制风暴问题。解决这种问题的方法是,Master 仅处理一个Slave的主从复制,其它的Slave复制由Slave负责。

MHA(MasterHighAvailability)

MHA模式介绍

以主从模式为基础,接下来就该考虑如下问题了:

  • 如何检测节点故障
  • master节点故障之后如何重新选举

MHA就是在解决这两个问题的,理论上,MHA模式可以在10s-30s内完成主从集群的自动故障检测和自动主从切换。

MHA由两个部分组成:

  • MHA-Manager:负责自动检测Master是否故障,检查主从复制状态,执行自动主从切换等。需要单独服务器部署。
  • MHA-Node:负责修复主从数据的差异,通常和Mysql服务器实例绑定部署。

MHA工作流程

  • Manager 和 Master之间心跳,如果连续4次探测不到心跳,就认为该Master宕机了,Master实例绑定一个Node。

  • Manager 分析各个Slave的binlog,选择一个更接近Master数据的Slave作为备选Master,一个Slave实例分别绑定一个Node。

  • Slave的Node试图通过SSH访问Master所在服务器:


    • 如果可达,Slave的Node获取Master的binlog数据,若发现Master和Slave数据存在差异,会将差异数据主动复制到Slave,以保持主从数据一致。
    • 如果不可达,Node对比各个Slave的relay log 差异,并做差异数据补齐。
  • Manager将备选Master提升为Master。

MMM(Multi-MasterReplicationManagerForMysql)

MMM模式简单来说就是引入虚拟IP(vip)技术,这种架构下,一个集群中有两个Master和若干个Slave,当其中一个Master不可用的时候,MMM会指示vip切换到另外一个Master上面,同时会向所有的Slave发送更换Master的消息,之后主从复制将切换到新的Master。

此方案比较古老,不支持Mysql GTID ,并且社区活跃度不够,目前处于无人维护的状态。

MGR(MysqlGroupReplication)

MGR,Mysql组复制模式是Mysql5.7.17版本推出的高可用解决方案,具备如下特性:

  • 一致性高:数据复制基于分布式共识算法Paxos,可以保证多个节点数据的一致性
  • 容错性高:只要不是超过一半的节点宕机,就可以继续提供服务
  • 灵活性强:MGR支持单主模式和多主模式,单主模式下如果Master故障,Slave们会重新选举一个新的Master,多主模式下每一个Mysql节点都可以同时处理写请求

MGR要求至少由3个Mysql节点组成一个复制组,即一主两从,一个事务必须经过复制组内超过半数节点通过后才能提交。

如果在不同的Mysql节点上执行不同的写操作发生了事务冲突,那么先提交的事务先执行,后提交的事务被回滚。在多主模式下,由于每个Mysql节点都可以执行写请求,在写请求高并发的场景下发生事务冲突的概率会非常大,会造成大量事务回滚。

在单主模式下,MGR会自动为复制组选择一个Master负责写请求,如果复制组内超过一半节点与Master通信失败,就认为Master宕机了,这时会根据各个节点的权重和ID标识重新选主。

MGR更加适合一致性强,写并发量不大的场景下使用。

总结

本文阐述了Mysql高可用架构方案,介绍了
主从模式,MHA模式,MMM模式,MGR模式
方案的实现方式,没有哪个方案是完美的,开发人员在选择何种方案应用到项目中也没有标准答案,合适的才是最好的。

在我们开发系统的时候,往往需要一个很容易理解功能的工具栏,工具栏是一个系统的快速入口,美观易用的工具栏是可以给系统程序增色不少的,本篇随笔介绍在使用wxpython开发跨平台桌面应用,工具栏的动态展现效果,以及多级工具栏显示等的创建处理过程。

1、wxpython工具栏介绍

在 wxPython 中,
工具栏(Toolbar)
是一种常用的 GUI 组件,用于显示一系列图标按钮,提供用户快速访问常用功能。

wxPython 中的工具栏可以使用
wx.ToolBar
类来创建和管理。wxPython 还提供了
wx.adv.ToolBar
组件,支持更丰富的界面元素和布局功能。根据用户需求可以选择使用。

另外在 wxPython 中,
wx.aui.AuiToolBar

高级用户界面库(Advanced User Interface,AUI)
中的工具栏组件,提供了更灵活和可自定义的工具栏功能。相较于传统的
wx.ToolBar

AuiToolBar
支持拖放、浮动、隐藏等高级特性,适合开发更复杂的 GUI 应用。

(MacOS系统表现界面)

本篇随笔主要介绍基于
wx.aui.AuiToolBar
来及进行工具栏界面的创建处理,以上图形就是基于
wx.aui.AuiToolBar
来进行创建的。

1).
主要特性

  • 拖放支持
    :可以通过拖动工具栏,将其停靠到不同的位置。
  • 可自定义样式
    :支持文本、图标、分隔符、下拉菜单等多种样式。
  • 可隐藏与显示
    :用户可以根据需要显示或隐藏工具栏。
  • 可浮动
    :工具栏可以作为独立窗口浮动。

2).
基础示例

以下是一个使用
wx.aui.AuiToolBar
的基本示例:

importwximportwx.auiclassMyFrame(wx.Frame):def __init__(self, *args, **kw):
super().
__init__(*args, **kw)#创建 AUI 管理器 self.mgr =wx.aui.AuiManager(self)#创建 AuiToolBar toolbar = wx.aui.AuiToolBar(self, style=wx.aui.AUI_TB_DEFAULT_STYLE |wx.aui.AUI_TB_OVERFLOW)#添加工具按钮 open_bmp =wx.ArtProvider.GetBitmap(wx.ART_FILE_OPEN, wx.ART_TOOLBAR)
save_bmp
=wx.ArtProvider.GetBitmap(wx.ART_FILE_SAVE, wx.ART_TOOLBAR)
exit_bmp
=wx.ArtProvider.GetBitmap(wx.ART_QUIT, wx.ART_TOOLBAR)

toolbar.AddTool(wx.ID_OPEN,
"Open", open_bmp, short_help="Open File")
toolbar.AddTool(wx.ID_SAVE,
"Save", save_bmp, short_help="Save File")
toolbar.AddSeparator()
#添加分隔符 toolbar.AddTool(wx.ID_EXIT, "Exit", exit_bmp, short_help="Exit Application")#完成工具栏布局 toolbar.Realize()#将工具栏添加到 AUI 管理器 self.mgr.AddPane(toolbar, wx.aui.AuiPaneInfo().Name("Toolbar").Top().Dockable(True))#绑定事件 self.Bind(wx.EVT_TOOL, self.on_open, id=wx.ID_OPEN)
self.Bind(wx.EVT_TOOL, self.on_save, id
=wx.ID_SAVE)
self.Bind(wx.EVT_TOOL, self.on_exit, id
=wx.ID_EXIT)#设置窗口 self.SetSize((600, 400))
self.Centre()
self.mgr.Update()
#更新 AUI 管理器 defon_open(self, event):
wx.MessageBox(
"Open clicked", "Info", wx.OK |wx.ICON_INFORMATION)defon_save(self, event):
wx.MessageBox(
"Save clicked", "Info", wx.OK |wx.ICON_INFORMATION)defon_exit(self, event):
self.Close()
defOnClose(self, event):
self.mgr.UnInit()
#清理 AUI 管理器 self.Destroy()classMyApp(wx.App):defOnInit(self):
frame
= MyFrame(None, title="wxPython AuiToolBar Example")
frame.Show()
returnTrueif __name__ == "__main__":
app
=MyApp()
app.MainLoop()

3).
主要方法与属性

  • AddTool(id, label, bitmap, short_help="", long_help="")
    :向工具栏添加工具按钮。
  • AddSeparator()
    :添加分隔符。
  • AddControl(control)
    :向工具栏添加一个控件,如文本框、下拉框等。
  • Realize()
    :确认并显示工具栏布局。
  • SetToolDropDown(id, enable=True)
    :为工具按钮启用下拉菜单功能。

4).
常用样式

wx.aui.AuiToolBar
提供了一些样式选项,可以灵活控制工具栏的外观和行为:

  • AUI_TB_TEXT
    :显示按钮文本。
  • AUI_TB_NO_TOOLTIPS
    :不显示工具提示。
  • AUI_TB_HORZ_LAYOUT
    :水平布局,文本在图标右侧。
  • AUI_TB_PLAIN_BACKGROUND
    :使用简单背景,不使用默认渐变效果。
  • AUI_TB_OVERFLOW
    :启用溢出按钮,用于显示更多按钮。

wx.aui.AuiToolBar
支持添加自定义控件,例如下拉框、文本框等,可以为工具按钮添加下拉菜单功能。

AUI 的工具栏可以动态更新,比如禁用、启用、添加、移除工具按钮:

5)、AUI 管理器与工具栏

AuiManager
负责管理所有 AUI 组件,工具栏也是其中之一。可以通过
AuiPaneInfo
配置工具栏的停靠位置、浮动、隐藏等特性:

pane_info = wx.aui.AuiPaneInfo().Name("Toolbar").Top().Caption("Main Toolbar").Floatable(True)
self.mgr.AddPane(toolbar, pane_info)

wx.aui.AuiToolBar
提供了比标准
wx.ToolBar
更强大和灵活的功能,尤其适合需要动态布局和自定义界面的应用程序。通过
AuiManager
管理工具栏,可以轻松实现拖放、浮动、隐藏等高级界面特性,使应用程序更具可操作性和用户体验。

2、工具栏的折叠和二级菜单的处理

对于工具栏,我们一般需要创建一些常用的按钮,如果界面折叠,工具栏可以进行折叠到一处,方便查看,如下界面所示(MacOS系统表现界面)。

折叠效果对于不同平台的表现效果是一致的,如下是Windows下的展现效果。

而对于工具栏有二级或者更多功能点的处理,也需要考虑,我们可以把它们放在左侧树列表中,进行展开更多的功能。

如下所示是我在一级【权限管理系统】中存在二级菜单,因此让它打开的时候,在左侧树列表中更加直观的体现出来,如下效果所示(Windows 系统表现界面)。

3、系统程序的动态工具的创建处理

上面的最终效果,我们会通过后端的数据库进行存储,动态在界面上进行展示,可以根据角色用户的权限进行分配显示即可。

为了实现这个目标,我们先定义菜单/工具栏的存储对象信息,如下所示。

@dataclassclassMenuInfo:
id: str
#菜单ID pid: str #父菜单ID label: str #菜单名称 icon: str = None #菜单图标 path: str = None #菜单路径,用来定位视图 tips: str = None #菜单提示 children: list["MenuInfo"] = None

对于单个工具栏信息,如下所示。

MenuInfo(
id
="01",
label
="用户管理",
icon
="user",
path
="views.testaui_panel.DocumentPanel",
),

其中icon, 我们根据内置的ART_图标或者自己定义的集合图标来处理即可。而Path是用来在模块中获得窗体界面的路径,我们动态根据路径来构建界面类。

而对于主工具栏上展示的多级菜单,我们也是通过上面的类来定义嵌套的集合即可,如下所示。

MenuInfo(
id
="11",
label
="权限管理系统",
icon
="computer_key",
children
=[
MenuInfo(
id
="11-1",
label
="用户管理",
icon
="user",
path
="views.testaui_panel.DocumentPanel",
),
MenuInfo(
id
="11-2",
label
="组织机构管理",
icon
="organ",
path
="views.my_banner.BannerDialog2",
),
...
]
}

在系统开发的初期,我们可以先试用模拟方式获得数据集合,如通过一个工具来来获得数据,如下所示。

以上的菜单集合,我们对接后端FastAPI+SqlAlchemy接口后,即可动态获取,有关 【后端FastAPI+SqlAlchemy接口开发】,大家可以参考随笔介绍。

基于SqlAlchemy+Pydantic+FastApi的Python开发框架的路由处理

基于SqlAlchemy+Pydantic+FastApi的Python开发框架

使用FastAPI来开发项目,项目的目录结构如何规划的一些参考和基类封装的一些处理

在使用FastAPI处理数据输入的时候,对模型数据和路径参数的一些转换处理

前面介绍了一下关于wx.aui.AuiToolBar的一些内容,我们按照上面的创建工具栏,如下代码所示。

defcreate_toolbars(self):"""创建工具栏"""self.tb1= self._create_toolbar()  #工具栏
self.mgr.AddPane(
self.tb1,
aui.AuiPaneInfo()
.Name(
"ToolBar1")
.Caption(
"工具条")
.ToolbarPane()
.Top()
.Row(0)
.Position(0)
.Floatable(False),
)

其中mgr是我们的Aui界面管理类,如下定义。

#初始化AuiManager,用来管理工具栏以及AuiNotebook等
self.mgr = aui.AuiManager(self)

其中_create_toolbar的处理就是获得工具栏集合并动态处理的,如下代码所示。

toolbars =ToolbarUtil.create_tools()#遍历工具栏进行处理
for item intoolbars:
tool_id
=wx.NewIdRef()
help_string
= item.tips if item.tips elseitem.label
bitmap
=get_bitmap(item.icon)
tb.AddSimpleTool(
tool_id
=tool_id,
label
=item.label,
bitmap
=bitmap,
short_help_string
=help_string,
)
#绑定事件 self.Bind(
wx.EVT_TOOL,
partial(self.on_tool_event, item),
#这里传递菜单信息 id=tool_id,
)

同时我们添加一些常见的折叠、关于、关闭窗口的常用工具栏,并绑定事件处理,如下代码所示。

self.Bind( wx.EVT_TOOL,lambdaevent: EventPub.toggle_left_panel(),id=self.id_show_hide_left)
self.Bind( wx.EVT_TOOL,
lambda event: EventPub.show_about_dialog(), id=self.id_about)
self.Bind( wx.EVT_TOOL,
lambda event: EventPub.close_all_page(), id=self.id_close_all)
tb.Realize()
return tb

其中对于一些全局的事件处理,我们通过pypubsub 组件进行事件的推动和接收处理。

如下代码,我们定义一个EventPub类来处理,如下代码所示。

from pubsub importpubfrom entity.common importMenuInfo#定义事件处理类,统一处理事件发布接口
classEventPub:"""事件发布者"""@staticmethoddef send_event(event_name: str, data=None):"""发送事件"""pub.sendMessage(event_name, data=data)

@staticmethod
def show_window(data: MenuInfo, hide_toolbox=True):"""打开窗口"""pub.sendMessage("show_window", info=data, hide_toolbox=hide_toolbox)

......................

在主窗体初始化的时候,我们会跟踪通过EventPub推送的事件进行处理,如下是对于窗体或者对话框的统一处理,通过动态构建视图对象,我们就可以让它显示在主界面的notbook控件里面了。

对于下面的界面,我们就是通过动态的路径进行统一的构建显示在主面板中的,效果如下所示。

当然MacOS里面的效果也是差不多的,换个窗体界面如下所示。

在PyCharm中打包Python项目并将其运行到服务器上的方法

在PyCharm中打包Python项目并将其运行到服务器上的过程,可以分解为几个关键步骤:创建项目、设置项目依赖、打包项目、配置服务器环境、上传可执行文件到服务器以及运行项目。以下是一个详细的指南,包括完整的代码示例,这些代码可以直接运行。

一、创建并设置Python项目

  1. 打开PyCharm并创建新项目:
    • 打开PyCharm,点击“File”菜单,选择“New Project”。
    • 设置项目名称和路径,确保勾选“Create virtual environment”以使用虚拟环境。
    • 点击“OK”完成项目创建。
  2. 设置项目依赖:
    • 在PyCharm中,点击“File”菜单,选择“Settings”。
    • 在左侧面板选择“Project: [项目名称]”,然后点击“Python Interpreter”选项卡。
    • 在右侧面板中,如果项目使用虚拟环境,切换到虚拟环境,并点击“+”按钮添加所需的第三方库(例如,
      flask
      )。

二、编写项目代码

在项目结构中添加Python文件,例如
main.py
,并编写代码。以下是一个简单的Flask Web应用示例:

# main.py
from flask import Flask
 
app = Flask(__name__)
 
@app.route('/')
def hello_world():
    return 'Hello, World!'
 
if __name__ == '__main__':
    app.run()

三、打包项目

  1. 安装PyInstaller:


    • 打开PyCharm的终端(Terminal)。

    • 输入以下命令安装PyInstaller:

      bash复制代码
      
      pip install pyinstaller
      
  2. 配置PyInstaller:


    • 在PyCharm中,通常不需要额外配置PyInstaller,除非有特定的需求。
  3. 打包项目:


    • 在终端中,导航到项目目录。

    • 输入以下命令打包项目:

      bash复制代码
      
      pyinstaller --onefile main.py
      
    • 这将生成一个
      dist
      目录,其中包含打包后的可执行文件。

四、配置服务器环境

  1. 选择并连接到服务器:


    • 确保服务器已经安装了Python环境。

    • 使用SSH连接到服务器,并检查Python版本:

      ssh username@your_server_ip
      python --version
      
  2. 安装依赖(如果需要):


    • 如果项目使用了第三方库,需要在服务器上安装这些库。例如,如果使用了Flask:

      bash复制代码
      
      pip install flask
      

五、上传可执行文件到服务器

使用
scp
命令将打包后的可执行文件上传到服务器:

bash复制代码

scp dist/main username@your_server_ip:/path/to/destination

六、在服务器上运行项目

  1. 登录到服务器

    bash复制代码
    
    ssh username@your_server_ip
    
  2. 导航到可执行文件所在的目录

    bash复制代码
    
    cd /path/to/destination
    
  3. 运行可执行文件

    bash复制代码
    
    ./main
    

七、注意事项

  • 如果我们的Flask应用需要运行在特定端口,确保服务器的防火墙已经开放了相应端口。

  • 如果想让应用在后台运行,可以使用

    nohup
    

    命令:

    bash复制代码
    
    nohup ./main &
    
  • 如果我们的项目需要与数据库交互,需要在服务器上安装相应的数据库驱动程序并配置连接信息。

八、总结

通过上述步骤,我们能够成功地将PyCharm中的Python项目打包并运行到服务器上。这一过程不仅帮助我们学习了一些基本的命令和工具的使用,还强化了对项目部署流程的理解。打包和部署是软件开发中不可或缺的一部分,掌握这些技能后,我们将能够更专业地进行程序开发和管理。

书接上回,我们继续来分享一些关于特殊时间获取的常用扩展方法。

01
、获取当天的开始时间

当天的开始时间指00:00:00时刻,因此只需要获取DateTime的Date属性只获取时间即可,具体代码如下:

//获取当天的开始时间
public static DateTime GetStartDateTimeOfDay(this DateTime dateTime)
{
    return dateTime.Date;
}

我们进行一个简单的单元测试,具体代码如下:

[Fact]
public void GetStartDateTimeOfDay()
{
    var datetime = new DateTime(2024, 11, 7, 14, 10, 10);
    var start = datetime.GetStartDateTimeOfDay();
    Assert.Equal(new DateTime(2024, 11, 7), start);
}

02
、获取当天的结束时间

该方法时候获取一天中最后一刻,也就是第二天的前一刻,我们可以用第二天的开始时间减去最小时间单位得到当天的结束时间,具体代码如下:

//获取当天的结束时间
public static DateTime GetEndDateTimeOfDay(this DateTime dateTime)
{
    return dateTime.Date.AddDays(1).AddTicks(-1);
}

下面我们通过单元测试验证,时间部分是否为“23:59:59 9999999”,具体代码如下:

[Fact]
public void GetEndDateTimeOfDay()
{
    var date4 = new DateTime(2024, 11, 7, 14, 10, 10);
    var end = date4.GetEndDateTimeOfDay();
    Assert.Equal("2024-11-07 23:59:59 9999999", end.ToString("yyyy-MM-dd HH:mm:ss fffffff"));
}

03
、获取当前日期所在周的第一天(周一)

要想获得当前日期所在周的周一,只需要知道当前是周几,然后计算出和周一相差几天,最后使用AddDays方法即可。

首先我们可以通过DayOfWeek获取到日期是周几枚举值,但是这个枚举值对应的int值是

0 = Sunday 周日, 1 = Monday 周一, ..., 6 = Saturday 周六。其中周日的0就显得很异类,处理起来也就比较麻烦。

因此如果当前日期是周日那么就会出现周日减周一等于0减1等于-1的情况,所有我们需要加7来保证结果为正数。

同样如果当前日期是周六那么就会出现周六减周一等于6减1加7等于12的情况,所以我们需要同取余%7,来保证两者相差在一周天数之内。

具体代码如下:

//获取当前日期所在周的第一天(周一)
public static DateTime GetFirstDayDateTimeOfWeek(this DateTime dateTime)
{
    //0 = Sunday 周日, 1 = Monday 周一, ..., 6 = Saturday 周六
    //首先获取当前日期星期枚举值,然后计算其和周一枚举值差值
    //结果+7,保证结果为正数
    //结果%7,保证结果在0-6之间,对于一周七天,从而表示要回退多少天到周一
    //+7 %7 巧妙的把周日当7处理,最后再转为0
    var diff = ((int)dateTime.DayOfWeek - (int)DayOfWeek.Monday + 7) % 7;
    return dateTime.AddDays(-diff).Date;
}

下面我们需要进行详细的单元测试,我们进行了四种情况的测试分别是:

(1) 验证当前日期是周五,而周一在上一个月的情况;

(2) 验证当前日期就是周一的情况;

(3) 验证当前日期是周四,而周一在当月的情况

(4) 验证当前日期是周日,而周一在当月的情况

具体代码如下:

[Fact]
public void GetFirstDayDateTimeOfWeek()
{
    //验证当前日期是周五,而周一在上一个月的情况
    var friday = new DateTime(2024, 11, 1, 14, 10, 10);
    var day_friday = friday.GetFirstDayDateTimeOfWeek();
    Assert.Equal(new DateTime(2024, 10, 28), day_friday);
    //验证当前日期就是周一的情况
    var monday = new DateTime(2024, 11, 4, 4, 10, 10);
    var day_monday = monday.GetFirstDayDateTimeOfWeek();
    Assert.Equal(new DateTime(2024, 11, 4), day_monday);
    //验证当前日期是周四的情况
    var thursday = new DateTime(2024, 11, 7, 4, 10, 10);
    var day_thursday = thursday.GetFirstDayDateTimeOfWeek();
    Assert.Equal(new DateTime(2024, 11, 4), day_thursday);
    //验证当前日期是周日的情况
    var sunday = new DateTime(2024, 11, 10, 4, 10, 10);
    var day_sunday = sunday.GetFirstDayDateTimeOfWeek();
    Assert.Equal(new DateTime(2024, 11, 4), day_sunday);
}

04
、获取当前日期所在周的最后一天(周日)

该方法和上面获取周一的思想一样,我们可以把周日枚举值就当作7来处理,具体代码如下:

//获取当前日期所在周的最后一天(周日)
public static DateTime GetLastDayDateTimeOfWeek(this DateTime dateTime)
{
    //0 = Sunday 周日, 1 = Monday 周一, ..., 6 = Saturday 周六
    //首先计算还差几天到周日
    //结果%7,保证结果在0-6之间
    //当周日时dateTime.DayOfWeek为0,(7-0)% 7 = 0
    //巧妙的把周日当7处理,最后再转为0
    var diff = (7 - (int)dateTime.DayOfWeek) % 7;
    return dateTime.AddDays(diff).Date;
}

同样的我们做类似获取周一的四种情况单元测试,具体代码如下:

[Fact]
public void GetLastDayDateTimeOfWeek()
{
    //验证当前日期是周六,而周日在下一个月的情况
    var sunday = new DateTime(2024, 11, 30, 14, 10, 10);
    var day_sunday = sunday.GetLastDayDateTimeOfWeek();
    Assert.Equal(new DateTime(2024, 12, 1), day_sunday);
    //验证当前日期就是周一的情况
    var monday = new DateTime(2024, 11, 4, 4, 10, 10);
    var day_monday = monday.GetLastDayDateTimeOfWeek();
    Assert.Equal(new DateTime(2024, 11, 10), day_monday);
    //验证当前日期是周四的情况
    var thursday = new DateTime(2024, 11, 7, 4, 10, 10);
    var day_thursday = thursday.GetLastDayDateTimeOfWeek();
    Assert.Equal(new DateTime(2024, 11, 10), day_thursday);
    //验证当前日期是周日的情况
    var sunday1 = new DateTime(2024, 11, 10, 4, 10, 10);
    var day_thursday1 = sunday1.GetLastDayDateTimeOfWeek();
    Assert.Equal(new DateTime(2024, 11, 10), day_thursday1);
}

05
、获取当前日期所在月的第一天

这个方法比较简单,只需要使用当前日期的年份和月份,然后直接构建当月第一天,具体代码如下:

//获取当前日期所在月的第一天
public static DateTime GetFirstDayDateTimeOfMonth(this DateTime dateTime)
{
    return new DateTime(dateTime.Year, dateTime.Month, 1, 0, 0, 0, 0, DateTimeKind.Local);
}

这个方法太简单了,我们就不列出单元测试代码了。

06
、获取当前日期所在月的最后一天

该方便也不复杂,可以先通过DaysInMonth获取当前月的总天数,然后再构建当月最后一天,具体代码如下:

//获取当前日期所在月的最后一天
public static DateTime GetLastDayDateTimeOfMonth(this DateTime dateTime)
{
    //获取当前月的总天数
    var days = DateTime.DaysInMonth(dateTime.Year, dateTime.Month);
    return new DateTime(dateTime.Year, dateTime.Month, days, 0, 0, 0, 0, DateTimeKind.Local);
}

同样的我们这个方法也不复杂,我们就不列举单元测试了。

07
、获取当前日期所在季度的第一天

如果想要获取当前日期所在季度的第一天那么首先需要获取当前日期所在季度的第一个月是多少。

我们知道三个月为一季度,因此我们可以使用当前月份除以3,如果这样直接除就会得到:1/3=0,2/3=0,3/3=1,这样1月到3月就不在同一个季度里,所以我们使用(moth - 1)/ 3,计算出0、1、2、3表示4个季度,这样就可以计算出当前日期所在第几个季度。

计算出所在季度后我们还需要计算出当前季度的第一个月即1月、4月、7月、10月,然后找出这4个月份与上面表示4个季度值的关系即可,最终得到如下公式:(moth - 1)/ 3 * 3 +1,即为当前日期所在季度的第一个月。

最后就是直接构建日期,具体代码如下:

//获取当前日期所在季度的第一天
public static DateTime GetFirstDayDateTimeOfQuarter(this DateTime dateTime)
{
    //计算当前日期所在季度的起始月
    var firstMonth = (dateTime.Month - 1) / 3 * 3 + 1;
    return new DateTime(dateTime.Year, firstMonth, 1, 0, 0, 0, 0, DateTimeKind.Local);
}

然后我们分别对这个方法做以下三种情况的单元测试:

(1) 一个季度第一个月取第一天的情况;

(2) 一个季度第二个月取中间的一天的情况;

(3) 一个季度第三个月取最后一天的情况;

[Fact]
public void GetFirstDayDateTimeOfQuarter()
{
    //一个季度第一个月取第一天的情况
    var month1 = new DateTime(2024, 10, 1, 14, 10, 10);
    var day_month1 = month1.GetFirstDayDateTimeOfQuarter();
    Assert.Equal(new DateTime(2024, 10, 1), day_month1);
    //一个季度第二个月取中间的一天的情况
    var month2 = new DateTime(2024, 11, 17, 4, 10, 10);
    var day_month2 = month2.GetFirstDayDateTimeOfQuarter();
    Assert.Equal(new DateTime(2024, 10, 1), day_month2);
    //一个季度第三个月取最后一天的情况
    var month3 = new DateTime(2024, 12, 31, 4, 10, 10);
    var day_month3 = month3.GetFirstDayDateTimeOfQuarter();
    Assert.Equal(new DateTime(2024, 10, 1), day_month3);
}

08
、获取当前日期所在季度的最后一天

该方法和上面获取季度的第一天思想一样,只是此方法获取当前日期所在季度的最后月份的计算公式有所差异,公式为:(moth + 2)/ 3 * 3,具体代码如下:

//获取当前日期所在季度的最后一天
public static DateTime GetLastDayDateTimeOfQuarter(this DateTime dateTime)
{
    //计算当前日期所在季度的最后月
    var lastMonth = (dateTime.Month + 2) / 3 * 3;
    //获取当前月的总天数
    var days = DateTime.DaysInMonth(dateTime.Year, lastMonth);
    return new DateTime(dateTime.Year, lastMonth, days, 0, 0, 0, 0, DateTimeKind.Local);
}

同样的我们对其进行三种情况单元测试,具体代码如下:

[Fact]
public void GetLastDayDateTimeOfQuarter()
{
    //一个季度第一个月取第一天的情况
    var month1 = new DateTime(2024, 10, 1, 14, 10, 10);
    var day_month1 = month1.GetLastDayDateTimeOfQuarter();
    Assert.Equal(new DateTime(2024, 12, 31), day_month1);
    //一个季度第二个月取中间的一天的情况
    var month2 = new DateTime(2024, 11, 17, 4, 10, 10);
    var day_month2 = month2.GetLastDayDateTimeOfQuarter();
    Assert.Equal(new DateTime(2024, 12, 31), day_month2);
    //一个季度第三个月取最后一天的情况
    var month3 = new DateTime(2024, 12, 31, 4, 10, 10);
    var day_month3 = month3.GetLastDayDateTimeOfQuarter();
    Assert.Equal(new DateTime(2024, 12, 31), day_month3);
}

09
、获取当前日期所在年的第一天

该方法比较简单,直接用当前日期所在年份和1月1号直接构建即可,代码如下:

//获取当前日期所在年的第一天
public static DateTime GetFirstDayDateTimeOfYear(this DateTime dateTime)
{
    return new DateTime(dateTime.Year, 1, 1, 0, 0, 0, 0, DateTimeKind.Local);
}

10
、获取当前日期所在年的最后一天

该方法也比较简单,直接用当前日期所在年份和12月31号直接构建即可,代码如下:

//获取当前日期所在年的最后一天
public static DateTime GetLastDayDateTimeOfYear(this DateTime dateTime)
{
    return new DateTime(dateTime.Year, 12, 31, 0, 0, 0, 0, DateTimeKind.Local);
}

稍晚些时候我会把库上传至Nuget,大家可以直接使用Ideal.Core.Common。


:测试方法代码以及示例源码都已经上传至代码库,有兴趣的可以看看。
https://gitee.com/hugogoos/Ideal

【1】引言(完整代码在最后面)

本项目旨在实现一个简单的“抛硬币”功能,用户可以通过点击屏幕上的地鼠图标来模拟抛硬币的过程。应用会记录并显示硬币正面(地鼠面)和反面(数字100面)出现的次数。为了增强用户体验,我们还添加了动画效果,使抛硬币的过程更加生动有趣。

【2】环境准备

电脑系统:windows 10

开发工具:DevEco Studio NEXT Beta1 Build Version: 5.0.3.806

工程版本:API 12

真机:mate60 pro

语言:ArkTS、ArkUI

【3】应用结构

应用主要由两个部分组成:地鼠组件(Hamster)和主页面组件(CoinTossPage)。

地鼠组件(Hamster)

地鼠组件是应用的核心视觉元素之一,负责展示地鼠的形象。该组件通过@Component装饰器定义,并接收一个属性cellWidth,用于控制组件的大小。

主页面组件(CoinTossPage)

主页面组件是整个应用的入口点,负责组织和管理各个UI元素。该组件同样通过@Component装饰器定义,并包含多个状态变量用于跟踪硬币的状态和动画进度。

【4】功能解析

1. 地鼠组件:

• 通过Stack布局组合多个图形元素,创建了一个地鼠的形象。

• 每个图形元素都设置了具体的尺寸、颜色、边框等样式,并通过margin属性调整位置。

2. 主页面组件:

• 顶部有一个“抛硬币”的标题,下方是一个行布局,用于展示地鼠组件及正反两面出现的次数。

• 地鼠组件被放置在一个圆形区域内,背景采用线性渐变色。

• 点击地鼠时,会触发一系列动画效果,模拟硬币抛起再落下的过程。

• 通过计算最终的角度,判断是正面还是反面朝上,并更新相应的计数。

【完整代码】

// 定义地鼠组件
@Component
struct Hamster {
  @Prop cellWidth: number // 单元格宽度

  build() {
    Stack() { // 创建一个堆叠布局
      // 身体
      Text()
        .width(`${this.cellWidth / 2}lpx`)// 宽度为单元格宽度的一半
        .height(`${this.cellWidth / 3 * 2}lpx`)// 高度为单元格高度的2/3
        .backgroundColor("#b49579")// 背景颜色
        .borderRadius({ topLeft: '50%', topRight: '50%' })// 圆角
        .borderColor("#2a272d")// 边框颜色
        .borderWidth(1) // 边框宽度
      // 嘴巴
      Ellipse()
        .width(`${this.cellWidth / 4}lpx`)// 嘴巴的宽度
        .height(`${this.cellWidth / 5}lpx`)// 嘴巴的高度
        .fillOpacity(1)// 填充不透明度
        .fill("#e7bad7")// 填充颜色
        .stroke("#563e3f")// 边框颜色
        .strokeWidth(1)// 边框宽度
        .margin({ top: `${this.cellWidth / 6}lpx` }) // 上边距
      // 左眼睛
      Ellipse()
        .width(`${this.cellWidth / 9}lpx`)// 左眼睛的宽度
        .height(`${this.cellWidth / 6}lpx`)// 左眼睛的高度
        .fillOpacity(1)// 填充不透明度
        .fill("#313028")// 填充颜色
        .stroke("#2e2018")// 边框颜色
        .strokeWidth(1)// 边框宽度
        .margin({ bottom: `${this.cellWidth / 3}lpx`, right: `${this.cellWidth / 6}lpx` }) // 下边距和右边距
      // 右眼睛
      Ellipse()
        .width(`${this.cellWidth / 9}lpx`)// 右眼睛的宽度
        .height(`${this.cellWidth / 6}lpx`)// 右眼睛的高度
        .fillOpacity(1)// 填充不透明度
        .fill("#313028")// 填充颜色
        .stroke("#2e2018")// 边框颜色
        .strokeWidth(1)// 边框宽度
        .margin({ bottom: `${this.cellWidth / 3}lpx`, left: `${this.cellWidth / 6}lpx` }) // 下边距和左边距
      // 左眼瞳
      Ellipse()
        .width(`${this.cellWidth / 20}lpx`)// 左眼瞳的宽度
        .height(`${this.cellWidth / 15}lpx`)// 左眼瞳的高度
        .fillOpacity(1)// 填充不透明度
        .fill("#fefbfa")// 填充颜色
        .margin({ bottom: `${this.cellWidth / 2.5}lpx`, right: `${this.cellWidth / 6}lpx` }) // 下边距和右边距
      // 右眼瞳
      Ellipse()
        .width(`${this.cellWidth / 20}lpx`)// 右眼瞳的宽度
        .height(`${this.cellWidth / 15}lpx`)// 右眼瞳的高度
        .fillOpacity(1)// 填充不透明度
        .fill("#fefbfa")// 填充颜色
        .margin({ bottom: `${this.cellWidth / 2.5}lpx`, left: `${this.cellWidth / 6}lpx` }) // 下边距和左边距
    }.width(`${this.cellWidth}lpx`).height(`${this.cellWidth}lpx`) // 设置组件的宽度和高度
  }
}

// 定义页面组件
@Entry
@Component
struct CoinTossPage {
  @State cellWidth: number = 50 // 单元格宽度
  @State headsCount: number = 0 // 正面朝上的次数
  @State tailsCount: number = 0 // 反面朝上的次数
  @State rotationAngle: number = 0 // 旋转角度
  @State verticalOffset: number = 0 // 纵向位移
  @State isAnimRun: boolean = false // 动画是否正在执行

  build() {
    Column() {
      // 页面标题
      Text('抛硬币')
        .height(50)// 高度设置为50
        .width('100%')// 宽度设置为100%
        .textAlign(TextAlign.Center)// 文本居中对齐
        .fontColor("#fefefe")// 字体颜色
        .fontSize(20); // 字体大小

      // 显示地鼠和计数
      Row({ space: 20 }) {
        Stack() {
          Hamster({ cellWidth: this.cellWidth }) // 创建地鼠组件
        }
        .borderRadius('50%') // 设置圆角
        .width(`${this.cellWidth}lpx`) // 设置宽度
        .height(`${this.cellWidth}lpx`) // 设置高度
        .linearGradient({
          // 设置线性渐变背景
          direction: GradientDirection.LeftBottom,
          colors: [['#ebcf2f', 0.0], ['#fef888', 0.5], ['#ebcf2f', 1.0]]
        });

        // 显示反面朝上的次数
        Text(`${this.tailsCount}`)
          .fontSize(20)
          .fontColor("#fefefe");

        Stack() {
          // 显示100
          Text("100")
            .fontColor("#9f7606")
            .fontSize(`${this.cellWidth / 2}lpx`);
        }
        .borderRadius('50%') // 设置圆角
        .width(`${this.cellWidth}lpx`) // 设置宽度
        .height(`${this.cellWidth}lpx`) // 设置高度
        .linearGradient({
          // 设置线性渐变背景
          direction: GradientDirection.LeftBottom,
          colors: [['#ebcf2f', 0.0], ['#fef888', 0.5], ['#ebcf2f', 1.0]]
        });

        // 显示正面朝上的次数
        Text(`${this.headsCount}`)
          .fontSize(20)
          .fontColor("#fefefe");

      }.width('100%').justifyContent(FlexAlign.Center); // 设置宽度和内容居中对齐

      Stack() {
        Stack() {
          // 创建放大版地鼠组件
          Hamster({ cellWidth: this.cellWidth * 3 })
            .visibility(this.isHeadsFaceUp() ? Visibility.Visible : Visibility.Hidden); // 根据状态显示或隐藏

          // 显示100
          Text("100")
            .fontColor("#9f7606")// 字体颜色
            .fontSize(`${this.cellWidth / 2 * 3}lpx`)// 字体大小
            .visibility(!this.isHeadsFaceUp() ? Visibility.Visible : Visibility.Hidden)// 根据状态显示或隐藏
            .rotate({
              // 旋转180度
              x: 1,
              y: 0,
              z: 0,
              angle: 180
            });
        }
        .borderRadius('50%') // 设置圆角
        .width(`${this.cellWidth * 3}lpx`) // 设置宽度
        .height(`${this.cellWidth * 3}lpx`) // 设置高度
        .linearGradient({
          // 设置线性渐变背景
          direction: GradientDirection.LeftBottom,
          colors: [['#ebcf2f', 0.0], ['#fef888', 0.5], ['#ebcf2f', 1.0]]
        })
        .rotate({
          // 根据当前角度旋转
          x: 1,
          y: 0,
          z: 0,
          angle: this.rotationAngle
        })
        .translate({ x: 0, y: this.verticalOffset }) // 设置纵向位移
        .onClick(() => { // 点击事件处理

          if (this.isAnimRun) {
            return;
          }
          this.isAnimRun = true

          let maxAnimationSteps = 2 * (10 + Math.floor(Math.random() * 10)); // 计算最大动画次数
          let totalAnimationDuration = 2000; // 动画总时长

          // 第一次动画,向上抛出
          animateToImmediately({
            duration: totalAnimationDuration / 2, // 动画时长为总时长的一半
            onFinish: () => { // 动画完成后的回调
              // 第二次动画,向下落
              animateToImmediately({
                duration: totalAnimationDuration / 2,
                onFinish: () => {
                  this.rotationAngle = this.rotationAngle % 360; // 确保角度在0到360之间
                  // 判断当前显示的面
                  if (this.isHeadsFaceUp()) { // 如果是地鼠面
                    this.tailsCount++; // 反面朝上的次数加1
                  } else { // 如果是反面
                    this.headsCount++; // 正面朝上的次数加1
                  }
                  this.isAnimRun = false
                }
              }, () => {
                this.verticalOffset = 0; // 重置纵向位移
              });
            }
          }, () => {
            // 设置纵向位移,模拟抛硬币的效果
            this.verticalOffset = -100 * (1 + Math.floor(Math.random() * 5)); // 随机设置向上的位移
          });

          // 循环动画,增加旋转效果
          for (let i = 0; i < maxAnimationSteps; i++) {
            animateToImmediately({
              delay: i * totalAnimationDuration / maxAnimationSteps, // 设置每次动画的延迟
              duration: 100, // 每次动画的持续时间
              onFinish: () => {
                // 动画完成后的回调
              }
            }, () => {
              this.rotationAngle += 90; // 每次增加90度旋转
            });
          }
        });

      }.width('100%').layoutWeight(1).align(Alignment.Bottom).padding({ bottom: 80 }); // 设置组件的宽度、权重、对齐方式和底部内边距
    }
    .height('100%') // 设置整个页面的高度
    .width('100%') // 设置整个页面的宽度
    .backgroundColor("#0b0d0c"); // 设置背景颜色
  }

  // 判断当前是否显示地鼠面
  isHeadsFaceUp() {
    let normalizedAngle = this.rotationAngle % 360; // 规范化角度
    // 判断角度范围,确定是否显示地鼠面
    if (normalizedAngle >= 0 && normalizedAngle < 90 || normalizedAngle >= 270 && normalizedAngle <= 360) {
      return true; // 显示地鼠面
    }
    return false; // 显示反面
  }
}